From 085479bfd42c66d922fdfc3e554bc8ca14570312 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:28:55 -0700 Subject: [PATCH 001/183] ci: release notes batch 10 commits per GraphQL query and retry GitHub 5xx (#20412) The 0.65.2 RC publish (run 38501752257) failed in the release notes: a new RC line summarizes ~2,000 commits since the lines' merge base, and GitHub answered a 25-commit association query with HTTP 502. Locally the same range completes with 10 commits per query and 5xx retries (1,328 PRs, ~19 min). Co-authored-by: Claude Opus 5.5 --- scripts/ci/nightly_release_notes.py | 16 +++++++++++++--- tests/test_nightly_release_notes.py | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/ci/nightly_release_notes.py b/scripts/ci/nightly_release_notes.py index b5cd3906c0f2..ef200ab1ea79 100755 --- a/scripts/ci/nightly_release_notes.py +++ b/scripts/ci/nightly_release_notes.py @@ -8,11 +8,13 @@ import re import subprocess import sys +import time from urllib.parse import quote from xml.dom import minidom MARKER = re.compile(r"", re.I) SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +COMMIT_BATCH = 10 REPAIR = "https://github.com/manaflow-ai/cmuxterm-hq/blob/main/REPAIR.md" @@ -22,7 +24,13 @@ def __init__(self, repo: str): @staticmethod def request(args: list[str]) -> dict: - result = subprocess.run(["gh", "api", *args], capture_output=True, text=True, timeout=90) + # GitHub answers a heavy GraphQL batch with a transient 5xx now and + # then; retry those with backoff. Other failures stop immediately. + for attempt in range(4): + result = subprocess.run(["gh", "api", *args], capture_output=True, text=True, timeout=90) + if not result.returncode or not re.search(r"HTTP 5\d\d", result.stderr) or attempt == 3: + break + time.sleep(2 ** (attempt + 1)) if result.returncode: raise RuntimeError("GitHub metadata request failed; check Actions token contents/pull-requests read access and API availability") value = json.loads(result.stdout) @@ -68,9 +76,11 @@ def collect_prs(github: GitHub, base: str, head: str, branch: str) -> list[dict] owner, name = github.repo.split("/") members = set(commits) prs = {} - for offset in range(0, len(commits), 25): + # Each commit asks for up to 10 PRs with 100 labels and 100 files; GitHub + # answers 25 commits per query with HTTP 502, so batch fewer. + for offset in range(0, len(commits), COMMIT_BATCH): objects = [] - for index, sha in enumerate(commits[offset:offset + 25]): + for index, sha in enumerate(commits[offset:offset + COMMIT_BATCH]): objects.append(f'''c{index}: object(oid: "{sha}") {{ ... on Commit {{ associatedPullRequests(first: 10) {{ pageInfo {{ hasNextPage }} nodes {{ number title url mergedAt baseRefName mergeCommit {{ oid }} labels(first: 100) {{ nodes {{ name }} }} diff --git a/tests/test_nightly_release_notes.py b/tests/test_nightly_release_notes.py index fcb909a6a668..95ef7ed1d66f 100755 --- a/tests/test_nightly_release_notes.py +++ b/tests/test_nightly_release_notes.py @@ -209,13 +209,13 @@ def test_batched_commit_queries_collect_later_prs(self): def graphql(query): objects = re.findall(r'(c\d+): object\(oid: "([0-9a-f]{40})"\)', query) self.assertGreater(len(objects), 0) - self.assertLessEqual(len(objects), 25) + self.assertLessEqual(len(objects), NOTES.COMMIT_BATCH) return {alias: self.associations(expected) if sha == shas[-1] else self.associations() for alias, sha in objects} github.graphql.side_effect = graphql self.assertEqual(NOTES.collect_prs(github, BASE, HEAD, "main"), [expected]) - self.assertEqual(github.graphql.call_count, 2) + self.assertEqual(github.graphql.call_count, 3) def test_identical_publication_needs_no_metadata_requests(self): github = Mock(repo=REPO) From f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 20:40:46 -0700 Subject: [PATCH 002/183] fix(cmux-tui): recover checkpoint capture from unreplayable Kitty uploads (#20259) * test(cmux-tui): reproduce checkpoint capture on oversized kitty upload * fix(cmux-tui): recover checkpoints from unreplayable terminal input * test(cmux-tui): cover safe checkpoint recovery boundaries * fix(cmux-tui): fence checkpoint replay recovery * fix(cmux-tui): reset metadata on checkpoint abort * fix(cmux-tui): reset metadata on checkpoint abort * cmux-tui: refuse local replay recovery for hosted terminals * cmux-tui: journal synthetic replay recovery bytes * cmux-tui: apply hosted rustfmt to checkpoint recovery * test(cmux-tui): keep checkpoint recovery attachment test bounded * cmux-tui: fix journal recovery regression test * fix(ghostty-vt): recover oversized Kitty replay * fix(cmux-tui): detect Kitty replay overflow * fix(ghostty-vt): reject completed Kitty overflow recovery * test(cmux-tui): avoid replay size assumption * test(cmux-tui): tolerate merged checkpoint output frames * style(cmux-tui): format checkpoint attachment assertion * fix(cmux-tui): satisfy checkpoint test lint * test(cmux-tui): attach recovery assertion to live surface * fix(ghostty-vt): reject recovery after prior Kitty overflow * test(ghostty-vt): cover recovery after lost multipart prefix * test(ghostty-vt): import replay error for overflow regression * fix(ghostty-vt): fail closed after lost Kitty prefix * test(cmux-tui): cover checkpoint recovery concurrency cuts * style(cmux-tui): format recovery blob lookup * fix(cmux-tui): preserve journal order across checkpoint recovery * test(cmux-tui): cover contextual checkpoint retry errors * fix(cmux-tui): retry contextual checkpoint ingress races --- .../cmux-tui-core/src/journal_checkpoint.rs | 261 +++++++++++++++--- cmux-tui/crates/cmux-tui-core/src/surface.rs | 97 +++++++ cmux-tui/crates/ghostty-vt/src/kitty.rs | 59 ++++ cmux-tui/crates/ghostty-vt/src/terminal.rs | 25 ++ cmux-tui/crates/ghostty-vt/tests/terminal.rs | 51 +++- 5 files changed, 456 insertions(+), 37 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs b/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs index a86996d08175..c12ef02a234f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs +++ b/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs @@ -42,6 +42,19 @@ pub(crate) struct CapturedCheckpoint { } pub(crate) fn capture(mux: &Mux) -> anyhow::Result { + for _ in 0..MAX_CHECKPOINT_CAPTURE_ATTEMPTS { + if let Some(captured) = capture_attempt(mux)? { + return Ok(captured); + } + #[cfg(test)] + if let Some(hook) = tests::AFTER_RECOVERY_HOOK.with_borrow_mut(Option::take) { + hook(); + } + } + anyhow::bail!("terminal replay recovery did not settle during checkpoint capture") +} + +fn capture_attempt(mux: &Mux) -> anyhow::Result> { // Drain every output frame that reached ingress before the capture fence. // Per-terminal epochs below reject a parser snapshot taken while its // corresponding journal frame is still being enqueued. @@ -76,22 +89,14 @@ pub(crate) fn capture(mux: &Mux) -> anyhow::Result { "checkpoint contains more than {MAX_CHECKPOINT_TERMINALS} terminals" ); - let mut total_bytes = 0_u64; - let mut blobs = Vec::new(); - for terminal_id in terminal_ids { - let Some(surface) = mux.terminal_resource_surface(&terminal_id) else { continue }; - let blob = terminal_replay_blob(&surface, &terminal_id)?; - total_bytes = total_bytes - .checked_add(blob.reference.uncompressed_bytes) - .context("checkpoint content byte count overflow")?; - anyhow::ensure!( - total_bytes <= MAX_CHECKPOINT_UNCOMPRESSED_BYTES, - "checkpoint terminal content exceeds {MAX_CHECKPOINT_UNCOMPRESSED_BYTES} bytes" - ); - blobs.push(blob); + let (blobs, recovered) = capture_terminal_blobs(mux, &terminal_ids)?; + mux.flush_terminal_journal()?; + if recovered { + // CAN advances the journal. Start a complete new attempt so terminal + // IDs and extension manifests come from the new consistency cut too. + return Ok(None); } - mux.flush_terminal_journal()?; // Verify against one cut as well, so a write landing between two separate // head and cursor reads cannot fail a capture that was in fact stable. let (verify_snapshot, head_after) = @@ -102,7 +107,7 @@ pub(crate) fn capture(mux: &Mux) -> anyhow::Result { head_before == head_after && snapshot["cursor"] == cursor_after, "session changed during checkpoint capture" ); - Ok(CapturedCheckpoint { + Ok(Some(CapturedCheckpoint { source_sequence: head_after, state: json!({ "session_snapshot":snapshot, @@ -112,19 +117,44 @@ pub(crate) fn capture(mux: &Mux) -> anyhow::Result { }, }), blobs, - }) + })) +} + +fn capture_terminal_blobs( + mux: &Mux, + terminal_ids: &[TerminalPublicId], +) -> anyhow::Result<(Vec, bool)> { + let mut total_bytes = 0_u64; + let mut recovered = false; + let mut blobs = Vec::new(); + for terminal_id in terminal_ids { + let Some(surface) = mux.terminal_resource_surface(terminal_id) else { continue }; + let (blob, terminal_recovered) = terminal_replay_blob_with_recovery(&surface, terminal_id)?; + recovered |= terminal_recovered; + total_bytes = total_bytes + .checked_add(blob.reference.uncompressed_bytes) + .context("checkpoint content byte count overflow")?; + anyhow::ensure!( + total_bytes <= MAX_CHECKPOINT_UNCOMPRESSED_BYTES, + "checkpoint terminal content exceeds {MAX_CHECKPOINT_UNCOMPRESSED_BYTES} bytes" + ); + blobs.push(blob); + } + Ok((blobs, recovered)) } pub(crate) fn capture_error_is_retryable(error: &anyhow::Error) -> bool { - let message = error.to_string(); - [ - "session changed during checkpoint capture", - "session journal changed during checkpoint capture", - "terminal changed during replay capture", - "terminal journal ingress is unsettled during replay capture", - ] - .iter() - .any(|reason| message.contains(reason)) + error.chain().any(|cause| { + let message = cause.to_string(); + [ + "session changed during checkpoint capture", + "session journal changed during checkpoint capture", + "terminal changed during replay capture", + "terminal journal ingress is unsettled during replay capture", + ] + .iter() + .any(|reason| message.contains(reason)) + }) } /// Capture one terminal's bounded `cmux.vt-replay.v1` blob from its live @@ -135,6 +165,13 @@ pub(crate) fn terminal_replay_blob( surface: &crate::Surface, terminal_id: &TerminalPublicId, ) -> anyhow::Result { + terminal_replay_blob_with_recovery(surface, terminal_id).map(|(blob, _)| blob) +} + +fn terminal_replay_blob_with_recovery( + surface: &crate::Surface, + terminal_id: &TerminalPublicId, +) -> anyhow::Result<(JournalContentBlob, bool)> { let epoch_before = surface .terminal_journal_capture_epoch() .context("captured terminal is not a PTY surface")?; @@ -142,9 +179,10 @@ pub(crate) fn terminal_replay_blob( epoch_before & 1 == 0, "terminal journal ingress is unsettled during replay capture" ); - let (cols, rows, replay) = { + let (cols, rows, replay, recovered) = { let mut last_error = None; let mut captured = None; + let mut recovered = false; for budget in CHECKPOINT_REPLAY_RETRY_BUDGETS { let result = surface .try_with_terminal(|terminal| { @@ -168,14 +206,28 @@ pub(crate) fn terminal_replay_blob( } } } - captured.ok_or_else(|| { - anyhow::anyhow!( - "terminal {terminal_id} replay capture failed after retrying larger buffers: {}", - last_error - .map(|error| error.to_string()) - .unwrap_or_else(|| "buffer too small".into()) - ) - })? + if captured.is_none() { + let result = surface + .discard_unreplayable_replay_with(|terminal| { + terminal + .vt_replay_bounded(CHECKPOINT_REPLAY_RETRY_BUDGETS[0]) + .map(|replay| (terminal.cols(), terminal.rows(), replay)) + }) + .with_context(|| format!("terminal {terminal_id} replay recovery"))?; + if let Some(Ok(value)) = result { + captured = Some(value); + recovered = true; + } else { + return Err(anyhow::anyhow!( + "terminal {terminal_id} replay capture failed after retrying larger buffers: {}", + last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "buffer too small".into()) + )); + } + } + let (cols, rows, replay) = captured.expect("replay recovery must either capture or return"); + (cols, rows, replay, recovered) }; let epoch_after = surface .terminal_journal_capture_epoch() @@ -216,7 +268,7 @@ pub(crate) fn terminal_replay_blob( let digest = Sha256::digest(&uncompressed); let digest_hex = encode_hex(digest.as_slice()); let compressed = gzip_deterministic(&uncompressed)?; - JournalContentBlob::verified( + let blob = JournalContentBlob::verified( JournalContentRef { content_id: format!("jcontent_{digest_hex}"), terminal_id: terminal_id.as_str().into(), @@ -228,7 +280,8 @@ pub(crate) fn terminal_replay_blob( rows, }, compressed, - ) + )?; + Ok((blob, recovered)) } #[cfg(test)] @@ -794,8 +847,27 @@ mod tests { JournalAuthority, JournalClass, JournalEventSchema, JournalProducer, JournalProducerManifest, JournalSensitivity, JournalSubject, }; + use base64::Engine; + use flate2::read::GzDecoder; + use std::io::Read; use std::sync::Arc; + thread_local! { + pub(super) static AFTER_RECOVERY_HOOK: std::cell::RefCell>> = + std::cell::RefCell::new(None); + } + + #[test] + fn checkpoint_recovery_retries_contextualized_ingress_races() { + let error = anyhow::anyhow!("terminal journal ingress is unsettled during replay capture") + .context("terminal term_test replay recovery"); + assert!(capture_error_is_retryable(&error)); + + let permanent = anyhow::anyhow!("cannot discard unreplayable replay on a hosted terminal") + .context("terminal term_test replay recovery"); + assert!(!capture_error_is_retryable(&permanent)); + } + fn terminal_replay_checkpoint() -> JournalCheckpoint { JournalCheckpoint { checkpoint_id: "checkpoint_terminal_replay".into(), @@ -1443,6 +1515,123 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[cfg(unix)] + #[test] + fn checkpoint_capture_recovers_from_an_unreplayable_kitty_upload() { + let root = std::env::temp_dir().join(format!( + "cmux-journal-checkpoint-kitty-overflow-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let mux = Mux::open_persistent( + "checkpoint-kitty-overflow", + crate::SurfaceOptions::default(), + &root, + ) + .unwrap(); + let workspace = mux.create_empty_workspace(None, None, None).unwrap(); + let surface_id = mux + .seed_running_terminal_for_test( + "00000000000040008000000000000070", + "10000000000040008000000000000070", + &workspace.key, + ) + .unwrap(); + let surface = mux.surface(surface_id).unwrap(); + surface + .try_with_terminal(|terminal| terminal.set_kitty_inflight_storage_limit(64)) + .unwrap(); + let public_id = match &surface.resource_identity().unwrap().content_id { + crate::resource::ContentPublicId::Terminal(public_id) => public_id.clone(), + crate::resource::ContentPublicId::Browser(_) => unreachable!(), + }; + let mut upload = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + upload.resize(1024, b'A'); + surface.apply_local_pty_output_for_test(&upload).unwrap(); + mux.journal_terminal_output( + Arc::new(public_id.clone()), + Arc::from("10000000000040008000000000000070"), + upload.clone(), + ); + + AFTER_RECOVERY_HOOK.with_borrow_mut(|slot| { + let mux = mux.clone(); + *slot = Some(Box::new(move || { + mux.seed_running_terminal_for_test( + "00000000000040008000000000000073", + "10000000000040008000000000000073", + &workspace.key, + ) + .unwrap(); + mux.put_journal_producer( + &JournalProducerManifest { + producer_id: "recovery_race".into(), + namespace: "plugin.recovery_race".into(), + manifest_version: 1, + max_sensitivity: JournalSensitivity::Metadata, + permissions: vec!["journal.append.plugin.recovery_race".into()], + events: vec![JournalEventSchema { + kind: "plugin.recovery_race.event".into(), + schema_version: 1, + class: JournalClass::Observation, + replay: JournalReplayPolicy::Advisory, + sensitivity: JournalSensitivity::Metadata, + payload_schema: json!({"type":"object"}), + }], + }, + "client_test", + "recovery_race_producer", + ) + .unwrap(); + })); + }); + + let captured = + capture(&mux).expect("checkpoint capture should recover the terminal parser"); + assert_eq!(captured.state["session_snapshot"]["terminals"].as_array().unwrap().len(), 2); + assert_eq!(captured.blobs.len(), 2, "recovery must refresh the captured terminal list"); + assert!( + captured.state["journal_extensions"]["producers"] + .as_array() + .unwrap() + .iter() + .any(|producer| producer["producer_id"] == "recovery_race"), + "recovery must refresh extension manifests with the consistency cut" + ); + + let mut decoded = Vec::new(); + let blob = captured + .blobs + .iter() + .find(|blob| blob.reference.terminal_id == public_id.as_str()) + .unwrap(); + GzDecoder::new(blob.compressed.as_slice()).read_to_end(&mut decoded).unwrap(); + let replay: Value = serde_json::from_slice(&decoded).unwrap(); + let replay_bytes = base64::engine::general_purpose::STANDARD + .decode(replay["bytes_base64"].as_str().unwrap()) + .unwrap(); + assert!( + !replay_bytes.windows(3).any(|window| window == b"\x1b_G"), + "checkpoint replay must drop the unreplayable Kitty upload" + ); + + mux.journal_terminal_output( + Arc::new(public_id.clone()), + Arc::from("10000000000040008000000000000070"), + b"visible after recovery".to_vec(), + ); + mux.flush_terminal_journal().unwrap(); + let output = mux.terminal_output_read(&public_id, None, 1 << 20).unwrap(); + assert!( + output["text"].as_str().unwrap().contains("visible after recovery"), + "checkpoint recovery must journal the synthetic CAN before later output" + ); + + drop(surface); + drop(mux); + std::fs::remove_dir_all(root).unwrap(); + } + #[cfg(unix)] #[test] fn checkpoint_capture_resolves_public_terminal_ids() { diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 0bda6bb017e8..d2c042cdecae 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -5150,6 +5150,45 @@ impl Surface { Ok(f(&mut pty.term.lock().unwrap())) } + /// Abort an unreplayable parser sequence and run a capture while the + /// terminal/output fence is held. The synthetic CAN bytes are published to + /// byte attachments before the fence is released, so their parsers stay + /// synchronized with the authoritative terminal. + pub(crate) fn discard_unreplayable_replay_with( + &self, + f: impl FnOnce(&mut Terminal) -> R, + ) -> anyhow::Result> { + let Some(pty) = self.as_pty() else { + anyhow::bail!("browser surface does not have a VT terminal"); + }; + // A connected terminal host owns the authoritative parser. The daemon + // terminal is only a mirror, so injecting CAN here would leave the two + // parsers at different sequence boundaries when the next host output + // frame arrives. Source-side recovery needs a host protocol command; + // refuse this local-only mutation until one exists. + #[cfg(unix)] + if matches!(&*pty.runtime.lock().unwrap(), PtyRuntime::Hosted(_)) { + anyhow::bail!("cannot discard unreplayable replay on a hosted terminal"); + } + let mut terminal = pty.term.lock().unwrap(); + // The reader releases this lock before journaling its parsed frame. + // Do not let synthetic CAN overtake that frame in journal ingress. + anyhow::ensure!( + pty.journal_capture_epoch.load(Ordering::Acquire) & 1 == 0, + "terminal journal ingress is unsettled during replay capture" + ); + let Some(output) = terminal.discard_unreplayable_replay() else { + return Ok(None); + }; + pty.observe_terminal_output(&output); + pty.broadcast_attach_output(&output); + if let Some(journal_target) = pty.journal_target() { + pty.journal_output_if_open(journal_target, output); + } + pty.stream_progress.notify(); + Ok(Some(f(&mut terminal))) + } + pub fn scroll_delta(&self, delta: isize) -> anyhow::Result<()> { let _ = self.apply_scroll_delta(None, delta)?; Ok(()) @@ -7593,6 +7632,64 @@ mod tests { assert!(pty.reaper_thread.lock().unwrap().is_none()); } + #[test] + fn checkpoint_recovery_resets_terminal_metadata_parser() { + let mux = Mux::new_for_test("checkpoint-metadata-recovery", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let attach = surface.attach_stream().unwrap(); + let mut oversized = b"\x1b]9;4;1;".to_vec(); + oversized.resize(1_048_577, b'x'); + surface.apply_stream_output_for_test(&oversized).unwrap(); + + let recovered = surface + .discard_unreplayable_replay_with(|terminal| { + terminal.vt_replay_bounded(VT_REPLAY_MAX_BYTES) + }) + .unwrap(); + assert!(matches!(recovered, Some(Ok(_)))); + + let mut attach_output = Vec::new(); + while let Ok(frame) = attach.stream.try_recv() { + if let AttachFrame::Output(bytes) = frame { + attach_output.extend_from_slice(&bytes); + } + } + assert!( + attach_output.contains(&0x18), + "recovery must publish CAN to attached byte parsers" + ); + + surface.apply_stream_output_for_test(b"\x1b]9;4;2;\x07").unwrap(); + assert_eq!(surface.terminal_screen_snapshot().unwrap().osc_progress, "4;2;"); + } + + #[test] + fn checkpoint_recovery_waits_for_pending_journal_output() { + let mux = Mux::new_for_test("checkpoint-ingress-recovery", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let attach = surface.attach_stream().unwrap(); + surface + .try_with_terminal(|terminal| terminal.set_kitty_inflight_storage_limit(64)) + .unwrap(); + let mut pending_output = surface.begin_terminal_journal_update_for_test().unwrap(); + assert!(pending_output.activate()); + let mut upload = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + upload.resize(1024, b'A'); + surface.apply_local_pty_output_for_test(&upload).unwrap(); + + // The local reader has parsed the frame but has not enqueued it yet. + let error = surface.discard_unreplayable_replay_with(|_| ()).unwrap_err(); + assert!(error.to_string().contains("terminal journal ingress is unsettled")); + assert_eq!(attach.stream.try_recv().unwrap(), AttachFrame::Output(upload)); + assert!(matches!(attach.stream.try_recv(), Err(TryRecvError::Empty))); + + drop(pending_output); + assert!(surface.discard_unreplayable_replay_with(|_| ()).unwrap().is_some()); + assert_eq!(attach.stream.try_recv().unwrap(), AttachFrame::Output(vec![0x18])); + } + #[cfg(unix)] #[test] fn local_surface_reaper_timeout_retains_production_handle() { diff --git a/cmux-tui/crates/ghostty-vt/src/kitty.rs b/cmux-tui/crates/ghostty-vt/src/kitty.rs index c9fd13f9b9f3..491891d7dff3 100644 --- a/cmux-tui/crates/ghostty-vt/src/kitty.rs +++ b/cmux-tui/crates/ghostty-vt/src/kitty.rs @@ -193,6 +193,31 @@ impl KittyInFlightTracker { && matches!(&self.scan, KittyStreamScan::Kitty(command) if !command.overflowed) } + pub(crate) fn has_unreplayable_command(&self) -> bool { + // A tracker-level overflow can also mean that a completed multipart + // chunk exceeded the retention budget. CAN cannot undo that command + // after the parser has returned to ground, so only an actively + // overflowed command is recoverable here. + !self.overflowed + && matches!(&self.scan, KittyStreamScan::Kitty(command) if command.overflowed) + } + + pub(crate) fn has_unreplayable_prefix(&self) -> bool { + self.overflowed + } + + pub(crate) fn discard_unreplayable(&mut self) { + self.scan = KittyStreamScan::Ground; + self.overflowed = false; + // CAN aborts only the command that is currently being parsed. Keep a + // completed m=1 prefix so checkpoint replay can still resume the + // native upload when a later m=0 chunk arrives. An overflowed first + // chunk has no prefix to preserve, so leave the tracker idle there. + if self.prefix.is_empty() { + self.loading = false; + } + } + /// The completed upload chunks and the partial command, separately, under /// the same budget as [`Self::replay_prefix_checked`]. pub(crate) fn replay_prefix_and_partial_checked( @@ -1228,6 +1253,40 @@ mod tests { } } + #[test] + fn discarding_an_unreplayable_command_preserves_completed_chunks() { + let first = b"\x1b_Ga=t,t=d,f=24,i=92,s=1,v=2,m=1;AAAA\x1b\\"; + let mut tracker = KittyInFlightTracker::default(); + tracker.set_max_bytes(64); + tracker.write(first); + + // Overflow the active continuation while keeping the completed m=1 + // prefix within the tracker's replay budget. + tracker.write(b"\x1b_Gm=0;this-command-is-too-large"); + tracker.discard_unreplayable(); + assert_eq!(tracker.replay_prefix(usize::MAX), first); + + tracker.write(b"\x1b_Gm=0;B\x1b\\"); + assert!(tracker.replay_prefix(usize::MAX).is_empty()); + } + + #[test] + fn completed_overflow_blocks_later_active_recovery() { + let mut first = b"\x1b_Ga=t,t=d,f=24,i=92,s=1,v=2,m=1;".to_vec(); + first.resize(128, b'A'); + first.extend_from_slice(b"\x1b\\"); + let mut tracker = KittyInFlightTracker::default(); + tracker.set_max_bytes(64); + tracker.write(&first); + + let mut active = b"\x1b_Gm=0;".to_vec(); + active.resize(128, b'B'); + tracker.write(&active); + assert!(tracker.overflowed); + assert!(matches!(&tracker.scan, KittyStreamScan::Kitty(command) if command.overflowed)); + assert!(!tracker.has_unreplayable_command()); + } + #[test] fn completed_oversized_non_transmissions_do_not_poison_future_replay() { for header in ["a=p,i=92,p=1,c=1,r=1", "a=d,d=i,i=92"] { diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 499401470f44..ce9a085435c4 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -2209,6 +2209,31 @@ impl Terminal { self.pending_sequence_replay().is_some() } + /// Abandon a pending VT control sequence that exceeded its replay budget. + /// + /// Checkpoint capture can continue from the rendered terminal state when + /// a client leaves an unbounded control string (most often a Kitty upload) + /// open forever. CAN has no screen effect and returns the parser to ground; + /// callers must publish the returned bytes to every attached parser before + /// resuming the live stream. + pub fn discard_unreplayable_replay(&mut self) -> Option> { + // CAN cannot undo completed multipart chunks. Reject recovery even + // when a later control string also exceeds the generic replay budget. + if self.kitty_inflight.has_unreplayable_prefix() { + return None; + } + // The generic VT boundary can still retain a complete replay for an + // APC while the Kitty-specific tracker has already overflowed its + // smaller upload budget. In that case the generic result is not a + // usable replay, so CAN recovery is still required. + if self.vt_replay_resumes_stream() && !self.kitty_inflight.has_unreplayable_command() { + return None; + } + let normalized = self.vt_write_with_normalized(b"\x18").into_owned(); + self.kitty_inflight.discard_unreplayable(); + self.vt_stream_is_ground().then_some(normalized) + } + /// How the replay reproduces the parser's incomplete sequence, if it can. fn pending_sequence_replay(&self) -> Option> { if self.vt_stream_is_ground() { diff --git a/cmux-tui/crates/ghostty-vt/tests/terminal.rs b/cmux-tui/crates/ghostty-vt/tests/terminal.rs index 6ce5d8b7e169..99722f1ac55c 100644 --- a/cmux-tui/crates/ghostty-vt/tests/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/tests/terminal.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, Mutex}; use ghostty_vt::{ - Callbacks, Cell, ColorSpec, CursorShape, Dirty, RenderState, Rgb, Terminal, + Callbacks, Cell, ColorSpec, CursorShape, Dirty, Error, RenderState, Rgb, Terminal, TerminalColorOverrides, }; @@ -1207,3 +1207,52 @@ fn empty_kitty_graphics_probe_keeps_checkpoint_replay_readable() { term.vt_replay_bounded(1024 * 1024) .expect("an empty Kitty capability probe must not poison replay capture"); } + +#[test] +fn oversized_kitty_upload_is_not_resumable() { + let mut term = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + term.set_kitty_inflight_storage_limit(64); + let mut upload = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + upload.resize(1024, b'A'); + term.vt_write(&upload); + + assert!(term.discard_unreplayable_replay().is_some()); + assert!(term.vt_replay_resumes_stream()); +} + +#[test] +fn completed_oversized_kitty_chunk_is_not_can_recoverable() { + let mut term = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + term.set_kitty_inflight_storage_limit(64); + let mut chunk = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + chunk.resize(1024, b'A'); + chunk.extend_from_slice(b"\x1b\\"); + term.vt_write(&chunk); + + assert!(term.vt_replay_resumes_stream()); + assert!(term.discard_unreplayable_replay().is_none()); +} + +#[test] +fn completed_kitty_overflow_blocks_later_control_string_recovery() { + for size in [1024, 1_048_577] { + for header in [b"\x1b_Gm=0;".as_slice(), b"\x1b]2;"] { + let mut term = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + term.set_kitty_inflight_storage_limit(64); + let mut first = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + first.resize(1024, b'A'); + first.extend_from_slice(b"\x1b\\"); + term.vt_write(&first); + + let mut active = header.to_vec(); + active.resize(size, b'B'); + term.vt_write(&active); + + assert!( + term.discard_unreplayable_replay().is_none(), + "CAN cannot recover a lost completed prefix: header={header:?}, size={size}" + ); + assert!(matches!(term.vt_replay_bounded(2 * 1024 * 1024), Err(Error::OutOfSpace))); + } + } +} From add03ae5c6c28c00e161321da98ff02a31734fda Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 21:30:56 -0700 Subject: [PATCH 003/183] Fix Claude Code resume cwd and reused PID restore blocking (#20249) * test: cover Claude transcript cwd selection * fix: resume Claude from transcript project cwd * test: cover reused legacy agent PIDs * fix: validate legacy agent PIDs by process identity * fix: unwrap candidate resume directories * fix: avoid ambiguous resume candidate closure * test: cover Claude resume review regressions * fix: address Claude resume review findings * fix: use canonical Claude project slugs in restore * test: reproduce process exit after restore identity capture * test: avoid redundant process probes during restore * fix: prioritize absent PIDs and probe presence only when needed * test: retain the mismatched PID reclamation safety probe --- CLI/cmux.swift | 6 +- .../AgentResumeWorkingDirectory.swift | 33 ++++- .../AgentResumeWorkingDirectoryTests.swift | 35 ++++++ Packages/macOS/CmuxWorkspaces/Package.swift | 3 + .../RestorableAgentProcessEvidence.swift | 42 +++++++ .../RestorableAgentProcessMatcher.swift | 40 ++++++ .../RestorableAgentProcessPresence.swift | 10 ++ .../RestorableAgentProcessMatcherTests.swift | 117 ++++++++++++++++++ Sources/RestorableAgentSession.swift | 71 ++++++----- ...estorableAgentProcessGenerationTests.swift | 103 +++++++++++++++ .../RestorableAgentSessionIndexTests.swift | 24 +++- 11 files changed, 444 insertions(+), 40 deletions(-) create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessEvidence.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessMatcher.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessPresence.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestorableAgentProcessMatcherTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5f9178697a03..dcbecf3a946b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -31322,6 +31322,7 @@ struct CMUXCLI { sessionId: acceptedSessionId, cwd: hookCwd, launchCommand: launchCommand, + transcriptPath: hookTranscriptPath, observedPermissionMode: observedHookPermissionMode ) } @@ -31540,6 +31541,7 @@ struct CMUXCLI { sessionId: sessionId, cwd: hookCwd ?? mappedSession?.cwd, launchCommand: mappedSession?.launchCommand, + transcriptPath: hookTranscriptPath, observedPermissionMode: observedHookPermissionMode ?? mappedSession?.lastPermissionMode ) @@ -31752,6 +31754,7 @@ struct CMUXCLI { sessionId: sessionId, cwd: hookCwd ?? mappedSession?.cwd, launchCommand: mappedSession?.launchCommand ?? firstSightingLaunchCommand, + transcriptPath: hookTranscriptPath, observedPermissionMode: observedHookPermissionMode ?? mappedSession?.lastPermissionMode ) @@ -35830,7 +35833,8 @@ struct CMUXCLI { let resumeWorkingDirectory = AgentResumeWorkingDirectory().resolve( kind: kind, runtimeCwd: cwd, - launchWorkingDirectory: launchCommand?.workingDirectory + launchWorkingDirectory: launchCommand?.workingDirectory, + transcriptPath: transcriptPath ) guard let command = agentSurfaceResumeCommand( kind: kind, diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeWorkingDirectory.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeWorkingDirectory.swift index 80371f20750e..5e30a3f7cdb6 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeWorkingDirectory.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeWorkingDirectory.swift @@ -41,18 +41,21 @@ public struct AgentResumeWorkingDirectory: Sendable, Equatable { /// /// Directory-namespaced agents prefer the launch working directory: it is captured once at launch, /// matches the session store's namespace, and does not drift when the agent `cd`s into a - /// subdirectory (e.g. a worktree) mid-session. Id-keyed agents keep the runtime cwd so they reopen + /// subdirectory (e.g. a worktree) mid-session. Claude first selects a candidate whose project + /// slug matches the transcript's directory, when available. Id-keyed agents keep the runtime cwd so they reopen /// where the agent was working. Inputs are trimmed and empty values are treated as absent. /// /// - Parameters: /// - kind: the agent's raw kind identifier. /// - runtimeCwd: the agent's last-reported runtime cwd (may have drifted). /// - launchWorkingDirectory: the directory the agent was launched in. + /// - transcriptPath: The hook-reported transcript path, when available. /// - Returns: the directory to `cd` into, or `nil` when neither input is usable. public func resolve( kind: String, runtimeCwd: String?, - launchWorkingDirectory: String? + launchWorkingDirectory: String?, + transcriptPath: String? = nil ) -> String? { let runtime = normalized(runtimeCwd) let launch = normalized(launchWorkingDirectory) @@ -60,10 +63,36 @@ public struct AgentResumeWorkingDirectory: Sendable, Equatable { case .cwdInFile: return runtime ?? launch case .byDirectory: + if kind == "claude", + let transcriptProject = transcriptProjectDirectoryName(transcriptPath), + let transcriptCwd = [launch, runtime].compactMap({ $0 }).first(where: { + encodedProjectDirectoryName(forPath: $0) == transcriptProject + }) { + return transcriptCwd + } return launch ?? runtime } } + /// Returns the Claude project-directory component from a transcript path. + /// Claude stores transcripts below `/projects//`; the + /// session file may be either directly in that directory or nested below it. + private func transcriptProjectDirectoryName(_ transcriptPath: String?) -> String? { + guard let rawPath = normalized(transcriptPath) else { return nil } + let components = (rawPath as NSString).expandingTildeInPath + .split(separator: "/", omittingEmptySubsequences: true) + .map(String.init) + guard let projectsIndex = components.lastIndex(of: "projects"), + projectsIndex + 1 < components.count else { + return nil + } + return components[projectsIndex + 1] + } + + private func encodedProjectDirectoryName(forPath path: String) -> String { + ClaudeProjectSlug().slug(forWorkingDirectory: path) + } + private func normalized(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeWorkingDirectoryTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeWorkingDirectoryTests.swift index d121e071c55c..d840afc45a8e 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeWorkingDirectoryTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeWorkingDirectoryTests.swift @@ -3,6 +3,41 @@ import Testing @Suite("AgentResumeWorkingDirectory") struct AgentResumeWorkingDirectoryTests { + @Test("Claude resumes from the worktree that owns its transcript", arguments: [false, true]) + func claudeTranscriptOverridesStaleLaunchDirectory(nested: Bool) { + let sessionID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + let transcript = "/custom/claude/projects/-Users-x-repo--claude-worktrees-feature/" + + (nested ? "\(sessionID)/messages/" : "") + "\(sessionID).jsonl" + #expect(AgentResumeWorkingDirectory().resolve( + kind: "claude", + runtimeCwd: "/Users/x/repo/.claude/worktrees/feature", + launchWorkingDirectory: "/Users/x/repo", + transcriptPath: transcript + ) == "/Users/x/repo/.claude/worktrees/feature") + } + + @Test("Claude uses the final projects directory in a nested transcript path") + func claudeTranscriptUsesFinalProjectsDirectory() { + let transcript = "/Users/x/projects/.claude/projects/-Users-x-repo--claude-worktrees-feature/session.jsonl" + #expect(AgentResumeWorkingDirectory().resolve( + kind: "claude", + runtimeCwd: "/Users/x/repo/.claude/worktrees/feature", + launchWorkingDirectory: "/Users/x/repo", + transcriptPath: transcript + ) == "/Users/x/repo/.claude/worktrees/feature") + } + + @Test("Claude project slugs preserve non-ASCII and punctuation normalization") + func claudeTranscriptMatchesGeneralProjectSlug() { + let transcript = "/custom/claude/projects/-Users-a-my-proj-v2-home--/session.jsonl" + #expect(AgentResumeWorkingDirectory().resolve( + kind: "claude", + runtimeCwd: "/Users/a/my_proj.v2/home/é", + launchWorkingDirectory: "/Users/a", + transcriptPath: transcript + ) == "/Users/a/my_proj.v2/home/é") + } + @Test("Directory-namespaced agents pin the launch cwd over a drifted runtime cwd") func directoryNamespacedPrefersLaunch() { #expect( diff --git a/Packages/macOS/CmuxWorkspaces/Package.swift b/Packages/macOS/CmuxWorkspaces/Package.swift index eeb9a1316ce9..4a1d45dea128 100644 --- a/Packages/macOS/CmuxWorkspaces/Package.swift +++ b/Packages/macOS/CmuxWorkspaces/Package.swift @@ -14,6 +14,7 @@ let package = Package( ), ], dependencies: [ + .package(path: "../CmuxFoundation"), // WorkspaceGroupNewPlacement (the typed setting value for new // in-group workspace placement) is owned by CmuxSettings. .package(path: "../CmuxSettings"), @@ -30,6 +31,7 @@ let package = Package( .target( name: "CmuxWorkspaces", dependencies: [ + .product(name: "CmuxFoundation", package: "CmuxFoundation"), .product(name: "CmuxSettings", package: "CmuxSettings"), .product(name: "Bonsplit", package: "bonsplit"), .product(name: "CMUXDebugLog", package: "CMUXDebugLog"), @@ -46,6 +48,7 @@ let package = Package( name: "CmuxWorkspacesTests", dependencies: [ "CmuxWorkspaces", + .product(name: "CmuxFoundation", package: "CmuxFoundation"), .product(name: "Bonsplit", package: "bonsplit"), .product(name: "CmuxTestSupport", package: "CmuxTestSupport"), ], diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessEvidence.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessEvidence.swift new file mode 100644 index 000000000000..4fbeb3d9ddea --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessEvidence.swift @@ -0,0 +1,42 @@ +public import CmuxFoundation + +/// Process observations supplied to ``RestorableAgentProcessMatcher``. +public struct RestorableAgentProcessEvidence: Sendable { + /// The process ID from the restorable session record. + public let processID: Int + /// The process generation saved with the record, when available. + public let recordedIdentity: AgentPIDProcessIdentity? + /// The process generation observed during restore, when available. + public let currentIdentity: AgentPIDProcessIdentity? + /// Whether the process ID is occupied, updated when a presence probe becomes necessary. + public var presence: RestorableAgentProcessPresence + /// Whether argv/environment identify the recorded cmux surface, when inspectable. + public let scopeMatches: Bool? + /// Whether argv/environment identify the expected agent session, when inspectable. + public let expectedProcessMatches: Bool? + + /// Creates process evidence for one recorded process ID. + /// + /// - Parameters: + /// - processID: The PID saved in the session record. + /// - recordedIdentity: The saved generation, or `nil` for a legacy record. + /// - currentIdentity: The observed generation, or `nil` if unreadable. + /// - presence: Whether the PID is currently occupied. + /// - scopeMatches: Whether the process belongs to the saved surface, or `nil` if unreadable. + /// - expectedProcessMatches: Whether the agent and session match, or `nil` if unreadable. + public init( + processID: Int, + recordedIdentity: AgentPIDProcessIdentity?, + currentIdentity: AgentPIDProcessIdentity?, + presence: RestorableAgentProcessPresence, + scopeMatches: Bool?, + expectedProcessMatches: Bool? + ) { + self.processID = processID + self.recordedIdentity = recordedIdentity + self.currentIdentity = currentIdentity + self.presence = presence + self.scopeMatches = scopeMatches + self.expectedProcessMatches = expectedProcessMatches + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessMatcher.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessMatcher.swift new file mode 100644 index 000000000000..8446a1a457d6 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessMatcher.swift @@ -0,0 +1,40 @@ +/// Classifies process evidence used by automatic agent-session restore. +public struct RestorableAgentProcessMatcher: Sendable { + /// Creates a stateless process matcher. + public init() {} + + /// Returns whether the evidence authenticates the recorded process generation. + /// + /// A legacy record without a saved generation never authenticates a matching + /// process, but contradictory argv or scope evidence still proves that a reused + /// PID cannot block restoration. Confirmed absence overrides any identity + /// captured before the process exited. + /// + /// - Parameter evidence: Process observations for the saved PID. + /// - Returns: A match only for an authenticated generation, a mismatch for + /// definitive contradictory evidence, or unknown when observations are inconclusive. + public func match(_ evidence: RestorableAgentProcessEvidence) -> RestorableAgentProcessMatch { + guard evidence.presence != .absent else { return .mismatches } + + if let recordedIdentity = evidence.recordedIdentity { + guard Int(recordedIdentity.pid) == evidence.processID else { + return .unknown + } + guard let currentIdentity = evidence.currentIdentity, + Int(currentIdentity.pid) == evidence.processID else { + return .unknown + } + guard currentIdentity == recordedIdentity else { + return .mismatches + } + } + + if evidence.scopeMatches == false || evidence.expectedProcessMatches == false { + return .mismatches + } + guard evidence.scopeMatches != nil, evidence.expectedProcessMatches != nil else { + return .unknown + } + return evidence.recordedIdentity == nil ? .unknown : .matches + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessPresence.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessPresence.swift new file mode 100644 index 000000000000..d7c384c3522b --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessPresence.swift @@ -0,0 +1,10 @@ +/// Tri-state evidence about whether a recorded process ID is still occupied. +public enum RestorableAgentProcessPresence: Equatable, Hashable, Sendable { + /// The process ID currently exists. + case present + /// The process ID is confirmed absent. + case absent + /// The process table could not establish whether the ID exists. + case unknown +} + diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestorableAgentProcessMatcherTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestorableAgentProcessMatcherTests.swift new file mode 100644 index 000000000000..b8e874a9cf43 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestorableAgentProcessMatcherTests.swift @@ -0,0 +1,117 @@ +import CmuxFoundation +import Testing +@testable import CmuxWorkspaces + +@Suite("RestorableAgentProcessMatcher") +struct RestorableAgentProcessMatcherTests { + private let matcher = RestorableAgentProcessMatcher() + + @Test("A reused legacy PID with contradictory process evidence mismatches", arguments: [false, true]) + func reusedLegacyPIDMismatches(hasCurrentIdentity: Bool) { + let result = matcher.match(.init( + processID: 42, + recordedIdentity: nil, + currentIdentity: hasCurrentIdentity ? identity() : nil, + presence: .present, + scopeMatches: true, + expectedProcessMatches: false + )) + #expect(result == .mismatches) + } + + @Test("A different surface proves a legacy PID is reused even without agent details") + func reusedLegacyPIDInAnotherScopeMismatches() { + #expect(matcher.match(.init( + processID: 42, + recordedIdentity: nil, + currentIdentity: nil, + presence: .present, + scopeMatches: false, + expectedProcessMatches: nil + )) == .mismatches) + } + + @Test("A matching legacy PID remains unknown without a saved generation", arguments: [false, true]) + func matchingLegacyPIDRemainsUnknown(hasCurrentIdentity: Bool) { + let result = matcher.match(.init( + processID: 42, + recordedIdentity: nil, + currentIdentity: hasCurrentIdentity ? identity() : nil, + presence: .present, + scopeMatches: true, + expectedProcessMatches: true + )) + #expect(result == .unknown) + } + + @Test("A matching saved process generation authenticates") + func matchingSavedGenerationAuthenticates() { + let identity = identity() + let result = matcher.match(.init( + processID: 42, + recordedIdentity: identity, + currentIdentity: identity, + presence: .present, + scopeMatches: true, + expectedProcessMatches: true + )) + #expect(result == .matches) + } + + @Test("A changed process generation proves PID reuse even when argv is unreadable") + func changedGenerationMismatches() { + #expect(matcher.match(.init( + processID: 42, + recordedIdentity: identity(), + currentIdentity: identity(startSeconds: 20), + presence: .present, + scopeMatches: nil, + expectedProcessMatches: nil + )) == .mismatches) + } + + @Test("Confirmed absence overrides a previously captured matching process", + arguments: [false, true]) + func absentProcessOverridesCapturedIdentity(hasRecordedIdentity: Bool) { + #expect(matcher.match(.init( + processID: 42, + recordedIdentity: hasRecordedIdentity ? identity() : nil, + currentIdentity: identity(), + presence: .absent, + scopeMatches: true, + expectedProcessMatches: true + )) == .mismatches) + } + + @Test("Unreadable processes remain uncertain unless their absence is definitive", + arguments: [RestorableAgentProcessPresence.present, .absent, .unknown], [false, true]) + func unreadableProcessRequiresDefinitiveAbsence( + presence: RestorableAgentProcessPresence, + hasRecordedIdentity: Bool + ) { + #expect(matcher.match(.init( + processID: 42, + recordedIdentity: hasRecordedIdentity ? identity() : nil, + currentIdentity: nil, + presence: presence, + scopeMatches: nil, + expectedProcessMatches: nil + )) == (presence == .absent ? .mismatches : .unknown)) + } + + @Test("A saved generation cannot authenticate without a matching current identity") + func missingCurrentGenerationStaysUnknown() { + #expect(matcher.match(.init( + processID: 42, + recordedIdentity: identity(), + currentIdentity: nil, + presence: .present, + scopeMatches: true, + expectedProcessMatches: true + )) == .unknown) + } + + private func identity(startSeconds: Int64 = 10) -> AgentPIDProcessIdentity { + AgentPIDProcessIdentity(pid: 42, startSeconds: startSeconds, startMicroseconds: 2) + } +} diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index dc8eb2743bc1..089db351e08b 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -2237,9 +2237,13 @@ struct RestorableAgentSessionIndex: Sendable { let liveProcessID = processObservation.processID let liveProcessIdentities: [Int: AgentPIDProcessIdentity] if let liveProcessID, - let recordedProcessIdentity, - Int(recordedProcessIdentity.pid) == liveProcessID { - liveProcessIdentities = [liveProcessID: recordedProcessIdentity] + let currentProcessIdentity, + Int(currentProcessIdentity.pid) == liveProcessID, + (recordedProcessIdentity == nil || recordedProcessIdentity == currentProcessIdentity) { + // Legacy hook records did not persist a process generation. Keep the + // generation observed during this scan so a later PID reuse can be + // rejected without treating an unrelated process as an uncertain owner. + liveProcessIdentities = [liveProcessID: currentProcessIdentity] } else { liveProcessIdentities = [:] } @@ -3149,11 +3153,7 @@ struct RestorableAgentSessionIndex: Sendable { } static func encodeClaudeProjectDir(_ path: String) -> String { - // Claude derives a project directory name by replacing both "/" and "." with "-" - // (e.g. "/Users/x/repo/.claude" -> "-Users-x-repo--claude"). Missing the "." case - // sent dotted paths to the wrong project directory. - path.replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: ".", with: "-") + ClaudeProjectSlug().slug(forWorkingDirectory: path) } private static func claudeProjectDirName(containingTranscriptPath path: String, configRoots: [String]) -> String? { @@ -3645,30 +3645,37 @@ struct RestorableAgentSessionIndex: Sendable { validator: CachedAgentProcessIdentityValidator, hermesSessionValidation: CachedAgentProcessIdentityValidator.HermesSessionValidation = .cachedSnapshot ) -> RestorableAgentProcessMatch { - guard let recordedProcessIdentity, - Int(recordedProcessIdentity.pid) == processID else { - return processPresenceProvider(processID) == .absent ? .mismatches : .unknown - } - guard let currentProcessIdentity, - Int(currentProcessIdentity.pid) == processID else { - return processPresenceProvider(processID) == .absent ? .mismatches : .unknown - } - guard currentProcessIdentity == recordedProcessIdentity else { - return .mismatches - } - guard let process = processArgumentsProvider(processID) else { - // A present process may be temporarily uninspectable. Only ESRCH-grade - // absence proves that the recorded generation exited. - return processPresenceProvider(processID) == .absent ? .mismatches : .unknown - } - guard process.matchesCMUXScope(workspaceId: workspaceId, surfaceId: panelId) else { - return .mismatches - } - return validator.currentProcess( - process, - matches: snapshot, - hermesSessionValidation: hermesSessionValidation - ) ? .matches : .mismatches + let process = processArgumentsProvider(processID) + let scopeMatches = process.map { + $0.matchesCMUXScope(workspaceId: workspaceId, surfaceId: panelId) + } + let matchesExpectedProcess = process.map { + validator.currentProcess( + $0, + matches: snapshot, + hermesSessionValidation: hermesSessionValidation + ) + } + var evidence = RestorableAgentProcessEvidence( + processID: processID, + recordedIdentity: recordedProcessIdentity, + currentIdentity: currentProcessIdentity, + presence: .unknown, + scopeMatches: scopeMatches, + expectedProcessMatches: matchesExpectedProcess + ) + let matcher = RestorableAgentProcessMatcher() + let processMatch = matcher.match(evidence) + guard processMatch == .unknown else { return processMatch } + + // Close fallbacks can load this index synchronously. Probe the process + // table only when the already-captured evidence cannot settle liveness. + evidence.presence = switch processPresenceProvider(processID) { + case .present: .present + case .absent: .absent + case .unknown: .unknown + } + return matcher.match(evidence) } private static func normalizedNonEmptyValue(_ value: String?) -> String? { diff --git a/cmuxTests/RestorableAgentProcessGenerationTests.swift b/cmuxTests/RestorableAgentProcessGenerationTests.swift index 6d5f8b75e36b..57d8528a3d97 100644 --- a/cmuxTests/RestorableAgentProcessGenerationTests.swift +++ b/cmuxTests/RestorableAgentProcessGenerationTests.swift @@ -200,6 +200,78 @@ struct RestorableAgentProcessGenerationTests { )?.processLiveness == .running) } + @Test("A legacy PID reused by another executable does not block restore") + func legacyPIDReusedByAnotherExecutableDoesNotBlockRestore() throws { + let fixture = try makeFixture(prefix: "cmux-legacy-pid-reuse") + defer { cleanup(fixture) } + let reusedIdentity = AgentPIDProcessIdentity( + pid: pid_t(fixture.processID), + startSeconds: Int64(fixture.updatedAt + 1), + startMicroseconds: 0 + ) + let reusedProcess = CmuxTopProcessArguments( + arguments: ["/usr/local/bin/claude"], + environment: [ + "CMUX_WORKSPACE_ID": fixture.workspaceID.uuidString, + "CMUX_SURFACE_ID": fixture.panelID.uuidString, + ] + ) + let index = loadRunningFixture( + fixture, + processArguments: reusedProcess, + processIdentity: reusedIdentity + ) + + #expect(index.entry( + workspaceId: fixture.workspaceID, + panelId: fixture.panelID + )?.processLiveness == .exited) + #expect(!index.hasUncertainStablePanelEntry(panelId: fixture.panelID)) + #expect(index.snapshot( + workspaceId: fixture.workspaceID, + panelId: fixture.panelID + )?.sessionId == fixture.sessionID) + } + + @Test("Conclusive generation evidence avoids an additional presence probe", arguments: [false, true]) + func conclusiveGenerationAvoidsRedundantPresenceProbe(generationMatches: Bool) throws { + let fixture = try makeFixture(prefix: "cmux-conclusive-pid-presence") + defer { cleanup(fixture) } + let recordedIdentity = AgentPIDProcessIdentity( + pid: pid_t(fixture.processID), + startSeconds: Int64(fixture.updatedAt), + startMicroseconds: 0 + ) + try writeStoredProcessIdentity(recordedIdentity, to: fixture) + let currentIdentity = AgentPIDProcessIdentity( + pid: pid_t(fixture.processID), + startSeconds: recordedIdentity.startSeconds + (generationMatches ? 0 : 1), + startMicroseconds: 0 + ) + let arguments = codexProcessArguments(for: fixture) + var presenceProbeCount = 0 + let index = RestorableAgentSessionIndex.load( + homeDirectory: fixture.root.path, + fileManager: fixture.fileManager, + registry: CmuxVaultAgentRegistry(registrations: []), + detectedSnapshots: [:], + processArgumentsProvider: { _ in arguments }, + processPresenceProvider: { _ in + presenceProbeCount += 1 + return .present + }, + processIdentityProvider: { _ in currentIdentity } + ) + + #expect(index.entry( + workspaceId: fixture.workspaceID, + panelId: fixture.panelID + )?.processLiveness == (generationMatches ? .running : .exited)) + // A mismatched PID still needs one presence check to keep an occupied + // process out of pressure reclamation; restore adds no second probe. + #expect(presenceProbeCount == (generationMatches ? 0 : 1)) + } + @Test("A present mismatched hook PID is not pressure-safe") func presentMismatchedHookPIDIsNotPressureSafe() throws { let fixture = try makeFixture(prefix: "cmux-mismatched-present-pid") @@ -250,6 +322,37 @@ struct RestorableAgentProcessGenerationTests { )?.processLiveness == .unknown) } + @Test("A legacy PID with contradictory argv is available for restore without identity") + func legacyPIDWithContradictoryArgumentsDoesNotBlockRestoreWithoutIdentity() throws { + let fixture = try makeFixture(prefix: "cmux-legacy-pid-missing-identity") + defer { cleanup(fixture) } + let unrelatedProcess = CmuxTopProcessArguments( + arguments: ["/usr/local/bin/claude"], + environment: [ + "CMUX_WORKSPACE_ID": fixture.workspaceID.uuidString, + "CMUX_SURFACE_ID": fixture.panelID.uuidString, + ] + ) + let index = RestorableAgentSessionIndex.load( + homeDirectory: fixture.root.path, + fileManager: fixture.fileManager, + registry: CmuxVaultAgentRegistry(registrations: []), + detectedSnapshots: [:], + environment: ["CMUX_AGENT_HOOK_STATE_DIR": fixture.hookStateDirectory.path], + processArgumentsProvider: { pid in + pid == fixture.processID ? unrelatedProcess : nil + }, + processPresenceProvider: { _ in .present }, + processIdentityProvider: { _ in nil } + ) + + #expect(index.entry( + workspaceId: fixture.workspaceID, + panelId: fixture.panelID + )?.processLiveness == .exited) + #expect(!index.hasUncertainStablePanelEntry(panelId: fixture.panelID)) + } + @Test("Missing identity for a recorded live PID fails closed") func missingIdentityForRecordedLivePIDFailsClosed() throws { let fixture = try makeFixture(prefix: "cmux-partial-pid-identity") diff --git a/cmuxTests/RestorableAgentSessionIndexTests.swift b/cmuxTests/RestorableAgentSessionIndexTests.swift index 7306cde96f6e..f45e75d254e9 100644 --- a/cmuxTests/RestorableAgentSessionIndexTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexTests.swift @@ -502,7 +502,7 @@ struct RestorableAgentSessionIndexTests { // the start directory's project folder. Fork/resume must cd into the directory that actually // holds the transcript, otherwise `claude --resume` fails with "No conversation found". // - // The launch path contains a "." so this also exercises encodeClaudeProjectDir's "." -> "-" + // The launch path contains punctuation so this also exercises Claude's full slug rule. // contract, and the on-disk fixture is placed using a project-dir name computed independently of // the production helper so a regression in that helper fails the test instead of being masked. @Test @@ -1370,7 +1370,8 @@ struct RestorableAgentSessionIndexTests { for relativeCwd in ["repo/.claude/worktrees/feat-X", "repo/.worktrees/feat-x"] { let cwd = root.appendingPathComponent(relativeCwd, isDirectory: true) let projectDirName = expectedClaudeProjectDirName(cwd.path) - // The pre-fix encoder kept ".", so a transcript there belongs to a different project dir. + // A slash-only directory is noncanonical because Claude replaces every punctuation + // scalar, including the dots in these paths. let slashOnlyDirName = cwd.path.replacingOccurrences(of: "/", with: "-") let otherProjectDirName = expectedClaudeProjectDirName(root.appendingPathComponent("other").path) for (dirName, sessionId) in [ @@ -1403,12 +1404,25 @@ struct RestorableAgentSessionIndexTests { try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: directory.path) } - /// Mirrors Claude's external project-directory naming rule ("/" and "." both become "-") + /// Mirrors Claude's external project-directory naming rule independently of production code. + /// Every non-ASCII-alphanumeric scalar becomes `-`. /// independently of the production `encodeClaudeProjectDir`, so these regression tests fail if /// that helper regresses instead of masking it by sharing the same code path. private func expectedClaudeProjectDirName(_ path: String) -> String { - path.replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: ".", with: "-") + String(path.unicodeScalars.map { scalar -> Character in + switch scalar { + case "a"..."z", "A"..."Z", "0"..."9": return Character(scalar) + default: return "-" + } + }) + } + + @Test + func testClaudeProjectDirEncodingCoversPunctuationAndUnicode() { + XCTAssertEqual( + RestorableAgentSessionIndex.encodeClaudeProjectDir("/Users/a/my_proj.v2/home/é"), + "-Users-a-my-proj-v2-home--" + ) } // A custom Vault agent defaults to cwd: .preserve and can expand {{cwd}} in its resume template, From ccc1e1c6c5472aff8b96ad5942ea3d9b4b8e4a30 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 21:40:49 -0700 Subject: [PATCH 004/183] Allow shared team members to enable Cloud (#20425) * test: allow shared team members through Cloud gate * fix: let accepted team members enable Cloud * test: keep personal team behind Cloud upgrade gate * fix: keep personal Cloud scope behind Pro gate * fix: explain shared team Cloud access --- Resources/Localizable.xcstrings | 118 ++++++++++++++++++ .../Cloud/CloudMachinesEnablementView.swift | 24 +++- Sources/Cloud/CloudWelcomeView.swift | 24 +++- .../Cloud/CloudWelcomeWindowController.swift | 8 +- .../Cloud/MachinesPanelView+Activation.swift | 3 + Sources/HostSettingsActions+Cloud.swift | 10 +- cmuxTests/CloudWelcomeTests.swift | 30 +++++ 7 files changed, 211 insertions(+), 6 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b69ac342eda8..2e391b6b40c3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -652234,6 +652234,124 @@ } } } + }, + "cloud.welcome.note.teamAvailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud can be enabled through your team; machine access follows the team’s plan." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يمكن تفعيل Cloud عبر فريقك؛ ويعتمد الوصول إلى الأجهزة على خطة الفريق." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud kann über Ihr Team aktiviert werden; der Maschinenzugriff richtet sich nach dem Teamplan." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cloud se puede activar a través de tu equipo; el acceso a las máquinas depende del plan del equipo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cloud peut être activé via votre équipe ; l’accès aux machines dépend du forfait de l’équipe." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud はチームを通じて有効にできます。マシンへのアクセスはチームのプランに従います。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀을 통해 Cloud를 활성화할 수 있으며 머신 사용 권한은 팀 요금제에 따라 결정됩니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可通过您的团队启用 Cloud;机器访问权限取决于团队方案。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 可透過您的團隊啟用;機器存取權限取決於團隊方案。" + } + } + } + }, + "cloud.welcome.note.teamIncluded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Included through your team’s plan." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مضمّن في خطة فريقك." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Teamplan enthalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Incluido en el plan de tu equipo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inclus dans le forfait de votre équipe." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームのプランに含まれています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 요금제에 포함되어 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已包含在您团队的方案中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已包含在您團隊的方案中。" + } + } + } } }, "version": "1.0" diff --git a/Sources/Cloud/CloudMachinesEnablementView.swift b/Sources/Cloud/CloudMachinesEnablementView.swift index a0c3d957582d..9fae10ef8208 100644 --- a/Sources/Cloud/CloudMachinesEnablementView.swift +++ b/Sources/Cloud/CloudMachinesEnablementView.swift @@ -45,6 +45,10 @@ struct CloudMachinesEnablementView: View { /// The account's plan is known (not just "a request finished"). let isPlanKnown: Bool let isProActive: Bool + /// Whether the signed-in account belongs to an accepted shared team. + /// This opens Cloud for the team's scope without changing the account's + /// personal plan or its personal VM allowance. + let hasCloudTeamAccess: Bool /// Title for ``CloudMachinesEnablementPhase/unavailable`` (the feature /// flag or managed policy message). let unavailableMessage: String @@ -101,7 +105,19 @@ struct CloudMachinesEnablementView: View { /// Free accounts see Upgrade to Pro in place of Enable Cloud. private var isProGated: Bool { - isPlanKnown && !isProActive + Self.shouldRequirePro( + isPlanKnown: isPlanKnown, + isProActive: isProActive, + hasCloudTeamAccess: hasCloudTeamAccess + ) + } + + static func shouldRequirePro( + isPlanKnown: Bool, + isProActive: Bool, + hasCloudTeamAccess: Bool + ) -> Bool { + isPlanKnown && !isProActive && !hasCloudTeamAccess } // MARK: Introduction (Enable or Upgrade) @@ -193,6 +209,12 @@ struct CloudMachinesEnablementView: View { if isPlanKnown && isProActive { return String(localized: "cloud.welcome.note.included", defaultValue: "Included in your plan.") } + if hasCloudTeamAccess { + return String( + localized: "cloud.welcome.note.teamAvailable", + defaultValue: "Cloud can be enabled through your team; machine access follows the team’s plan." + ) + } return String(localized: "cloud.welcome.note.available", defaultValue: "Cloud is available on cmux Pro and Max.") } diff --git a/Sources/Cloud/CloudWelcomeView.swift b/Sources/Cloud/CloudWelcomeView.swift index c3899f7664ce..194e4e471dff 100644 --- a/Sources/Cloud/CloudWelcomeView.swift +++ b/Sources/Cloud/CloudWelcomeView.swift @@ -11,11 +11,23 @@ enum CloudWelcomeNextStep: Equatable { /// whose own gate decides between Upgrade and Enable. case openCloud - static func resolve(isAuthenticated: Bool, isPlanKnown: Bool, isPro: Bool) -> CloudWelcomeNextStep { + /// Stack Auth creates one personal team for a normal account. A free + /// account gets shared-team access only when its accepted list contains an + /// additional team. + static func hasSharedTeamAccess(availableTeamCount: Int) -> Bool { + availableTeamCount > 1 + } + + static func resolve( + isAuthenticated: Bool, + isPlanKnown: Bool, + isPro: Bool, + hasCloudTeamAccess: Bool = false + ) -> CloudWelcomeNextStep { // Signed out reads like Free: Upgrade to Pro (the pricing page signs in). guard isAuthenticated else { return .upgrade } guard isPlanKnown else { return .openCloud } - return isPro ? .enable : .upgrade + return isPro || hasCloudTeamAccess ? .enable : .upgrade } } @@ -28,6 +40,8 @@ struct CloudWelcomeView: View { let nextStep: CloudWelcomeNextStep let onNotNow: () -> Void let onNext: (CloudWelcomeNextStep) -> Void + /// Whether the account has an accepted team in addition to its personal scope. + var hasCloudTeamAccess = false /// Where each slide's clip lives (the lab points this at a folder). var mediaURL: (CloudWelcomeSlide) -> URL? = CloudWelcomeMediaCarousel.bundledMediaURL // Lab toggles while the slider is designed. @@ -340,6 +354,12 @@ struct CloudWelcomeView: View { ) switch nextStep { case .enable: + if hasCloudTeamAccess { + return [String( + localized: "cloud.welcome.note.teamIncluded", + defaultValue: "Included through your team’s plan." + )] + } return [String(localized: "cloud.welcome.note.alreadyIncluded", defaultValue: "Already included in your plan.")] case .signIn, .upgrade, .openCloud: return [available] diff --git a/Sources/Cloud/CloudWelcomeWindowController.swift b/Sources/Cloud/CloudWelcomeWindowController.swift index 3313e9411bc9..9f829915845d 100644 --- a/Sources/Cloud/CloudWelcomeWindowController.swift +++ b/Sources/Cloud/CloudWelcomeWindowController.swift @@ -319,10 +319,16 @@ private struct CloudWelcomeAccountView: View { nextStep: CloudWelcomeNextStep.resolve( isAuthenticated: accountFlow?.isAuthenticated == true, isPlanKnown: accountFlow?.hasLoadedBillingPlan == true, - isPro: accountFlow?.isProActive == true + isPro: accountFlow?.isProActive == true, + hasCloudTeamAccess: CloudWelcomeNextStep.hasSharedTeamAccess( + availableTeamCount: accountFlow?.availableTeams.count ?? 0 + ) ), onNotNow: onNotNow, onNext: onNext, + hasCloudTeamAccess: CloudWelcomeNextStep.hasSharedTeamAccess( + availableTeamCount: accountFlow?.availableTeams.count ?? 0 + ), sliderShowsFeatureList: sliderShowsFeatureList, sliderListUsesDots: sliderListUsesDots, posterHeadline: posterHeadline, diff --git a/Sources/Cloud/MachinesPanelView+Activation.swift b/Sources/Cloud/MachinesPanelView+Activation.swift index 24520ccae80e..e7f533963a2b 100644 --- a/Sources/Cloud/MachinesPanelView+Activation.swift +++ b/Sources/Cloud/MachinesPanelView+Activation.swift @@ -60,6 +60,9 @@ struct CloudMachinesEnablementPanel: View { billingPlanLoaded: billingPlanLoaded, isPlanKnown: accountFlow?.hasLoadedBillingPlan == true, isProActive: accountFlow?.isProActive == true, + hasCloudTeamAccess: CloudWelcomeNextStep.hasSharedTeamAccess( + availableTeamCount: accountFlow?.availableTeams.count ?? 0 + ), unavailableMessage: CloudMachinesFeature.disabledMessage, chromeBackgroundColor: chromeBackgroundColor, onEnable: { coordinator.enable() }, diff --git a/Sources/HostSettingsActions+Cloud.swift b/Sources/HostSettingsActions+Cloud.swift index 45514f5c40a8..a91d25a302ef 100644 --- a/Sources/HostSettingsActions+Cloud.swift +++ b/Sources/HostSettingsActions+Cloud.swift @@ -88,8 +88,14 @@ extension HostSettingsActions { guard !Task.isCancelled, flow.currentIdentity?.id == accountID, flow.confirmedTeamID == requestedTeamID else { return nil } - // Same answer the Cloud tab uses, so both show Upgrade for Free plans. - return flow.hasLoadedBillingPlan ? flow.isProActive : nil + // A member can use the selected team's Cloud scope without a personal + // Pro subscription. Personal VM access remains server-scoped to the + // user's own plan. + return flow.hasLoadedBillingPlan + ? flow.isProActive || CloudWelcomeNextStep.hasSharedTeamAccess( + availableTeamCount: flow.availableTeams.count + ) + : nil } func openCloudMachinesPanel() { diff --git a/cmuxTests/CloudWelcomeTests.swift b/cmuxTests/CloudWelcomeTests.swift index 3fd2f62c0ee0..f327d7b9dc5e 100644 --- a/cmuxTests/CloudWelcomeTests.swift +++ b/cmuxTests/CloudWelcomeTests.swift @@ -311,6 +311,36 @@ struct CloudWelcomeTests { #expect(CloudWelcomeNextStep.resolve(isAuthenticated: true, isPlanKnown: false, isPro: false) == .openCloud) #expect(CloudWelcomeNextStep.resolve(isAuthenticated: true, isPlanKnown: true, isPro: false) == .upgrade) #expect(CloudWelcomeNextStep.resolve(isAuthenticated: true, isPlanKnown: true, isPro: true) == .enable) + + // A free account that has accepted membership in a shared team can use + // that team's Cloud scope without buying a personal Pro subscription. + #expect(CloudWelcomeNextStep.resolve( + isAuthenticated: true, + isPlanKnown: true, + isPro: false, + hasCloudTeamAccess: true + ) == .enable) + } + + @Test("only an additional accepted team opens Cloud for a free personal account") + func sharedTeamAccessRequiresMoreThanPersonalTeam() { + #expect(!CloudWelcomeNextStep.hasSharedTeamAccess(availableTeamCount: 0)) + #expect(!CloudWelcomeNextStep.hasSharedTeamAccess(availableTeamCount: 1)) + #expect(CloudWelcomeNextStep.hasSharedTeamAccess(availableTeamCount: 2)) + } + + @Test("a shared team does not turn a personal free scope into Pro") + func sharedTeamAccessOnlyOpensCloud() { + #expect(!CloudMachinesEnablementView.shouldRequirePro( + isPlanKnown: true, + isProActive: false, + hasCloudTeamAccess: true + )) + #expect(CloudMachinesEnablementView.shouldRequirePro( + isPlanKnown: true, + isProActive: false, + hasCloudTeamAccess: false + )) } @Test("feature clips keep their stable product order") From 5b61020ac3fa058363a2551e104a288397580343 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 21:42:33 -0700 Subject: [PATCH 005/183] Bump Bonsplit: stop per-render tab-strip work behind CMUXTERM-MACOS-41NN hangs (#20238) * Bump Bonsplit: stop per-render tab-strip work behind hang reports Pins manaflow-ai/bonsplit#287 (663bc41): context-menu state is built when a menu opens, the hint monitor no longer reads user defaults on every tab-bar init, and the whole-tab hint animation is gone. Sentry: CMUXTERM-MACOS-41NN, -2QV2, -2QAN, -2SCZ, -2PRX, -26QQ. Co-Authored-By: Claude Opus 5.5 * Bump Bonsplit: resolve the hint symbol when the monitor starts Pins manaflow-ai/bonsplit#287 review follow-up (9d90480). Co-Authored-By: Claude Opus 5.5 * Diagnose sustained tab-bar render storms * Tighten tab-bar render storm diagnostics * Name tab-bar diagnostics as render windows --------- Co-authored-by: Claude Opus 5.5 --- Sources/Workspace.swift | 14 ++++++++++++++ vendor/bonsplit | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5b3200ca95b6..27f64779d774 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4286,6 +4286,20 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos configuration: config, tabDragTransferRegistry: tabDragTransferRegistry ) + self.bonsplitController.onTabBarRenderDiagnostic = { diagnostic in + Task { @MainActor in + sentryBreadcrumb( + "tabbar.render.window", + category: "performance", + data: [ + "elapsedMs": Int((diagnostic.elapsed * 1_000).rounded()), + "renderCount": diagnostic.renderCount, + "tabCount": diagnostic.tabCount, + "paneCount": diagnostic.paneCount + ] + ) + } + } self.bonsplitController.tabMiddleClickCapture = { onMiddleClick in AnyView(MiddleClickCapture(onMiddleClick: onMiddleClick)) } diff --git a/vendor/bonsplit b/vendor/bonsplit index 093f774ff08f..d67d1b114fd2 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 093f774ff08f9a7480051a10fba313bd8797b443 +Subproject commit d67d1b114fd20edba7a19f76cc39e35bde43c2d8 From a80788308ffcca6ba88f854e60c4919b183d0b2b Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 21:52:48 -0700 Subject: [PATCH 006/183] Fix custom upload command remote paths (#20260) * test: reproduce custom upload copy into missing directory * fix: keep custom upload paths standalone * fix: preserve custom upload extension names * test: cover bounded custom upload extensions * fix: bound custom upload remote extensions * fix: append bounded extension scalars --- .../CmuxConfigSchema.generated.swift | 2 +- .../Values/TerminalUploadCommandRule.swift | 6 +- Sources/TerminalCustomUploadRunner.swift | 7 +- Sources/TerminalUploadCommand.swift | 30 +++++++-- cmuxTests/TerminalUploadCommandTests.swift | 66 ++++++++++++++++++- docs/configuration.md | 43 ++++++------ web/data/cmux.schema.json | 2 +- 7 files changed, 118 insertions(+), 38 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift index 27be7efa6704..5747fadd9fb4 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift @@ -1374,7 +1374,7 @@ enum CmuxEmbeddedConfigSchema { "type": "string", "minLength": 1, "pattern": "\\S", - "description": "Shell command run via /bin/sh -c, once per file. It receives the file and endpoint on its environment (CMUX_UPLOAD_LOCAL_PATH, CMUX_UPLOAD_REMOTE_PATH, CMUX_UPLOAD_DESTINATION, CMUX_UPLOAD_PORT, CMUX_UPLOAD_IDENTITY_FILE, CMUX_UPLOAD_SSH_OPTIONS). CMUX_UPLOAD_REMOTE_PATH is a file under ~/.cache/cmux/paste//, a directory the command has to create before writing to it. Its stdout is what cmux inserts at the cursor." + "description": "Shell command run via /bin/sh -c, once per file. It receives the file and endpoint on its environment (CMUX_UPLOAD_LOCAL_PATH, CMUX_UPLOAD_REMOTE_PATH, CMUX_UPLOAD_DESTINATION, CMUX_UPLOAD_PORT, CMUX_UPLOAD_IDENTITY_FILE, CMUX_UPLOAD_SSH_OPTIONS). CMUX_UPLOAD_REMOTE_PATH is an absolute /tmp/cmux-drop-. path whose parent already exists; the extension is lowercased. The command owns file permissions and cleanup. Its stdout is what cmux inserts at the cursor." }, "enabled": { "type": "boolean", diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/TerminalUploadCommandRule.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/TerminalUploadCommandRule.swift index 35b95d0b326c..009dcc12599e 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/TerminalUploadCommandRule.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/TerminalUploadCommandRule.swift @@ -13,9 +13,9 @@ nonisolated private let terminalUploadCommandRuleLogger = Logger( /// stdout (or the remote path it chose, if the command prints nothing). A rule /// with no `hostPattern` matches every remote host. /// -/// The remote path cmux hands the command is in a per-session directory under -/// `~/.cache/cmux/paste` that only the built-in `scp` creates. A command that -/// writes there creates the directory first. +/// The remote path cmux hands the command is a standalone file under `/tmp`. +/// The command owns the transport, file permissions, and cleanup. The built-in +/// transport's private per-session directory is separate from custom commands. /// /// Semantics mirror `~/.ssh/config` `Host` blocks: a glob pattern (`*`, `?` via /// `fnmatch`), **first match wins**, no match → the built-in `scp` transport. diff --git a/Sources/TerminalCustomUploadRunner.swift b/Sources/TerminalCustomUploadRunner.swift index 9e7042be4da2..41f4225372bd 100644 --- a/Sources/TerminalCustomUploadRunner.swift +++ b/Sources/TerminalCustomUploadRunner.swift @@ -1,6 +1,5 @@ import CmuxCloud import CmuxFoundation -import CmuxRemoteSession import CmuxSettings import Darwin import Foundation @@ -79,7 +78,6 @@ struct TerminalCustomUploadRunner { fileURLs: [URL], endpoint: Endpoint, command: String, - remotePastePolicy: RemotePasteFileTransferPolicy = RemotePasteFileTransferPolicy(), operation: TerminalImageTransferOperation, timeout: TimeInterval = 120, completion: @escaping (Result) -> Void @@ -92,7 +90,6 @@ struct TerminalCustomUploadRunner { fileURLs: fileURLs, endpoint: endpoint, command: command, - remotePastePolicy: remotePastePolicy, operation: operation, timeout: timeout )) @@ -103,7 +100,6 @@ struct TerminalCustomUploadRunner { fileURLs: [URL], endpoint: Endpoint, command: String, - remotePastePolicy: RemotePasteFileTransferPolicy = RemotePasteFileTransferPolicy(), operation: TerminalImageTransferOperation, timeout: TimeInterval = 120 ) -> Result { @@ -116,7 +112,7 @@ struct TerminalCustomUploadRunner { guard normalizedLocalURL.isFileURL else { throw Self.uploadError("Dropped item is not a local file.") } - let remotePath = remotePastePolicy.remotePath(for: normalizedLocalURL) + let remotePath = TerminalUploadCommand.customRemotePath(for: normalizedLocalURL) let env = TerminalUploadCommand.environment( localPath: normalizedLocalURL.path, remotePath: remotePath, @@ -190,7 +186,6 @@ struct TerminalCustomUploadRunner { fileURLs: fileURLs, endpoint: endpoint, command: command, - remotePastePolicy: session.remotePastePolicy, operation: operation ) { result in cleanup(fileURLs) diff --git a/Sources/TerminalUploadCommand.swift b/Sources/TerminalUploadCommand.swift index 39335f6a1133..579040221e81 100644 --- a/Sources/TerminalUploadCommand.swift +++ b/Sources/TerminalUploadCommand.swift @@ -93,10 +93,9 @@ struct TerminalUploadCommand: Sendable, Equatable { /// on the environment (not stdin) so a plain shell one-liner can use it; the /// standard process environment is inherited by the caller. /// - /// `remotePath` comes from ``RemotePasteFileTransferPolicy`` and names a file in - /// a per-session directory under `~/.cache/cmux/paste`. Only the built-in - /// transport creates and cleans that directory, so a command that writes to the - /// path creates the directory itself. + /// `remotePath` is a standalone path under `/tmp` for the custom command. The + /// command owns its transport, permissions, and cleanup; the built-in SSH + /// transport uses its separate private per-session directory. static func environment( localPath: String, remotePath: String, @@ -122,6 +121,29 @@ struct TerminalUploadCommand: Sendable, Equatable { return env } + /// The path handed to a custom upload command. It is intentionally independent + /// of the built-in SSH transport's private session directory: custom commands + /// may use an interactive SSH wrapper, an artifact service, or another + /// transport that cannot run cmux's directory-maintenance SSH command. + static func customRemotePath(for fileURL: URL, uuid: UUID = UUID()) -> String { + // Preserve the path shape used by custom commands before the private + // session directory was introduced in #16523. + let extensionValue = fileURL.pathExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + // Keep the optional extension useful to commands that inspect the + // destination, but bound it by UTF-8 bytes so the generated name stays + // well below the 255-byte filename-component limit on remote filesystems. + var boundedExtension = String() + for scalar in extensionValue.unicodeScalars { + let scalarText = String(scalar) + guard boundedExtension.utf8.count + scalarText.utf8.count <= 16 else { break } + boundedExtension.unicodeScalars.append(scalar) + } + let suffix = boundedExtension.isEmpty ? "" : "." + boundedExtension + return "/tmp/cmux-drop-\(uuid.uuidString.lowercased())\(suffix)" + } + /// The ready-to-type string cmux inserts for one file after the command runs. /// /// Non-empty command output is typed verbatim — a path, URL, or any reference diff --git a/cmuxTests/TerminalUploadCommandTests.swift b/cmuxTests/TerminalUploadCommandTests.swift index e06455606cbe..86bbd4f07f25 100644 --- a/cmuxTests/TerminalUploadCommandTests.swift +++ b/cmuxTests/TerminalUploadCommandTests.swift @@ -365,6 +365,70 @@ import Testing #expect(text == "OUT:/tmp/a.png OUT:/tmp/b.png") } + @Test func realCopyToRemotePathNeedsNoDirectorySetup() throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + let source = directory.appendingPathComponent("Screen Shot.PNG") + let payload = Data("upload regression".utf8) + try payload.write(to: source) + var destinations: [String] = [] + defer { + for path in destinations { try? fileManager.removeItem(atPath: path) } + } + + // Model a fresh remote home, expanding ~/ as SCP would, and run the actual + // shell copy. The command does no mkdir and prints no path; cmux must + // supply a usable destination and return it for insertion after copying. + let runner = runner { command, environment, timeout, operation in + var environment = environment + let path = environment["CMUX_UPLOAD_REMOTE_PATH"] ?? "" + let destination = path.hasPrefix("~/") ? directory.path + String(path.dropFirst()) : path + environment["CMUX_UPLOAD_REMOTE_PATH"] = destination + destinations.append(destination) + return try TerminalCustomUploadRunner.spawnCommand( + command: command, environment: environment, timeout: timeout, operation: operation + ) + } + let result = runner.runSync( + fileURLs: [source, source], + endpoint: endpoint(), + command: "cp \"$CMUX_UPLOAD_LOCAL_PATH\" \"$CMUX_UPLOAD_REMOTE_PATH\"", + operation: TerminalImageTransferOperation() + ) + let text = try result.get() + + #expect(destinations.count == 2) + #expect(Set(destinations).count == 2, "each upload needs its own destination") + for path in destinations { + #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == payload) + } + #expect(text == destinations.map(TerminalImageTransferPlanner.escapeForShell).joined(separator: " ")) + } + + @Test func customRemotePathPreservesLegacyExtensionShape() { + let uuid = UUID(uuidString: "12345678-1234-1234-1234-1234567890AB")! + #expect( + TerminalUploadCommand.customRemotePath( + for: URL(fileURLWithPath: "/tmp/file.tar-gz"), + uuid: uuid + ) == "/tmp/cmux-drop-12345678-1234-1234-1234-1234567890ab.tar-gz" + ) + } + + @Test func customRemotePathBoundsLongExtensionByUTF8Bytes() { + let uuid = UUID(uuidString: "12345678-1234-1234-1234-1234567890AB")! + let longExtension = String(repeating: "x", count: 300) + let path = TerminalUploadCommand.customRemotePath( + for: URL(fileURLWithPath: "/tmp/file.\(longExtension)"), + uuid: uuid + ) + + #expect(path == "/tmp/cmux-drop-12345678-1234-1234-1234-1234567890ab.\(String(repeating: "x", count: 16))") + #expect(path.utf8.count < 255) + } + @Test func nonZeroExitFailsClosed() { let result = runner { _, _, _, _ in (1, "", "boom") }.runSync( fileURLs: [URL(fileURLWithPath: "/tmp/a.png")], @@ -389,7 +453,7 @@ import Testing return } // Falls back to the cmux-chosen remote path (escaped). - #expect(text.contains("cmux-paste")) + #expect(text.contains("cmux-drop")) } @Test func cancelledOperationFailsClosed() { diff --git a/docs/configuration.md b/docs/configuration.md index c0a3e80e54fb..bc61d2281c93 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -361,7 +361,7 @@ instead and inserts what the command prints. against either the alias or the real host works. - `command`: run through `/bin/sh -c`, **once per file**. It receives the file and endpoint on its environment: `CMUX_UPLOAD_LOCAL_PATH`, `CMUX_UPLOAD_REMOTE_PATH` - (the `~/.cache/cmux/paste//cmux-paste-.` path cmux picked; + (the `/tmp/cmux-drop-.` path cmux picked; see below), `CMUX_UPLOAD_DESTINATION`, `CMUX_UPLOAD_PORT`, `CMUX_UPLOAD_IDENTITY_FILE`, and `CMUX_UPLOAD_SSH_OPTIONS` (newline-separated; the last three are unset when the session has none). The @@ -372,32 +372,31 @@ instead and inserts what the command prints. **First matching enabled rule wins.** If no rule matches, the built-in `scp` runs unchanged, so other hosts are untouched. -### The remote path is yours to create +### The custom command owns the upload -`CMUX_UPLOAD_REMOTE_PATH` starts with `~/` and names a file in a directory that -does not exist yet. The built-in transport creates that directory with mode -`0700` before it copies, sets the file to `0600`, deletes files older than a day -and trims the directory when it passes 200 MB. cmux does none of that for a -custom command, so a command that writes to the path has to create the directory -first. A bare `scp` to it fails with `No such file or directory`: +`CMUX_UPLOAD_REMOTE_PATH` is an absolute path under `/tmp`, with a random name +and a lowercased file extension. Its parent already exists, so a command can +copy to it without creating a session directory: ```sh -dir=$(dirname "$CMUX_UPLOAD_REMOTE_PATH") -ssh "$CMUX_UPLOAD_DESTINATION" "umask 077; mkdir -p \"\$HOME/${dir#\~/}\"" && - scp "$CMUX_UPLOAD_LOCAL_PATH" "$CMUX_UPLOAD_DESTINATION:$CMUX_UPLOAD_REMOTE_PATH" +scp "$CMUX_UPLOAD_LOCAL_PATH" "$CMUX_UPLOAD_DESTINATION:$CMUX_UPLOAD_REMOTE_PATH" ``` -The example reaches the host with the settings in your own ssh configuration. A -session that was opened with a port, an identity file or `-o` options hands them -to the command as `CMUX_UPLOAD_PORT`, `CMUX_UPLOAD_IDENTITY_FILE` and -`CMUX_UPLOAD_SSH_OPTIONS`, and a command for such a host has to pass them to -both `ssh` and `scp`. `scp` copies over SFTP by default. Use `scp -O` if the -server does not support the `expand-path@openssh.com` extension (OpenSSH -`sftp-server` added it in 8.7). It copies over the legacy protocol and lets the -remote shell expand the leading `~`. - -Cleaning up old files there is also the command's job. A command that stores -the file somewhere else can ignore the path and print where it put the file. +The example uses your ssh configuration. If the session supplies +`CMUX_UPLOAD_PORT`, `CMUX_UPLOAD_IDENTITY_FILE` or `CMUX_UPLOAD_SSH_OPTIONS`, +the command must pass those settings to its transport as needed. + +The command owns file permissions and cleanup. For sensitive files, use a +private destination and set appropriate permissions through your upload tool. +A command that stores the file somewhere else can ignore the suggested path +and print where it put the file. cmux does not open an extra SSH connection +before running the command, so authentication wrappers and non-SSH upload +services can work independently. + +The built-in transport uses a private per-session directory under +`~/.cache/cmux/paste`, creates it with mode `0700`, sets uploaded files to `0600`, +and removes old or excess files. These directory and cleanup rules apply to +built-in uploads; custom commands manage their own destinations. ### How the command's output is used diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 0aee69342450..e676cbc939fb 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1364,7 +1364,7 @@ "type": "string", "minLength": 1, "pattern": "\\S", - "description": "Shell command run via /bin/sh -c, once per file. It receives the file and endpoint on its environment (CMUX_UPLOAD_LOCAL_PATH, CMUX_UPLOAD_REMOTE_PATH, CMUX_UPLOAD_DESTINATION, CMUX_UPLOAD_PORT, CMUX_UPLOAD_IDENTITY_FILE, CMUX_UPLOAD_SSH_OPTIONS). CMUX_UPLOAD_REMOTE_PATH is a file under ~/.cache/cmux/paste//, a directory the command has to create before writing to it. Its stdout is what cmux inserts at the cursor." + "description": "Shell command run via /bin/sh -c, once per file. It receives the file and endpoint on its environment (CMUX_UPLOAD_LOCAL_PATH, CMUX_UPLOAD_REMOTE_PATH, CMUX_UPLOAD_DESTINATION, CMUX_UPLOAD_PORT, CMUX_UPLOAD_IDENTITY_FILE, CMUX_UPLOAD_SSH_OPTIONS). CMUX_UPLOAD_REMOTE_PATH is an absolute /tmp/cmux-drop-. path whose parent already exists; the extension is lowercased. The command owns file permissions and cleanup. Its stdout is what cmux inserts at the cursor." }, "enabled": { "type": "boolean", From 1db7d00c04e8fe042dff1e7c7ffefdd8c5371e74 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:13:24 -0700 Subject: [PATCH 007/183] Name every Cloud diagnostics 429 and keep failures and drop reports past the budget (#20435) * test(cloud diagnostics): ingest refusals name their cause and keep failures and drop reports Red on main at the real handler with Postgres: the byte-budget and ingress 429s carry no refusal code, routine detail that fills the minute refuses a failure batch, and a sampled-out poll that reports queue drops is discarded. * Name every Cloud diagnostics 429 and keep failures and drop reports past the budget Production 429s on POST /api/observability/cloud all come from the shared cmux-analytics firewall rule, which is keyed by connection IP; the route span could not say so. The route span now records the refusal cause (ingress_rate_limited with the rule ID, account_byte_budget, event_conflict, storage_unavailable), the keyed account hash and the install ID. Batches with a failure, timeout, cancellation or sign-in record get a 128 KiB reserve above the 256 KiB routine minute budget, so routine success detail cannot refuse them. A sampled-out poll span that carries the client's dropped count is now stored, since it is the only record of that loss. * Count every Cloud diagnostics refusal despite head sampling; keep evidence from over-budget batches Review follow-up. The route span is head-sampled at 2%, so a refusal now also emits one always-kept cmux.cloud_diagnostics.refusal span with the cause, firewall rule, account key, install ID and counts. The firewall rule travels as the ingress refusal value instead of a span side effect. The byte budget no longer lets routine spans spend the reserve: a batch over the routine budget stores only its failure, timeout, cancellation and sign-in records within the reserve, commits them, and still answers 429, so the client's retry deduplicates them. The install ID is labeled cmux.client.instance_id so it cannot be confused with the server's service.instance.id. Sampling docs name the drop-report exception. * Coalesce repeatable Cloud diagnostics refusal spans; drain evidence kept from refused batches Second review follow-up. A firewall refusal runs before authentication, so an always-kept span per refusal let an anonymous flood become kept Axiom spans. Firewall and malformed-body refusals now emit at most one span per cause, rule and account per 10 s on each instance, with cmux.telemetry.refusal_count. Budget, conflict and storage refusals stay one span each. Evidence stored from a budget-refused batch schedules a drain, and the refusal reports it as cmux.telemetry.stored_spans instead of accepted_spans. --- web/app/api/observability/cloud/route.ts | 3 +- .../observability/cloudTelemetryExport.ts | 9 +- .../observability/cloudTelemetryIngest.ts | 142 +++++++++++++-- .../observability/cloudTelemetryRepository.ts | 61 +++++-- .../observability/cloudTelemetrySampling.ts | 16 +- .../cloud-diagnostics-db-behavior.test.ts | 164 +++++++++++++++++- 6 files changed, 351 insertions(+), 44 deletions(-) diff --git a/web/app/api/observability/cloud/route.ts b/web/app/api/observability/cloud/route.ts index ff2d60695503..89323e1fa858 100644 --- a/web/app/api/observability/cloud/route.ts +++ b/web/app/api/observability/cloud/route.ts @@ -16,7 +16,8 @@ export const POST = makeCloudTelemetryHandler({ throw new Error("diagnostics_rate_limit_unconfigured"); } const result = await checkRateLimit(rule, { request }); - if (result.rateLimited || result.error === "blocked") return false; + // Names the firewall rule that refused, so a shared or misconfigured rule is visible in Axiom. + if (result.rateLimited || result.error === "blocked") return { rateLimitRule: rule }; if (result.error) throw new Error("diagnostics_rate_limit_unavailable"); return true; }, diff --git a/web/services/observability/cloudTelemetryExport.ts b/web/services/observability/cloudTelemetryExport.ts index 61e4d603cc8b..9f8070a40276 100644 --- a/web/services/observability/cloudTelemetryExport.ts +++ b/web/services/observability/cloudTelemetryExport.ts @@ -2,6 +2,11 @@ import { createHmac } from "node:crypto"; import type { CloudTelemetryAuth, CloudTelemetrySpan } from "./cloudTelemetryContract"; import type { StoredCloudDiagnostic } from "./cloudTelemetryRepository"; +/** The keyed account hash shared by exported diagnostics and the ingest route span. */ +export function cloudTelemetryAccountKey(identityKey: string, userId: string): string { + return createHmac("sha256", identityKey).update(userId).digest("hex"); +} + export type CloudAxiomConfiguration = { readonly origin: string; readonly token: string; @@ -64,7 +69,7 @@ export async function exportCloudDiagnostics( backend_tag: row.payload.backend?.tag, backend_source_sha256: row.payload.backend?.sourceSha256, backend_revision: row.payload.backend?.revision ?? "unknown", - account_key: createHmac("sha256", configuration.identityKey).update(row.userId).digest("hex"), + account_key: cloudTelemetryAccountKey(configuration.identityKey, row.userId), source: row.payload.source ?? "client", event_id: row.eventId, operation_id: row.payload.span.operationId, trace_id: row.payload.span.traceId, span_id: row.payload.span.spanId, @@ -117,7 +122,7 @@ function resourceAttributes(row: StoredCloudDiagnostic, configuration: CloudAxio "service.instance.id": row.payload.source === "server" ? undefined : client.instanceId, "os.version": row.payload.source === "server" ? undefined : client.osVersion, "host.arch": row.payload.source === "server" ? undefined : client.architecture, - "cmux.account_key": createHmac("sha256", configuration.identityKey).update(row.userId).digest("hex"), + "cmux.account_key": cloudTelemetryAccountKey(configuration.identityKey, row.userId), "cmux.observation.source": row.payload.source ?? "client", }; } diff --git a/web/services/observability/cloudTelemetryIngest.ts b/web/services/observability/cloudTelemetryIngest.ts index a5de493d9994..58c437665a3b 100644 --- a/web/services/observability/cloudTelemetryIngest.ts +++ b/web/services/observability/cloudTelemetryIngest.ts @@ -1,19 +1,29 @@ -import { trace } from "@opentelemetry/api"; +import { SpanKind, trace, type Attributes } from "@opentelemetry/api"; import { Effect } from "effect"; import { readBoundedJsonObject } from "../apns/routePolicy"; import { CLOUD_TELEMETRY_MAX_BYTES, parseCloudTelemetryUpload, type CloudTelemetryBatch, type CloudTelemetryUpload } from "./cloudTelemetryContract"; +import { cloudTelemetryAccountKey } from "./cloudTelemetryExport"; import { sampleCloudTelemetryBatch } from "./cloudTelemetrySampling"; export type CloudTelemetryIngestDependencies = { readonly authenticate: (request: Request) => Promise<{ readonly id: string } | null>; - readonly checkIngress: (request: Request) => Promise; + /** + * The pre-authentication gate. `true` admits the request; `false` or a refusal + * naming the firewall rule answers 429 with refusal `ingress_rate_limited`. + */ + readonly checkIngress: (request: Request) => Promise; /** Stores the sampled spans. `sampleWeights` lists spans that stand for more than one submitted span. */ readonly accept: (userId: string, batch: CloudTelemetryBatch, sampleWeights: ReadonlyMap) => Promise; readonly scheduleDrain: () => void; readonly now: () => number; }; -export class CloudTelemetryLimitError extends Error {} +export type CloudTelemetryIngressRefusal = { readonly rateLimitRule: string }; + +/** The account's byte budget refused the batch. `storedSpans` were kept anyway, from the reserve or earlier receipts. */ +export class CloudTelemetryLimitError extends Error { + constructor(readonly storedSpans = 0) { super("cloud_diagnostics_byte_budget"); } +} export class CloudTelemetryConflictError extends Error {} /** The HTTP adapter runs one Effect program; delivery is owned by the durable outbox. */ @@ -25,26 +35,43 @@ export function makeCloudTelemetryHandler(dependencies: CloudTelemetryIngestDepe function ingest(request: Request, dependencies: CloudTelemetryIngestDependencies) { return Effect.gen(function* () { - const allowed = yield* Effect.tryPromise(() => dependencies.checkIngress(request)); - if (!allowed) return response(429, "rate_limited"); + // Identity labels gathered as the request is understood; refusals carry what is known so far. + const labels: Record = {}; + const ingress = yield* Effect.tryPromise(() => dependencies.checkIngress(request)); + if (ingress !== true) { + if (ingress) labels["cmux.telemetry.rate_limit_rule"] = ingress.rateLimitRule; + // Before authentication and parsing, so only the cause and the rule are known. + return refuse(429, "rate_limited", { submitted: 0, accepted: 0, rejected: 0, coerced: 0, refusal: "ingress_rate_limited" }, labels, dependencies.now()); + } const encoding = request.headers.get("content-encoding"); if (encoding && encoding !== "identity") return response(415, "unsupported_encoding"); if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return response(415, "unsupported_content_type"); const user = yield* Effect.tryPromise(() => dependencies.authenticate(request)); if (!user) return response(401, "unauthorized"); + const accountKey = cloudTelemetryAccountKeyFromEnvironment(user.id); + if (accountKey) labels["cmux.account_key"] = accountKey; const body = yield* Effect.tryPromise(() => readBoundedJsonObject(request, CLOUD_TELEMETRY_MAX_BYTES)); if (!body.ok) { - recordUploadOutcome({ submitted: 0, accepted: 0, rejected: 0, coerced: 0, refusal: body.error }); - return response(body.error === "request_too_large" ? 413 : 400, body.error); + return refuse(body.error === "request_too_large" ? 413 : 400, body.error, { submitted: 0, accepted: 0, rejected: 0, coerced: 0, refusal: body.error }, labels, dependencies.now()); } const parsed = parseCloudTelemetryUpload(body.value, dependencies.now()); if (!parsed.ok) { - recordUploadOutcome({ submitted: parsed.submitted, accepted: 0, rejected: parsed.submitted, coerced: 0, refusal: parsed.reason }); - return response(400, "invalid_diagnostics"); + return refuse(400, "invalid_diagnostics", { submitted: parsed.submitted, accepted: 0, rejected: parsed.submitted, coerced: 0, refusal: parsed.reason }, labels, dependencies.now()); } const { upload } = parsed; - yield* store(user.id, upload.batch, dependencies); - recordUploadOutcome(uploadCounts(upload)); + if (upload.batch.client.instanceId) labels["cmux.client.instance_id"] = upload.batch.client.instanceId; + yield* store(user.id, upload.batch, dependencies).pipe( + Effect.tapError((error) => Effect.sync(() => { + const stored = error instanceof CloudTelemetryLimitError ? error.storedSpans : 0; + // Evidence kept from a refused batch is durable now; deliver it without waiting for the cron. + if (stored > 0) scheduleDrainSafely(dependencies); + // Budget, conflict and storage refusals follow authentication and client backoff, so each is recorded. + recordRefusal(storeRefusalStatus(error), { + ...uploadCounts(upload), accepted: 0, refusal: storeRefusal(error), ...(stored > 0 ? { stored } : {}), + }, labels); + })), + ); + recordUploadOutcome(uploadCounts(upload), labels); // Sampled-out and rejected spans are acknowledged too: the client must not retry or re-queue them. return new Response(JSON.stringify({ accepted: upload.batch.spans.length, @@ -65,14 +92,20 @@ function store(userId: string, batch: CloudTelemetryBatch, dependencies: CloudTe yield* Effect.tryPromise({ try: () => dependencies.accept(userId, { ...batch, spans: sampled.spans }, sampled.sampleWeights), catch: (error) => error, }); - // A failed scheduling call must not revoke a durable receipt. The cron drain remains responsible. - yield* Effect.sync(() => dependencies.scheduleDrain()).pipe(Effect.catchAllDefect(() => Effect.void)); + yield* Effect.sync(() => scheduleDrainSafely(dependencies)); }); } +/** A failed scheduling call must not revoke a durable receipt. The cron drain remains responsible. */ +function scheduleDrainSafely(dependencies: CloudTelemetryIngestDependencies): void { + try { dependencies.scheduleDrain(); } catch { /* the cron drain delivers it */ } +} + type UploadOutcome = { readonly submitted: number; readonly accepted: number; readonly rejected: number; readonly coerced: number; readonly refusal?: string; readonly rejectedReasons?: string; + /** Spans of a refused batch that are stored anyway (reserve evidence or earlier receipts of a retry). */ + readonly stored?: number; }; function uploadCounts(upload: CloudTelemetryUpload): UploadOutcome { @@ -83,16 +116,91 @@ function uploadCounts(upload: CloudTelemetryUpload): UploadOutcome { }; } -/** Counts on the route span, so Axiom shows partial acceptance and refusal causes. Codes only, never submitted text. */ -function recordUploadOutcome(outcome: UploadOutcome): void { - trace.getActiveSpan()?.setAttributes({ +function outcomeAttributes(outcome: UploadOutcome, labels: Readonly>): Attributes { + return { + ...labels, "cmux.telemetry.submitted_spans": outcome.submitted, "cmux.telemetry.accepted_spans": outcome.accepted, "cmux.telemetry.rejected_spans": outcome.rejected, "cmux.telemetry.coerced_spans": outcome.coerced, ...(outcome.refusal ? { "cmux.telemetry.refusal": outcome.refusal } : {}), ...(outcome.rejectedReasons ? { "cmux.telemetry.rejected_reasons": outcome.rejectedReasons } : {}), - }); + ...(outcome.stored ? { "cmux.telemetry.stored_spans": outcome.stored } : {}), + }; +} + +/** Counts on the route span, so Axiom shows partial acceptance and refusal causes. Codes only, never submitted text. */ +function recordUploadOutcome(outcome: UploadOutcome, labels: Readonly>): void { + trace.getActiveSpan()?.setAttributes(outcomeAttributes(outcome, labels)); +} + +/** + * The route span is head-sampled with the app-wide 2% ratio, so a refusal also + * leaves its own always-kept span: refused uploads are countable in Axiom by + * cause, firewall rule, account and install. `cmux.telemetry.refusal_count` is + * the number of refusals the span stands for. + */ +function recordRefusal(status: number, outcome: UploadOutcome, labels: Readonly>, refusalCount = 1): void { + recordUploadOutcome(outcome, labels); + trace.getTracer("cmux.cloud-diagnostics").startSpan(CLOUD_TELEMETRY_REFUSAL_SPAN, { + root: true, kind: SpanKind.INTERNAL, + attributes: { + ...outcomeAttributes(outcome, labels), "cmux.priority": true, "http.response.status_code": status, + "cmux.telemetry.refusal_count": refusalCount, + }, + }).end(); +} + +/** + * Refusals a caller can repeat without backing off (a firewall refusal before + * authentication, a malformed body) are coalesced per cause, rule and account: + * at most one kept span per key per window on each server instance, carrying the + * count. A flood the firewall stops therefore cannot become a flood of kept spans. + * Refusals still pending when an instance stops are not reported. + */ +const REFUSAL_COALESCE_WINDOW_MS = 10_000; +const REFUSAL_COALESCE_MAX_KEYS = 1_000; +const pendingRefusals = new Map(); + +function refuse(status: number, error: string, outcome: UploadOutcome, labels: Readonly>, nowMs: number): Response { + const key = [outcome.refusal, labels["cmux.telemetry.rate_limit_rule"], labels["cmux.account_key"]].join("\u0000"); + const pending = pendingRefusals.get(key); + if (pending && nowMs - pending.emittedAtMs < REFUSAL_COALESCE_WINDOW_MS) { + pendingRefusals.set(key, { ...pending, suppressed: pending.suppressed + 1 }); + recordUploadOutcome(outcome, labels); + } else { + if (!pending && pendingRefusals.size >= REFUSAL_COALESCE_MAX_KEYS) pendingRefusals.clear(); + pendingRefusals.set(key, { emittedAtMs: nowMs, suppressed: 0 }); + recordRefusal(status, outcome, labels, 1 + (pending?.suppressed ?? 0)); + } + return response(status, error); +} + +/** The always-sampled span name for a refused upload. */ +export const CLOUD_TELEMETRY_REFUSAL_SPAN = "cmux.cloud_diagnostics.refusal"; + + +function storeRefusalStatus(error: unknown): number { + if (error instanceof CloudTelemetryLimitError) return 429; + if (error instanceof CloudTelemetryConflictError) return 409; + return 503; +} + +/** The refusal code of a storage error, matching the status `errorResponse` answers. */ +function storeRefusal(error: unknown): string { + if (error instanceof CloudTelemetryLimitError) return "account_byte_budget"; + if (error instanceof CloudTelemetryConflictError) return "event_conflict"; + return "storage_unavailable"; +} + +/** + * The same keyed account hash as exported client diagnostics, so Axiom can join + * upload outcomes to the accounts that sent them. Without the key the account + * is left out, never sent raw. + */ +function cloudTelemetryAccountKeyFromEnvironment(userId: string): string | undefined { + const key = process.env.CMUX_CLOUD_TELEMETRY_ID_KEY?.trim(); + return key && key.length >= 32 ? cloudTelemetryAccountKey(key, userId) : undefined; } function errorResponse(error: unknown): Response { diff --git a/web/services/observability/cloudTelemetryRepository.ts b/web/services/observability/cloudTelemetryRepository.ts index 03d5834fd314..cb02ec011c2a 100644 --- a/web/services/observability/cloudTelemetryRepository.ts +++ b/web/services/observability/cloudTelemetryRepository.ts @@ -16,6 +16,21 @@ export type StoredCloudDiagnostic = { readonly attempts: number; }; +/** An account's routine diagnostics budget per minute: storage bytes of newly accepted spans. */ +export const CLOUD_DIAGNOSTICS_BYTES_PER_MINUTE = 262_144; +/** + * Failure, timeout, cancellation and sign-in records may use this reserve above + * the routine budget. When routine success detail has filled the minute, a batch + * still stores its evidence before it answers 429, so the evidence is durable on + * the first attempt and the client's retry deduplicates it. The ceiling is + * budget plus reserve. + */ +export const CLOUD_DIAGNOSTICS_PRIORITY_RESERVE_BYTES = 131_072; + +function isPriorityCloudDiagnostic(span: CloudTelemetrySpan): boolean { + return span.outcome !== "success" || span.auth !== undefined; +} + /** Account quota and deduplication are transactional across all server instances. */ export async function acceptCloudTelemetry( userId: string, batch: CloudTelemetryBatch, @@ -36,9 +51,13 @@ export async function acceptCloudTelemetry( // The sample weight is ingest policy, not submitted evidence, so it stays outside the hash. const sampleWeight = sampleWeights?.get(span.eventId); const stored = { ...payload, backend, ...(sampleWeight && sampleWeight !== 1 ? { sampleWeight } : {}) }; - return { id: span.eventId, payload: canonicalJSON(stored), hash: createHash("sha256").update(encoded).digest("hex") }; + return { + id: span.eventId, payload: canonicalJSON(stored), hash: createHash("sha256").update(encoded).digest("hex"), + priority: isPriorityCloudDiagnostic(span), + }; }); - return cloudDb().transaction(async (tx) => { + /** Pending spans left unstored by the budget; zero accepts the whole batch. */ + const refused = await cloudDb().transaction(async (tx) => { await tx.execute(sql`set local statement_timeout = '3000ms'`); // One account lock also makes duplicate-content checks race-free. await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`cloud-diagnostics:${userId}`}, 0))`); @@ -52,21 +71,33 @@ export async function acceptCloudTelemetry( if (previous && previous !== row.hash) throw new CloudTelemetryConflictError(); } const pending = rows.filter((row) => !existing.has(row.id)); - if (pending.length === 0) return rows.length; - const bytes = pending.reduce((sum, row) => sum + Buffer.byteLength(row.payload), 0); + if (pending.length === 0) return 0; const minute = Math.floor(Date.now() / 60_000); - const quota = await tx.execute(sql` - insert into cloud_diagnostic_budgets (user_id, minute, bytes) values (${userId}, ${minute}, ${bytes}) - on conflict (user_id, minute) do update set bytes = cloud_diagnostic_budgets.bytes + excluded.bytes - where cloud_diagnostic_budgets.bytes + excluded.bytes <= 262144 returning bytes - `); - if (!quota[0]) throw new CloudTelemetryLimitError(); - await tx.execute(sql` - insert into cloud_diagnostic_events (user_id, event_id, payload, payload_hash) - values ${sql.join(pending.map((row) => sql`(${userId}, ${row.id}::uuid, ${row.payload}::jsonb, ${row.hash})`), sql`, `)} - `); - return rows.length; + const charge = async (charged: typeof pending, limit: number) => { + const bytes = charged.reduce((sum, row) => sum + Buffer.byteLength(row.payload), 0); + const quota = await tx.execute(sql` + insert into cloud_diagnostic_budgets (user_id, minute, bytes) values (${userId}, ${minute}, ${bytes}) + on conflict (user_id, minute) do update set bytes = cloud_diagnostic_budgets.bytes + excluded.bytes + where cloud_diagnostic_budgets.bytes + excluded.bytes <= ${limit} returning bytes + `); + if (!quota[0]) return false; + await tx.execute(sql` + insert into cloud_diagnostic_events (user_id, event_id, payload, payload_hash) + values ${sql.join(charged.map((row) => sql`(${userId}, ${row.id}::uuid, ${row.payload}::jsonb, ${row.hash})`), sql`, `)} + `); + return true; + }; + if (await charge(pending, CLOUD_DIAGNOSTICS_BYTES_PER_MINUTE)) return 0; + // Over the routine budget: keep the batch's evidence within the reserve and refuse the rest. + // A retry deduplicates the stored evidence, so only its routine spans are charged again. + const priority = pending.filter((row) => row.priority); + if (priority.length === 0) return pending.length; + const kept = await charge(priority, CLOUD_DIAGNOSTICS_BYTES_PER_MINUTE + CLOUD_DIAGNOSTICS_PRIORITY_RESERVE_BYTES); + return kept ? pending.length - priority.length : pending.length; }); + // Thrown after the commit, so evidence kept from a refused batch stays stored. + if (refused > 0) throw new CloudTelemetryLimitError(rows.length - refused); + return rows.length; } export async function claimCloudDiagnostics(limit = 100, onlyOwner?: string): Promise<{ leaseId: string; rows: StoredCloudDiagnostic[] }> { diff --git a/web/services/observability/cloudTelemetrySampling.ts b/web/services/observability/cloudTelemetrySampling.ts index 3677b7e02f46..fe1ba6e91e03 100644 --- a/web/services/observability/cloudTelemetrySampling.ts +++ b/web/services/observability/cloudTelemetrySampling.ts @@ -13,7 +13,8 @@ const POLLED_OPERATIONS: ReadonlySet = new Set( * sampling convention. The trace predicate matches the Mac uploader, which * already keeps successful poll detail spans only when the last four hex digits * of the trace ID are divisible by 50. Using the same predicate keeps a sampled - * trace whole and drops an unsampled trace whole, including for old clients. + * trace whole and drops an unsampled trace whole, including for old clients; + * the one exception is a span that reports client queue drops (see below). */ export const CLOUD_POLL_SAMPLE_WEIGHT = Math.round(1 / DEFAULT_BASE_SAMPLE_RATIO); @@ -28,7 +29,9 @@ export type SampledCloudTelemetryBatch = { * Keep every non-success span, every non-poll span, and every span of a trace * that has a non-success span in this batch. Successful poll spans of other * traces are kept with weight `CLOUD_POLL_SAMPLE_WEIGHT` when their trace is - * sampled, and otherwise dropped. Development builds keep everything. + * sampled; otherwise only one that reports client queue drops is kept, unweighted, + * because it is the only record of that loss. Development builds keep everything. + * `droppedCount` is a raw count: never multiply it by the sample weight. * * Contract: the decision holds no state across batches. Whether a trace is kept is a * deterministic hash of its trace ID, shared with the Mac uploader, and every @@ -42,9 +45,12 @@ export function sampleCloudTelemetryBatch(batch: CloudTelemetryBatch): SampledCl const eventfulTraces = new Set(batch.spans.filter((span) => span.outcome !== "success").map((span) => span.traceId)); const spans = batch.spans.filter((span) => { if (span.outcome !== "success" || !POLLED_OPERATIONS.has(span.operation) || eventfulTraces.has(span.traceId)) return true; - if (!isSampledCloudPollTrace(span.traceId)) return false; - sampleWeights.set(span.eventId, CLOUD_POLL_SAMPLE_WEIGHT); - return true; + if (isSampledCloudPollTrace(span.traceId)) { + sampleWeights.set(span.eventId, CLOUD_POLL_SAMPLE_WEIGHT); + return true; + } + // The only record of a client-side queue drop rides on the next span; keep it, unweighted. + return (span.droppedCount ?? 0) > 0; }); return { spans, sampleWeights }; } diff --git a/web/tests/cloud-diagnostics-db-behavior.test.ts b/web/tests/cloud-diagnostics-db-behavior.test.ts index eee55d0edb2d..4a5c03e913df 100644 --- a/web/tests/cloud-diagnostics-db-behavior.test.ts +++ b/web/tests/cloud-diagnostics-db-behavior.test.ts @@ -1,11 +1,15 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { randomBytes, randomUUID } from "node:crypto"; +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { createHmac, randomBytes, randomUUID } from "node:crypto"; +import { context as otelContext, trace, type Attributes } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { sql } from "drizzle-orm"; import { cloudDb } from "../db/client"; import { acceptCloudTelemetry, claimCloudDiagnostics, cloudDiagnosticsFinishStatement, expireCloudDiagnostics, finishCloudDiagnostics } from "../services/observability/cloudTelemetryRepository"; -import { CloudTelemetryConflictError } from "../services/observability/cloudTelemetryIngest"; +import { CLOUD_TELEMETRY_REFUSAL_SPAN, CloudTelemetryConflictError, makeCloudTelemetryHandler } from "../services/observability/cloudTelemetryIngest"; +import { buildCmuxTraceSampler } from "../services/observability/sampler"; import { CloudOperationProgress, readCloudOperationProgress } from "../services/observability/cloudOperationProgress"; -import type { CloudTelemetryBatch } from "../services/observability/cloudTelemetryContract"; +import type { CloudTelemetryBatch, CloudTelemetrySpan } from "../services/observability/cloudTelemetryContract"; import { parseCloudTelemetryBatch } from "../services/observability/cloudTelemetryContract"; const enabled = process.env.CMUX_DB_TEST === "1"; @@ -160,3 +164,155 @@ describe("Cloud diagnostic durable storage", () => { expect(steps.every((step) => step.outcome === "success" && step.endedAtMs !== null)).toBe(true); }); }); + +// The ingest route as deployed: the real handler, durable acceptance in Postgres, +// and the production trace sampler with its head-sampling ratio at zero, so only +// spans that are always kept reach the exporter. +const exporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ + sampler: buildCmuxTraceSampler({ CMUX_OTEL_BASE_SAMPLE_RATIO: "0" }), + spanProcessors: [new SimpleSpanProcessor(exporter)], +}); +trace.disable(); +trace.setGlobalTracerProvider(provider); +otelContext.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +afterAll(async () => { await provider.shutdown(); trace.disable(); otelContext.disable(); }); + +const identityKey = "cloud-diagnostics-test-identity-key-0123456789"; +function telemetrySpan(overrides: Partial = {}): CloudTelemetrySpan { + const now = Date.now(); + return { + eventId: randomUUID(), operationId: randomUUID(), traceId: randomBytes(16).toString("hex"), + spanId: randomBytes(8).toString("hex"), operation: "exec", phase: "request", outcome: "success", + startedAtMs: now - 100, endedAtMs: now, attempt: 1, ...overrides, + }; +} +const routine = (count: number) => Array.from({ length: count }, () => telemetrySpan()); +const failures = (count: number) => Array.from({ length: count }, () => telemetrySpan({ outcome: "failure", failure: "network" })); +const instanceId = randomUUID(); +async function upload(owner: string, spans: CloudTelemetrySpan[], ingress: boolean | { rateLimitRule: string } = true, nowMs = Date.now()) { + let drains = 0; + const handler = makeCloudTelemetryHandler({ + authenticate: async () => ({ id: owner }), + checkIngress: async () => ingress, + accept: (userId, batch, sampleWeights) => acceptCloudTelemetry(userId, batch, { sampleWeights }), + scheduleDrain: () => { drains += 1; }, + now: () => nowMs, + }); + const body = { version: 1, client: { channel: "production", version: "0.65.0", build: "110", revision: "abcdef123", osVersion: "26.0", architecture: "arm64", instanceId }, spans }; + exporter.reset(); + const response = await trace.getTracer("cloud-diagnostics-test").startActiveSpan("POST /api/observability/cloud", async (span) => { + try { + return await handler(new Request("https://cmux.test/api/observability/cloud", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + })); + } finally { span.end(); } + }); + const exported = exporter.getFinishedSpans(); + return { + status: response.status, drains, + refusals: exported.filter((finished) => finished.name === CLOUD_TELEMETRY_REFUSAL_SPAN).map((finished) => finished.attributes as Attributes), + routeSpanExported: exported.some((finished) => finished.name === "POST /api/observability/cloud"), + }; +} +async function uploadUntilRefused(owner: string, batch: () => CloudTelemetrySpan[]) { + let result = await upload(owner, batch()); + for (let attempt = 0; result.status === 202 && attempt < 40; attempt += 1) result = await upload(owner, batch()); + return result; +} +async function storedEventIds(owner: string): Promise> { + const rows = await cloudDb().execute(sql`select event_id::text from cloud_diagnostic_events where user_id = ${owner}`); + return new Set(rows.map((row) => String(row.event_id))); +} + +describe("Cloud diagnostic ingest refusals", () => { + dbTest("routine detail that fills an account's minute cannot refuse its failures, and every refusal is counted", async () => { + const owner = fixture().owner; + const previousKey = process.env.CMUX_CLOUD_TELEMETRY_ID_KEY; + process.env.CMUX_CLOUD_TELEMETRY_ID_KEY = identityKey; + try { + // Successful command spans are not sampled, so 40 per upload fill the routine budget in a few uploads. + const full = await uploadUntilRefused(owner, () => routine(40)); + expect(full.status).toBe(429); + // The route span is head-sampled away; the refusal is still exported, once. + expect(full.routeSpanExported).toBe(false); + expect(full.refusals).toEqual([expect.objectContaining({ + "cmux.telemetry.refusal": "account_byte_budget", "http.response.status_code": 429, "cmux.priority": true, + "cmux.telemetry.submitted_spans": 40, "cmux.telemetry.accepted_spans": 0, + "cmux.account_key": createHmac("sha256", identityKey).update(owner).digest("hex"), + "cmux.client.instance_id": instanceId, + })]); + + // Evidence that shares a batch with routine detail is stored on the first attempt; the batch still answers 429. + let failure = telemetrySpan(); + let signIn = telemetrySpan(); + let mixed: CloudTelemetrySpan[] = []; + let refusedMixed = full; + for (let attempt = 0; attempt < 2; attempt += 1) { + // A minute boundary resets the budget; fill it again before the evidence arrives. + if (attempt > 0) await uploadUntilRefused(owner, () => routine(40)); + failure = telemetrySpan({ outcome: "failure", failure: "network" }); + signIn = telemetrySpan({ operation: "session", phase: "authentication", outcome: "failure", failure: "rate_limit", httpStatus: 429 }); + mixed = [...routine(38), failure, signIn]; + refusedMixed = await upload(owner, mixed); + if (refusedMixed.status === 429) break; + } + expect(refusedMixed.status).toBe(429); + expect(refusedMixed.refusals[0]).toMatchObject({ + "cmux.telemetry.refusal": "account_byte_budget", "cmux.telemetry.accepted_spans": 0, "cmux.telemetry.stored_spans": 2, + }); + // The kept evidence is delivered now, not at the next cron drain. + expect(refusedMixed.drains).toBe(1); + let stored = await storedEventIds(owner); + expect([stored.has(failure.eventId), stored.has(signIn.eventId), stored.has(mixed[0]!.eventId)]).toEqual([true, true, false]); + // The client's retry of the same batch is deduplicated, never a conflict. + expect((await upload(owner, mixed)).status).not.toBe(409); + + // A batch of only evidence is accepted in full. + const evidence = failures(2); + expect((await upload(owner, evidence)).status).toBe(202); + stored = await storedEventIds(owner); + expect(evidence.every((span) => stored.has(span.eventId))).toBe(true); + + // The reserve is bounded: an account that sends only failures is still refused. + expect((await uploadUntilRefused(owner, () => failures(40))).status).toBe(429); + } finally { + if (previousKey === undefined) delete process.env.CMUX_CLOUD_TELEMETRY_ID_KEY; + else process.env.CMUX_CLOUD_TELEMETRY_ID_KEY = previousKey; + } + }); + + dbTest("firewall refusals answer 429 before authentication, store nothing, and a flood leaves one counted span per window", async () => { + const owner = fixture().owner; + const rule = { rateLimitRule: `cmux-analytics-${randomUUID()}` }; + const start = Date.now(); + const refused = await upload(owner, failures(1), rule, start); + expect(refused.status).toBe(429); + expect(refused.refusals).toEqual([expect.objectContaining({ + "cmux.telemetry.refusal": "ingress_rate_limited", "cmux.telemetry.rate_limit_rule": rule.rateLimitRule, + "http.response.status_code": 429, "cmux.telemetry.refusal_count": 1, + })]); + expect(refused.refusals[0]!["cmux.account_key"]).toBeUndefined(); + // Within the window the refusals are still answered but only counted. + for (let index = 0; index < 5; index += 1) { + const flooded = await upload(owner, failures(1), rule, start + 1_000); + expect([flooded.status, flooded.refusals.length]).toEqual([429, 0]); + } + const next = await upload(owner, failures(1), rule, start + 10_000); + expect(next.refusals).toEqual([expect.objectContaining({ "cmux.telemetry.refusal_count": 6 })]); + expect((await storedEventIds(owner)).size).toBe(0); + }); + + dbTest("a sampled-out routine poll that reports client queue drops is stored with its count", async () => { + const owner = fixture().owner; + // A trace ID whose last four hex digits are not divisible by 50 is outside the 2% poll sample. + const reported = telemetrySpan({ operation: "list", traceId: `${randomBytes(14).toString("hex")}0001`, droppedCount: 7 }); + const silent = telemetrySpan({ operation: "list", traceId: `${randomBytes(14).toString("hex")}0001` }); + const accepted = await upload(owner, [reported, silent]); + expect(accepted.status).toBe(202); + expect(accepted.refusals).toEqual([]); + const claimed = await claimCloudDiagnostics(100, owner); + expect(claimed.rows.map((row) => [row.eventId, row.payload.span.droppedCount, row.payload.sampleWeight])).toEqual([[reported.eventId, 7, undefined]]); + await finishCloudDiagnostics(claimed, true); + }); +}); From 0ad2d1c9ec02cc5e186203e5a62f118a3d7aa3e3 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 22:28:35 -0700 Subject: [PATCH 008/183] fix(cloud): recover wedged cmux-tui links safely (#20376) * test(cloud): reject stale terminal projection * fix(cloud): recover wedged workspace links Drop timed-out local links, avoid reprojecting stale terminal inventory, and restart a live but unresponsive guest daemon after bounded health failures. Preserve the remote registry and terminal hosts for the next connection. * cmux-tui: test that registry reopen cost ignores terminal output history Reopening a registry reads every page of the file today: an unscoped PRAGMA quick_check and an unconditional journal_event_index backfill. A production machine with 3.6M terminal.output records took ~3 minutes of single-threaded CPU to start. This test measures reopen page-cache misses after 16 and 16Ki output records and fails on main. Co-Authored-By: Claude Opus 5.5 (1M context) * cmux-tui: bound daemon startup by recent work, not journal size A production registry held 3.6M terminal.output records (12 GB) and the daemon took ~3 minutes of single-threaded CPU to start. Three open-time steps read the whole journal: - create_session_journal_schema re-inserted every journal row into journal_event_index on every open (twice). Appends write the index in the same transaction, so a full backfill runs only when the index table is new; an existing index repairs only rows past its highest sequence. - PRAGMA quick_check read every page of the file. The check now runs per registry state table and its indexes, skipping the journal history tables (session_journal, its subject and event indexes, segments, checkpoints, content blobs), whose records are validated on read. - Agent-roster replay decoded and hashed every terminal.output chunk since the roster cursor, at startup and under the registry lock on the next agent event. The roster ignores terminal output (terminal-runtime producer, fixed format), so replay reads only the other records through two (kind, sequence) index ranges and then stands on the target sequence. Reducer semantics are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(cloud): recover wedged cmux-tui links safely * fix(cloud): format journal archive fast path * fix(cloud): use imported journal kind set * test(cloud): expose incomplete canary rollback * fix cloud workspace recovery review findings * test(cloud): exercise stale restored pane recovery * style: format filtered journal query * fix(cmux-tui): skip archived terminal output during roster replay * test(cloud): expose helper failure after canary stop * fix(cloud): harden link teardown and journal replay * style(cmux-tui): apply hosted rustfmt * test(cloud): expect requestTimedOut for persistent request deadlines The persistent resource connection now reports an expired request budget as LinkError.requestTimedOut. Add the case to the isolated deadline fixture's LinkError stub and update the deadline tests to expect it. Co-Authored-By: Claude Opus 5.5 * fix cloud recovery review findings * chore(cloud): promote devbox bake with daemon health watchdog Records the verified Freestyle devbox ladder from cloud-vm-image-contract run 38514254364, baked from bc4c6b13a1d with this branch's cmux-devbox-boot health watchdog and coreutils change, and demotes the previous defaults. Co-Authored-By: Claude Opus 5.5 * chore(cloud): promote verified devbox image --------- Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../CmuxCloud/Link/CloudMachineLink.swift | 90 +++- ...CloudTuiPersistentResourceConnection.swift | 6 +- .../Operations/CloudDiagnosticFailure.swift | 2 +- .../Surfaces/CloudTuiDaemonAnswer.swift | 2 +- .../CmuxCloudTests/CloudLinkTestClock.swift | 124 +++++ ...CloudMachineLinkTimeoutRecoveryTests.swift | 223 +++++++++ Resources/Localizable.xcstrings | 25 + .../CloudTerminalPaneReservation.swift | 2 +- .../CmuxTuiSurfaceProvider+ManualMirror.swift | 26 + .../Surfaces/CmuxTuiSurfaceProviders.swift | 3 +- cmux-tui/crates/cmux-tui-core/src/mux.rs | 60 ++- .../cmux-tui-core/src/workspace_registry.rs | 57 ++- .../workspace_registry/journal_extensions.rs | 18 + .../src/workspace_registry/session_journal.rs | 440 ++++++++++++++++- .../src/workspace_registry/tests.rs | 72 +++ cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/CloudCommandDeadlineTests.swift | 4 +- ...udStaleInventoryMaterializationTests.swift | 61 +++ .../StandaloneDependencies.swift | 1 + web/scripts/build-devbox-freestyle.ts | 2 +- web/scripts/cloud-vm/cmux-tui-upgrade.sh | 139 ++++-- web/services/vms/images/devbox/Dockerfile | 1 + .../vms/images/devbox/cmux-devbox-boot | 84 +++- web/services/vms/images/manifest.json | 455 +++++++++++++++++- web/tests/vm-cmux-tui-upgrade.test.ts | 85 ++++ web/tests/vm-devbox-image.test.ts | 45 ++ 26 files changed, 1923 insertions(+), 108 deletions(-) create mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudLinkTestClock.swift create mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineLinkTimeoutRecoveryTests.swift create mode 100644 cmuxTests/CloudStaleInventoryMaterializationTests.swift create mode 100644 web/tests/vm-cmux-tui-upgrade.test.ts diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLink.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLink.swift index 8284045f7979..baded472bb08 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLink.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLink.swift @@ -2,6 +2,7 @@ import CmuxCloudTui import CmuxSurfaceCatalogModel import Foundation import CmuxCore +import Darwin /// One headless cmux-tui carrier for a remote machine, over SSH or the Cloud network. /// Native panes and control requests share its daemon-owned session and local socket. @@ -82,6 +83,7 @@ public actor CloudMachineLink { case failureMessage(String) case exited(status: Int32, output: String) case timedOut + case requestTimedOut case inputTooLarge public var errorDescription: String? { @@ -99,6 +101,8 @@ public actor CloudMachineLink { return "cmux-tui link exited with status \(status)" + (tail.isEmpty ? "" : ": \(tail)") case .timedOut: return "cmux-tui link did not report a socket within the connect timeout." + case .requestTimedOut: + return String(localized: "cloud.link.requestTimedOut", defaultValue: "The Cloud machine did not answer the request before its deadline. The connection will be checked before retrying.") } } } @@ -106,6 +110,7 @@ public actor CloudMachineLink { public nonisolated let machineID: String private let clientURL: URL private let paths: CloudTuiClientPaths + private let deadlineClock: any Clock public private(set) var state: SurfaceLinkState = .connecting public private(set) var lastError: String? @@ -163,12 +168,14 @@ public actor CloudMachineLink { machineID: String, clientURL: URL, paths: CloudTuiClientPaths, + deadlineClock: any Clock = ContinuousClock(), eventsRecoveryClock: any Clock = ContinuousClock(), eventsRecoveryPolicy: CloudMachineLinkEventsRecoveryPolicy = .standard ) { self.machineID = machineID self.clientURL = clientURL self.paths = paths + self.deadlineClock = deadlineClock self.eventsRecoveryClock = eventsRecoveryClock self.eventsRecoveryPolicy = eventsRecoveryPolicy (changes, changesContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) @@ -285,7 +292,7 @@ public actor CloudMachineLink { // stdout closed before a socket line: the client exited (an older // client rejecting a flag, a refused dial). Report that exit and its // stderr, not the deadline it never reached. - await Self.terminateAndWait(process, exit: processExit) + await Self.terminateAndWait(process, exit: processExit, clock: deadlineClock) await Self.awaitStderrDrain(stderrDrain) throw LinkError.exited(status: process.terminationStatus, output: stderrTail.joined(separator: "\n")) case .timedOut?, nil: @@ -299,7 +306,7 @@ public actor CloudMachineLink { } catch { state = .error lastError = Self.errorText(error) - await Self.terminateAndWait(process, exit: processExit) + await Self.terminateAndWait(process, exit: processExit, clock: deadlineClock) if self.process === process { self.process = nil self.processExit = nil @@ -310,7 +317,7 @@ public actor CloudMachineLink { } let connected = Connected(socketPath: socketPath, session: session) self.connected = connected - self.resourceConnection = CloudTuiPersistentResourceConnection(socketPath: socketPath) + self.resourceConnection = CloudTuiPersistentResourceConnection(socketPath: socketPath, clock: deadlineClock) state = .connected await startEventsSubscription(socketPath: socketPath, cursor: nil) changesContinuation.yield(.connected) @@ -318,6 +325,16 @@ public actor CloudMachineLink { } public func disconnect() async { + let oldResource = resourceConnection + let oldProcess = process + let oldProcessExit = processExit + let oldEventStreamID = eventStreamID + let oldReleaseHubLease = releaseHubLease + resourceConnection = nil + process = nil + processExit = nil + eventStreamID = nil + releaseHubLease = nil eventsSubscriptionID = nil eventsReaderTask?.cancel() eventsReaderTask = nil @@ -332,17 +349,12 @@ public actor CloudMachineLink { stderrGeneration = nil stderrTail.removeAll(keepingCapacity: true) changesContinuation.finish() - await cancelEventsStream() - if let process, let processExit { - await Self.terminateAndWait(process, exit: processExit) - if self.process === process { - self.process = nil - self.processExit = nil - } + if let oldEventStreamID { await oldResource?.cancelStream(oldEventStreamID) } + if let oldProcess, let oldProcessExit { + await Self.terminateAndWait(oldProcess, exit: oldProcessExit, clock: deadlineClock) } - await resourceConnection?.close() - resourceConnection = nil - await releaseHubLeaseOnce() + await oldResource?.close() + await oldReleaseHubLease?() } /// Records a cursor only after the owner has accepted the corresponding @@ -437,9 +449,29 @@ public actor CloudMachineLink { /// Commands are immutable protocol messages on one machine-owned socket. /// No child process, CLI parsing, automatic mutation replay or re-authentication. public func run(arguments: CloudTuiRequest, timeout: Duration = .seconds(30)) async throws -> Data { - try await CloudOperationContext.phase(.process) { - let channel = try await self.controlConnection() - return try await channel.request(arguments, timeout: timeout) + let channel = try await controlConnection() + do { + return try await CloudOperationContext.phase(.process) { + return try await channel.request(arguments, timeout: timeout) + } + } catch LinkError.requestTimedOut { + // A caller's deadline is not proof that the shared link is wedged. + // Probe the same channel before retiring it; terminal.wait and + // wait_exit are expected to outlive short request budgets. + guard timeout > .zero, resourceConnection === channel else { + throw LinkError.requestTimedOut + } + do { + _ = try await channel.request(CloudTuiRequest("session.ping"), timeout: .seconds(2)) + } catch { + // Never replay the original request: its remote outcome is + // ambiguous. Retire only this link so a manager can establish + // a replacement while durable workspace/terminal state stays + // on the machine. + guard resourceConnection === channel else { throw LinkError.requestTimedOut } + await disconnect() + } + throw LinkError.requestTimedOut } } @@ -458,7 +490,7 @@ public actor CloudMachineLink { throw LinkError.exited(status: 3, output: "transport closed: machine link is unavailable") } if let resourceConnection, !(await resourceConnection.isClosed) { return resourceConnection } - let channel = CloudTuiPersistentResourceConnection(socketPath: connected.socketPath) + let channel = CloudTuiPersistentResourceConnection(socketPath: connected.socketPath, clock: deadlineClock) resourceConnection = channel return channel } @@ -718,13 +750,29 @@ public actor CloudMachineLink { /// Foundation aborts if a running `Process` is released. Keep a detached exit /// waiter alive because the caller can already be cancelled when cleanup starts. + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif private nonisolated static func terminateAndWait( _ process: Process, - exit: CloudLinkFirstValue + exit: CloudLinkFirstValue, + clock: any Clock ) async { - let exitTask = Task.detached { await exit.result } - if process.isRunning { process.terminate() } - _ = await exitTask.value + // Cleanup outlives caller cancellation and retains Process until its exit signal. + let cleanup = Task.detached { + if process.isRunning { process.terminate() } + // A genuine escalation deadline; process exit owns and cancels this timer. + let escalation = Task { + do { try await clock.sleep(for: .seconds(5)) } catch { return } + if process.isRunning { _ = Darwin.kill(process.processIdentifier, SIGKILL) } + } + _ = await exit.result + escalation.cancel() + await escalation.value + } + await cleanup.value } private func releaseHubLeaseOnce() async { diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudTuiPersistentResourceConnection.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudTuiPersistentResourceConnection.swift index c04eeae36c0f..9873be518a95 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudTuiPersistentResourceConnection.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudTuiPersistentResourceConnection.swift @@ -95,7 +95,7 @@ public actor CloudTuiPersistentResourceConnection { _ request: CloudTuiRequest, timeout: Duration, clock: RequestClock ) async throws -> Data where RequestClock.Duration == Duration { try Task.checkCancellation() - guard timeout > .zero else { throw CloudMachineLink.LinkError.timedOut } + guard timeout > .zero else { throw CloudMachineLink.LinkError.requestTimedOut } try await start() try Task.checkCancellation() guard !closed else { throw Self.protocolFailure } @@ -112,7 +112,7 @@ public actor CloudTuiPersistentResourceConnection { // A genuine request deadline, owned and cancelled with its pending entry. let deadline = Task { [weak self, clock] in do { try await clock.sleep(until: expiresAt, tolerance: nil) } catch { return } - await self?.retire(id, error: CloudMachineLink.LinkError.timedOut) + await self?.retire(id, error: CloudMachineLink.LinkError.requestTimedOut) } pending[id] = Pending( continuation: continuation, request: request, deadline: deadline, @@ -268,7 +268,7 @@ public actor CloudTuiPersistentResourceConnection { } // After suspend/resume, the socket reader may run before the deadline task. guard !entry.isExpired() else { - retire(id, error: CloudMachineLink.LinkError.timedOut) + retire(id, error: CloudMachineLink.LinkError.requestTimedOut) return } pending.removeValue(forKey: id) diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudDiagnosticFailure.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudDiagnosticFailure.swift index dfe85fd8a678..790d5911332a 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudDiagnosticFailure.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudDiagnosticFailure.swift @@ -72,7 +72,7 @@ public enum CloudDiagnosticFailure: String, Codable, Sendable, Error { } if let error = error as? CloudMachineLink.LinkError { switch error { - case .timedOut: return .timeout + case .timedOut, .requestTimedOut: return .timeout case .inputTooLarge: return .resourceLimit case .clientMissing, .spawnFailed, .exited: return .process case .failureMessage: return .network diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudTuiDaemonAnswer.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudTuiDaemonAnswer.swift index 45c2633fd5ea..6d668ffb286e 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudTuiDaemonAnswer.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudTuiDaemonAnswer.swift @@ -26,7 +26,7 @@ public enum CloudTuiDaemonAnswer: Equatable, Sendable { return } switch linkError { - case .timedOut: + case .timedOut, .requestTimedOut: self = .transportFailure("link deadline") case .clientMissing, .spawnFailed, .failureMessage: self = .transportFailure(CloudMachineLink.errorText(linkError)) diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudLinkTestClock.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudLinkTestClock.swift new file mode 100644 index 000000000000..574cd02dcd7d --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudLinkTestClock.swift @@ -0,0 +1,124 @@ +import Foundation + +/// A manually-advanced virtual clock for deadline tests: time only moves when +/// the test calls ``advance(by:)``, so timeout behavior is exercised without +/// real waiting. Sleeps are cancellation-responsive (resuming with +/// `CancellationError`), matching the request and process deadline lifecycle. +// Clock requires synchronous now; its test-only mutable state is protected by the lock. +final class CloudLinkTestClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol, Sendable { + var offset: Duration + + func advanced(by duration: Duration) -> Instant { Instant(offset: offset + duration) } + func duration(to other: Instant) -> Duration { other.offset - offset } + static func < (lhs: Instant, rhs: Instant) -> Bool { lhs.offset < rhs.offset } + } + + private struct Sleeper { + let deadline: Instant + let continuation: CheckedContinuation + } + + private let lock = NSLock() + private var currentInstant = Instant(offset: .zero) + private var sleepers: [UUID: Sleeper] = [:] + /// Sleeps cancelled before their continuation was parked (the + /// `withTaskCancellationHandler` handler can run first). + private var cancelledSleeperIDs: Set = [] + private var parkWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + var now: Instant { + lock.lock() + defer { lock.unlock() } + return currentInstant + } + + var minimumResolution: Duration { .zero } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + lock.lock() + if cancelledSleeperIDs.remove(id) != nil { + lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + if deadline <= currentInstant { + lock.unlock() + continuation.resume() + return + } + sleepers[id] = Sleeper(deadline: deadline, continuation: continuation) + let satisfied = takeSatisfiedParkWaitersLocked() + lock.unlock() + for waiter in satisfied { waiter.resume() } + } + } onCancel: { + lock.lock() + let sleeper = sleepers.removeValue(forKey: id) + if sleeper == nil { cancelledSleeperIDs.insert(id) } + lock.unlock() + sleeper?.continuation.resume(throwing: CancellationError()) + } + } + + /// Suspends until at least `count` sleepers are parked, so a test advances + /// time only once the deadline under test is actually waiting (instead of + /// racing its start). + func waitUntilSleepers(count: Int = 1) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + if sleepers.count >= count { + lock.unlock() + continuation.resume() + return + } + parkWaiters.append((count, continuation)) + lock.unlock() + } + } + + /// Suspends until a sleeper is parked whose deadline is at most `window` + /// from now, so a test can target one short backoff sleep while longer + /// phase deadlines are also parked. + func waitUntilSleeper(dueWithin window: Duration) async { + while !hasSleeper(dueWithin: window) { + await waitUntilSleepers() + await Task.yield() + } + } + + private func hasSleeper(dueWithin window: Duration) -> Bool { + lock.lock() + defer { lock.unlock() } + let limit = currentInstant.advanced(by: window) + return sleepers.values.contains { $0.deadline <= limit } + } + + /// Advance virtual time, resuming every sleeper whose deadline has passed. + func advance(by duration: Duration) { + lock.lock() + currentInstant = currentInstant.advanced(by: duration) + var due: [Sleeper] = [] + for (id, sleeper) in sleepers where sleeper.deadline <= currentInstant { + sleepers[id] = nil + due.append(sleeper) + } + lock.unlock() + for sleeper in due.sorted(by: { $0.deadline < $1.deadline }) { + sleeper.continuation.resume() + } + } + + private func takeSatisfiedParkWaitersLocked() -> [CheckedContinuation] { + var satisfied: [CheckedContinuation] = [] + parkWaiters.removeAll { waiter in + guard sleepers.count >= waiter.count else { return false } + satisfied.append(waiter.continuation) + return true + } + return satisfied + } +} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineLinkTimeoutRecoveryTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineLinkTimeoutRecoveryTests.swift new file mode 100644 index 000000000000..63e7b14da258 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineLinkTimeoutRecoveryTests.swift @@ -0,0 +1,223 @@ +@testable import CmuxCloud +import CmuxCloudTui +import Darwin +import Foundation +import Testing + +@Suite("Cloud machine link timeout recovery", .timeLimit(.minutes(1))) +struct CloudMachineLinkTimeoutRecoveryTests { + @Test("a request deadline does not drop a healthy shared link") + func requestTimeoutKeepsHealthyLink() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-link-request-timeout-\(UUID().uuidString.lowercased())", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let socketPath = "/tmp/cmux-link-request-timeout-\(UUID().uuidString.lowercased()).sock" + let listener = try Self.makeListener(at: socketPath) + defer { Darwin.close(listener); unlink(socketPath) } + let client = root.appendingPathComponent("fake-cmux-tui") + try """ + #!/bin/sh + printf '%s\\n' '{"event":"connection-snapshot","local_socket":"\(socketPath)"}' + exec tail -f /dev/null + """.write(to: client, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: client.path) + let requestReceived = CloudLinkFirstValue() + let server = Task.detached { Self.serve(listener: listener, respondsToPing: true, requestReceived: requestReceived) } + let clock = CloudLinkTestClock() + let link = CloudMachineLink(machineID: "healthy-timeout-machine", clientURL: client, paths: CloudTuiClientPaths(home: root), deadlineClock: clock) + _ = try await link.connect(route: "ws://127.0.0.1:1337/v1/link", session: "cloud", carrier: true, timeout: .seconds(2)) + let request = Task { try await link.run(arguments: CloudTuiRequest("terminal.wait"), timeout: .seconds(1)) } + _ = await requestReceived.result + await clock.waitUntilSleeper(dueWithin: .seconds(1)) + clock.advance(by: .seconds(1)) + do { + _ = try await request.value + Issue.record("the intentionally short request must time out") + } catch CloudMachineLink.LinkError.requestTimedOut { + // Expected: the health probe below receives a response on the same channel. + } + #expect(await link.isConnected) + _ = try await link.run(arguments: CloudTuiRequest("session.ping"), timeout: .seconds(1)) + #expect(await link.isConnected) + await link.disconnect() + server.cancel() + await server.value + } + + @Test("a timed out control request drops the wedged link for replacement") + func timeoutDisconnectsLink() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-link-timeout-\(UUID().uuidString.lowercased())", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let socketPath = "/tmp/cmux-link-timeout-\(UUID().uuidString.lowercased()).sock" + let listener = try Self.makeListener(at: socketPath) + defer { + Darwin.close(listener) + unlink(socketPath) + } + + let client = root.appendingPathComponent("fake-cmux-tui") + try """ + #!/bin/sh + printf '%s\\n' '{"event":"connection-snapshot","local_socket":"\(socketPath)"}' + exec tail -f /dev/null + """.write(to: client, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: client.path) + + let requestReceived = CloudLinkFirstValue() + let probeReceived = CloudLinkFirstValue() + let server = Task.detached(priority: .userInitiated) { + Self.serve(listener: listener, respondsToPing: false, requestReceived: requestReceived, probeReceived: probeReceived) + } + let clock = CloudLinkTestClock() + let link = CloudMachineLink( + machineID: "timeout-machine", + clientURL: client, + paths: CloudTuiClientPaths(home: root), + deadlineClock: clock + ) + _ = try await link.connect( + route: "ws://127.0.0.1:1337/v1/link", + session: "cloud", + carrier: true, + timeout: .seconds(2) + ) + + let request = Task { try await link.run(arguments: CloudTuiRequest("session.ping"), timeout: .seconds(1)) } + _ = await requestReceived.result + await clock.waitUntilSleeper(dueWithin: .seconds(1)) + clock.advance(by: .seconds(1)) + _ = await probeReceived.result + await clock.waitUntilSleeper(dueWithin: .seconds(2)) + clock.advance(by: .seconds(2)) + do { + _ = try await request.value + Issue.record("the unresponsive control request must time out") + } catch CloudMachineLink.LinkError.requestTimedOut { + // Expected: the timeout is the health boundary for this local link. + } + #expect(await link.isConnected == false) + #expect(await link.state == .unavailable) + + server.cancel() + await server.value + } + + @Test("disconnect escalates a child that ignores TERM on the injected deadline") + func disconnectKillsWedgedChild() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-link-shutdown-\(UUID())", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let socketPath = "/tmp/cmux-link-shutdown-\(UUID()).sock" + let listener = try Self.makeListener(at: socketPath) + defer { Darwin.close(listener); unlink(socketPath) } + let client = root.appendingPathComponent("fake-cmux-tui") + let pidFile = root.appendingPathComponent("pid") + try """ + #!/bin/sh + trap '' TERM + echo $$ > '\(pidFile.path)' + printf '%s\\n' '{"event":"connection-snapshot","local_socket":"\(socketPath)"}' + exec tail -f /dev/null + """.write(to: client, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: client.path) + let server = Task.detached { Self.serve(listener: listener, respondsToPing: true) } + let clock = CloudLinkTestClock() + let link = CloudMachineLink(machineID: "wedged-child", clientURL: client, + paths: CloudTuiClientPaths(home: root), deadlineClock: clock) + _ = try await link.connect(route: "ws://127.0.0.1:1337/v1/link", session: "cloud", carrier: true, timeout: .seconds(2)) + let pid = try #require(Int32(String(contentsOf: pidFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines))) + let disconnect = Task { await link.disconnect() } + await clock.waitUntilSleeper(dueWithin: .seconds(5)) + #expect(Darwin.kill(pid, 0) == 0) + clock.advance(by: .seconds(5)) + await disconnect.value + #expect(Darwin.kill(pid, 0) != 0) + #expect(await link.isConnected == false) + server.cancel() + await server.value + } + + private static func makeListener(at path: String) throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.EIO) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8CString) + guard bytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + Darwin.close(fd) + throw POSIXError(.ENAMETOOLONG) + } + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in destination.copyBytes(from: source) } + } + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0, listen(fd, 1) == 0 else { + Darwin.close(fd) + throw POSIXError(.EADDRINUSE) + } + return fd + } + + private static func serve(listener: Int32, respondsToPing: Bool, requestReceived: CloudLinkFirstValue? = nil, probeReceived: CloudLinkFirstValue? = nil) { + let peer = accept(listener, nil, nil) + guard peer >= 0 else { return } + defer { Darwin.close(peer) } + var line = Data() + var requestCount = 0 + var byte: UInt8 = 0 + while !Task.isCancelled { + let count = withUnsafeMutableBytes(of: &byte) { Darwin.read(peer, $0.baseAddress, 1) } + guard count == 1 else { return } + if byte == 0x0A { + guard + let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], + let id = object["id"] as? String + else { + line.removeAll(keepingCapacity: true) + continue + } + if let operation = object["operation"] as? String, operation == "session.ping" || operation == "terminal.wait" { + requestCount += 1 + if requestCount == 1 { requestReceived?.resolve(true) } + if requestCount == 2 { probeReceived?.resolve(true) } + } + if (object["operation"] as? String == "session.ping" && respondsToPing) { + let response: [String: Any] = [ + "protocol": "cmux.protocol/2", "type": "response", "id": id, + "ok": true, "result": [:], + ] + if let data = try? JSONSerialization.data(withJSONObject: response) { + _ = data.withUnsafeBytes { Darwin.write(peer, $0.baseAddress, data.count) } + var newline = UInt8(0x0A) + _ = withUnsafeBytes(of: &newline) { Darwin.write(peer, $0.baseAddress, 1) } + } + } else if object["operation"] as? String == "session.events", + let params = object["params"] as? [String: Any], + let streamID = params["stream_id"] as? String + { + let response: [String: Any] = [ + "protocol": "cmux.protocol/2", "type": "response", "id": id, + "ok": true, "result": ["stream_id": streamID], + ] + if let data = try? JSONSerialization.data(withJSONObject: response) { + _ = data.withUnsafeBytes { Darwin.write(peer, $0.baseAddress, data.count) } + var newline = UInt8(0x0A) + _ = withUnsafeBytes(of: &newline) { Darwin.write(peer, $0.baseAddress, 1) } + } + } + line.removeAll(keepingCapacity: true) + } else { + line.append(byte) + } + } + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2e391b6b40c3..1b4f12cf3f4a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -652235,6 +652235,31 @@ } } }, + "cloud.link.requestTimedOut": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Cloud machine did not answer the request before its deadline. The connection will be checked before retrying." } }, + "de": { "stringUnit": { "state": "translated", "value": "Die Cloud-Maschine hat vor Ablauf der Frist nicht geantwortet. Die Verbindung wird vor dem erneuten Versuch geprüft." } }, + "fr": { "stringUnit": { "state": "translated", "value": "La machine Cloud n’a pas répondu avant la date limite. La connexion sera vérifiée avant une nouvelle tentative." } }, + "ar": { "stringUnit": { "state": "translated", "value": "لم تستجب آلة Cloud قبل الموعد النهائي للطلب. سيتم التحقق من الاتصال قبل إعادة المحاولة." } }, + "es": { "stringUnit": { "state": "translated", "value": "La máquina Cloud no respondió antes del plazo. Se comprobará la conexión antes de volver a intentarlo." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Cloud 機器未在期限前回應要求。重試前會檢查連線。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Cloud 机器未在截止时间前响应请求。重试前将检查连接。" } }, + "ko": { "stringUnit": { "state": "translated", "value": "Cloud 머신이 기한 내에 요청에 응답하지 않았습니다. 다시 시도하기 전에 연결을 확인합니다." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Cloud マシンは期限内に応答しませんでした。再試行する前に接続を確認します。" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Cloud mašina nije odgovorila prije isteka roka. Veza će biti provjerena prije ponovnog pokušaja." } }, + "da": { "stringUnit": { "state": "translated", "value": "Cloud-maskinen svarede ikke inden tidsfristen. Forbindelsen kontrolleres, før der prøves igen." } }, + "it": { "stringUnit": { "state": "translated", "value": "La macchina Cloud non ha risposto entro il termine. La connessione verrà controllata prima di riprovare." } }, + "km": { "stringUnit": { "state": "translated", "value": "ម៉ាស៊ីន Cloud មិនបានឆ្លើយតបមុនពេលកំណត់ទេ។ ការតភ្ជាប់នឹងត្រូវបានពិនិត្យមុនពេលព្យាយាមម្តងទៀត។" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Cloud-maskinen svarte ikke innen tidsfristen. Tilkoblingen blir kontrollert før nytt forsøk." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Maszyna Cloud nie odpowiedziała przed upływem limitu czasu. Połączenie zostanie sprawdzone przed ponowną próbą." } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "A máquina Cloud não respondeu antes do prazo. A conexão será verificada antes de tentar novamente." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Облачная машина не ответила до истечения срока. Перед повторной попыткой будет проверено подключение." } }, + "th": { "stringUnit": { "state": "translated", "value": "เครื่อง Cloud ไม่ตอบสนองก่อนถึงกำหนด จะตรวจสอบการเชื่อมต่อก่อนลองอีกครั้ง" } }, + "tr": { "stringUnit": { "state": "translated", "value": "Cloud makinesi süre dolmadan isteğe yanıt vermedi. Yeniden denemeden önce bağlantı kontrol edilecek." } }, + "uk": { "stringUnit": { "state": "translated", "value": "Хмарна машина не відповіла до завершення терміну. Перед повторною спробою з’єднання буде перевірено." } } + } + }, "cloud.welcome.note.teamAvailable": { "extractionState": "manual", "localizations": { diff --git a/Sources/Surfaces/CloudTerminalPaneReservation.swift b/Sources/Surfaces/CloudTerminalPaneReservation.swift index 969b8bf28a67..9c9c8a9d2606 100644 --- a/Sources/Surfaces/CloudTerminalPaneReservation.swift +++ b/Sources/Surfaces/CloudTerminalPaneReservation.swift @@ -225,7 +225,7 @@ final class CloudOptimisticInputRelay: @unchecked Sendable { } catch let error as CloudMachineLink.LinkError { let requeueInput: Bool switch error { - case .clientMissing, .spawnFailed, .inputTooLarge, .timedOut, .exited, .failureMessage: + case .clientMissing, .spawnFailed, .inputTooLarge, .timedOut, .requestTimedOut, .exited, .failureMessage: requeueInput = false } if case .clientMissing = error { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index af663ced59cb..14a97f1d3055 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -7,6 +7,10 @@ import Foundation @MainActor extension CmuxTuiSurfaceProvider { + nonisolated static func shouldShowRecoverableCloudFailure(observation: CloudVMStateObservation) -> Bool { + observation.freshness != .current + } + /// Creates a native manual-I/O pane and attaches it to the remote PTY. /// /// The legacy tree lookup is only an identity bridge: public `term_…` @@ -330,6 +334,7 @@ extension CmuxTuiSurfaceProvider { reservation.retry = { [weak self] in guard let self else { return } self.restoredAttachTasks[panelID]?.cancel() + Workspace.liveWorkspace(id: reservation.workspaceID)?.restartReservedCloudTerminalPane(reservation) self.attachReservedTerminalPane(reservation, resource: resource, remoteTabID: remoteTabID) } reservation.cancel = { [weak self] in @@ -337,6 +342,18 @@ extension CmuxTuiSurfaceProvider { self.restoredAttachTasks.removeValue(forKey: panelID)?.cancel() self.materializedPanels.remove(panelID) } + if Self.shouldShowRecoverableCloudFailure( + observation: catalog.cloudStateObservations[machine] ?? .stale(reason: "missing") + ), + let workspace = Workspace.liveWorkspace(id: reservation.workspaceID) { + restoredAttachTasks[panelID] = nil + materializedPanels.remove(panelID) + workspace.failReservedCloudTerminalPane( + reservation, + error: CloudDiagnosticFailure.network + ) + return + } restoredAttachTasks[panelID] = Task { @MainActor [weak self] in var failures = 0 while !Task.isCancelled { @@ -397,6 +414,15 @@ extension CmuxTuiSurfaceProvider { SurfacePaneFactory.closeExited(panelID: panelID, in: reservation.workspaceID) return } + let inventoryIsStale = Self.shouldShowRecoverableCloudFailure( + observation: self.catalog.cloudStateObservations[self.machine] ?? .stale(reason: "missing") + ) + if inventoryIsStale { + self.restoredAttachTasks[panelID] = nil + self.materializedPanels.remove(panelID) + workspace.failReservedCloudTerminalPane(reservation, error: error) + return + } failures += 1 self.attachmentLog.giveUp( machineID: self.machineID, terminalID: resource.id.key, diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index f0f4b9388b4f..026e73f9b6be 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -1812,9 +1812,10 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { func projectionsRestored(resources: Set) { reprojectRestoredPanes(generation: lifecycleGeneration, resourceIDs: resources) } - /// Reprojects restored panes while preserving lifecycle and ownership guards. private func reprojectRestoredPanes(generation: UInt64, resourceIDs: Set? = nil) { guard isCurrentLifecycleGeneration(generation), isRegisteredInCatalog() else { return } + // Browser and port projections have their own reachability state. A + // stale terminal graph must not strand those unrelated panes. reprojectRestoredBrowserPanes(generation: generation, resourceIDs: resourceIDs) let projectionsByResource = resourceIDs.map { catalog.projections(of: $0) } let terminals = catalog.authoritativeSnapshot.resources(on: machine).filter { diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index f1489582fd2c..d9207af053af 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -1302,22 +1302,51 @@ fn restore_agent_roster(registry: &WorkspaceRegistry) -> anyhow::Result 0 { - let journal_head = registry.session_journal_head()?; + let journal_head = match registry.session_journal_head() { + Ok(head) => head, + Err(error) => { + eprintln!( + "cmux-tui: agent roster journal head unavailable; starting empty: {error}" + ); + return Ok(AgentRosterHost::default()); + } + }; if host.cursor > journal_head { host = AgentRosterHost::default(); needs_repair = true; } } let started_at = host.cursor; - loop { - let page = registry.session_journal_after(host.cursor, 512)?; - if page.records.is_empty() { - break; + // The roster ignores terminal output, so replay reads only the other + // records up to the head and then stands on the head: startup no longer + // decodes every output chunk printed since the last agent event. + let head = match registry.session_journal_head() { + Ok(head) => head, + Err(error) => { + eprintln!("cmux-tui: agent roster journal head unavailable; starting empty: {error}"); + return Ok(AgentRosterHost::default()); } - for record in &page.records { + }; + while host.cursor < head { + let (records, reached) = + match registry.session_journal_without_output_after(host.cursor, head, 512) { + Ok(page) => page, + Err(error) => { + // The roster is derived state. A malformed history page must + // not keep the daemon in a supervisor crash loop; discard the + // in-memory roster and fence its cursor at the known head. + eprintln!( + "cmux-tui: agent roster journal tail unavailable; resetting: {error}" + ); + host = AgentRosterHost { roster: AgentRoster::default(), cursor: head }; + needs_repair = true; + break; + } + }; + for record in &records { host.roster.apply(&RosterEvent::from_record(record)); - host.cursor = host.cursor.max(record.sequence); } + host.cursor = host.cursor.max(reached); } if needs_repair || host.cursor != started_at { registry.put_journal_reducer_state( @@ -6612,19 +6641,20 @@ impl Mux { } let mut deltas = Vec::new(); while host.cursor < commit.sequence { - let page = match registry.session_journal_after(host.cursor, 512) { + // Terminal output between agent events is skipped by the read: + // the roster ignores it, and this runs under the registry lock. + let (records, reached) = match registry.session_journal_without_output_after( + host.cursor, + commit.sequence, + 512, + ) { Ok(page) => page, Err(error) => { eprintln!("cmux-tui: reading agent journal tail failed: {error}"); return; } }; - if page.records.is_empty() { - break; - } - for record in - page.records.iter().take_while(|record| record.sequence <= commit.sequence) - { + for record in &records { let changes = host.roster.apply(&RosterEvent::from_record(record)); // Hooks already use the durable, session-fenced projector. // Applying their reducer delta again would bypass its @@ -6634,8 +6664,8 @@ impl Mux { { deltas.extend(changes); } - host.cursor = record.sequence; } + host.cursor = reached; } (deltas, host.cursor, host.roster.snapshot().to_string()) }; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs index 912b9fb41a27..262e455cd37e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs @@ -2715,11 +2715,7 @@ impl WorkspaceRegistry { let registry_id = required_meta(&connection, "registry_id")?; validate_identifier("registry id", ®istry_id)?; let session_id = SessionPublicId::parse(required_meta(&connection, "session_public_id")?)?; - let quick_check: String = - connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?; - if quick_check != "ok" { - anyhow::bail!("workspace registry integrity check failed: {quick_check}"); - } + check_registry_integrity(&connection)?; { let tx = connection.unchecked_transaction()?; initialize_compatibility_active_workspace(&tx)?; @@ -3960,6 +3956,57 @@ fn migrate_resource_api_frontend_projection_envelopes( Ok(()) } +/// Tables that hold journal history rather than registry state. They grow +/// with every terminal output chunk (millions of rows, gigabytes on a +/// long-lived machine) and are append-only or sealed archives whose records +/// are validated when they are read, so the open-time check skips them. +const JOURNAL_HISTORY_TABLES: [&str; 7] = [ + "session_journal", + "journal_subject_index", + "journal_event_index", + "journal_segments", + "journal_checkpoints", + "journal_content_blobs", + "journal_segment_kinds", +]; + +/// Structural check of the registry state on open. An unscoped quick_check +/// reads every page of the file, so its cost tracked total journal history; +/// checking each state table and its indexes keeps startup proportional to +/// the registry, not to how much output its terminals ever printed. +fn check_registry_integrity(connection: &Connection) -> anyhow::Result<()> { + let tables = { + let mut statement = connection.prepare( + "SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' + ORDER BY name", + )?; + statement.query_map([], |row| row.get::<_, String>(0))?.collect::, _>>()? + }; + for table in tables.iter().filter(|table| !JOURNAL_HISTORY_TABLES.contains(&table.as_str())) { + let pragma = format!("PRAGMA quick_check('{}')", table.replace('\'', "''")); + let result: String = connection.query_row(&pragma, [], |row| row.get(0))?; + if result != "ok" { + anyhow::bail!("workspace registry integrity check failed: {result}"); + } + } + // History is validated incrementally when replayed. Touch only each + // table's newest indexed row during open so a corrupt tail page is caught + // without turning startup back into a full-journal scan. + for (table, order) in [ + ("session_journal", "sequence"), + ("journal_subject_index", "sequence"), + ("journal_event_index", "sequence"), + ("journal_segments", "end_sequence"), + ("journal_checkpoints", "source_sequence"), + ("journal_content_blobs", "created_at_ms"), + ] { + let query = format!("SELECT 1 FROM {table} ORDER BY {order} DESC LIMIT 1"); + let _: Option = connection.query_row(&query, [], |row| row.get(0)).optional()?; + } + Ok(()) +} + fn checkpoint_and_truncate_wal(connection: &Connection) -> anyhow::Result<()> { let (busy, _, _): (i64, i64, i64) = connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs index cc576dd36bd8..4f3e7ddc9147 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs @@ -321,6 +321,7 @@ struct PreparedJournalSegment { metadata: JournalSegment, compressed: Vec, digest: [u8; 32], + kinds: Vec, } pub(super) fn create_journal_extensions_schema( @@ -411,6 +412,12 @@ pub(super) fn create_journal_extensions_schema( sha256 BLOB UNIQUE NOT NULL CHECK(length(sha256) = 32), sealed_at_ms INTEGER NOT NULL CHECK(sealed_at_ms >= 0) ); + CREATE TABLE IF NOT EXISTS journal_segment_kinds ( + segment_id TEXT NOT NULL, + kind TEXT NOT NULL, + PRIMARY KEY(segment_id, kind), + FOREIGN KEY(segment_id) REFERENCES journal_segments(segment_id) + ) WITHOUT ROWID; CREATE TRIGGER IF NOT EXISTS journal_segments_reject_update BEFORE UPDATE ON journal_segments BEGIN @@ -2627,6 +2634,7 @@ impl WorkspaceRegistry { let segments = prepared_segments.iter().map(|segment| segment.metadata.clone()).collect::>(); for segment in prepared_segments { + let kinds = segment.kinds.clone(); tx.execute( "INSERT INTO journal_segments( segment_id, start_sequence, end_sequence, record_count, codec, content, @@ -2643,6 +2651,12 @@ impl WorkspaceRegistry { i64::try_from(segment.metadata.sealed_at_ms)?, ], )?; + for kind in kinds { + tx.execute( + "INSERT INTO journal_segment_kinds(segment_id, kind) VALUES(?1, ?2)", + params![segment.metadata.segment_id, kind], + )?; + } } tx.execute_batch("DROP TRIGGER IF EXISTS session_journal_reject_delete;")?; let deleted = tx.execute( @@ -2727,6 +2741,7 @@ impl JournalSegmentSealPlan { uncompressed.push(b'['); let mut start_sequence = None; let mut record_count = 0_u64; + let mut kinds = BTreeSet::new(); 'segment: while cursor < self.through_sequence && record_count < u64::try_from(JOURNAL_SEGMENT_RECORD_LIMIT)? { @@ -2743,6 +2758,7 @@ impl JournalSegmentSealPlan { record.sequence ); let sequence = record.sequence; + let kind = record.kind.clone(); let record = session_journal::journal_record_for_archive(&record); let record_bytes = serde_json::to_vec(&record)?; anyhow::ensure!( @@ -2760,6 +2776,7 @@ impl JournalSegmentSealPlan { { break 'segment; } + kinds.insert(kind); if separator != 0 { uncompressed.push(b','); } @@ -2800,6 +2817,7 @@ impl JournalSegmentSealPlan { }, compressed, digest, + kinds: kinds.into_iter().collect(), }); } validate_prepared_journal_segments(&self, &segments)?; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs index f2ad838cfeeb..e68e3bbe932d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs @@ -437,14 +437,15 @@ impl ResourceEffectJournalState { } pub(super) fn create_session_journal_schema(transaction: &Transaction<'_>) -> anyhow::Result<()> { - let subject_index_existed = transaction.query_row( - "SELECT EXISTS( - SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = 'journal_subject_index' - )", - [], - |row| row.get::<_, bool>(0), - )?; + let table_exists = |name: &str| { + transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [name], + |row| row.get::<_, bool>(0), + ) + }; + let subject_index_existed = table_exists("journal_subject_index")?; + let event_index_existed = table_exists("journal_event_index")?; transaction.execute_batch( "CREATE TABLE IF NOT EXISTS session_journal ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, @@ -505,8 +506,6 @@ pub(super) fn create_session_journal_schema(transaction: &Transaction<'_>) -> an ) ) ); - INSERT OR IGNORE INTO journal_event_index(event_id, sequence, causation_depth) - SELECT event_id, sequence, causation_depth FROM session_journal; CREATE TRIGGER IF NOT EXISTS session_journal_reject_update BEFORE UPDATE ON session_journal BEGIN @@ -528,6 +527,21 @@ pub(super) fn create_session_journal_schema(transaction: &Transaction<'_>) -> an SELECT RAISE(ABORT, 'journal subject index is append-only'); END;", )?; + // Every append writes its event index row in the same transaction, so an + // existing index can only lack a tail left by a build that predates it. + // Repairing from its highest sequence keeps this open-time step a seek + // instead of a pass over the whole journal. + transaction.execute( + if event_index_existed { + "INSERT OR IGNORE INTO journal_event_index(event_id, sequence, causation_depth) + SELECT event_id, sequence, causation_depth FROM session_journal + WHERE sequence > (SELECT COALESCE(MAX(sequence), 0) FROM journal_event_index)" + } else { + "INSERT OR IGNORE INTO journal_event_index(event_id, sequence, causation_depth) + SELECT event_id, sequence, causation_depth FROM session_journal" + }, + [], + )?; if !subject_index_existed { transaction.execute_batch( "INSERT OR IGNORE INTO journal_subject_index(sequence, kind, id) @@ -1132,6 +1146,22 @@ impl WorkspaceRegistry { query_session_journal_after(&self.connection, sequence, limit) } + /// Records in `(sequence, through]` other than `terminal.output`, oldest + /// first, at most `limit`, and the sequence the read covered through. + /// Terminal output is nearly the whole journal, and folding agent state + /// never reads it: its producer is the terminal runtime and its format is + /// fixed, so it can be neither an agent-hook nor an agent-plugin event. + /// Replaying such a reducer through this read costs the other records, + /// not the output history. + pub(crate) fn session_journal_without_output_after( + &self, + sequence: u64, + through: u64, + limit: usize, + ) -> anyhow::Result<(Vec, u64)> { + query_session_journal_without_output_after(&self.connection, sequence, through, limit) + } + /// Return the highest sequence in the active or archived journal without /// decoding any records. Reducer recovery uses this to reject a stale /// snapshot cursor while remaining valid when the journal has compacted @@ -1348,6 +1378,255 @@ pub(super) fn query_session_journal_after( Ok(SessionJournalPage { head_sequence, records }) } +fn query_session_journal_without_output_after( + connection: &Connection, + sequence: u64, + through: u64, + limit: usize, +) -> anyhow::Result<(Vec, u64)> { + anyhow::ensure!(limit > 0, "journal page limit must be positive"); + anyhow::ensure!( + limit <= MAX_JOURNAL_PAGE_SIZE, + "journal page limit exceeds {MAX_JOURNAL_PAGE_SIZE}" + ); + if sequence >= through { + return Ok((Vec::new(), through)); + } + let archive_end = query_archived_journal_head(connection)?; + let archive_through = through.min(archive_end); + let mut records = Vec::new(); + let mut reached = sequence; + if sequence < archive_through { + if let Some((archived, archived_reached)) = + archived_non_output_after(connection, sequence, archive_through, archive_end, limit)? + { + records = archived; + reached = archived_reached; + } else { + // Older sealed segments predate journal_segment_kinds. Decode those + // segments with the validating legacy path, then use the indexed + // active query below for any remaining tail. + let archived = archived_records_after(connection, sequence, limit)?; + let covered = archived + .into_iter() + .take_while(|record| record.sequence <= archive_through) + .collect::>(); + reached = covered.last().map_or(archive_through, |record| record.sequence); + records.extend(covered.into_iter().filter(|record| record.kind != "terminal.output")); + if records.len() >= limit || reached >= through { + records.truncate(limit); + return Ok((records, reached)); + } + } + if records.len() >= limit || reached >= through { + records.truncate(limit); + return Ok((records, reached)); + } + } + // Two ranges of the (kind, sequence) index around 'terminal.output' + // visit only the other kinds' entries, however much output there is. + let remaining = limit - records.len(); + let mut statement = connection.prepare( + "SELECT sequence, event_id, schema_version, kind, class, replay_policy, + occurred_at_ms, committed_at_ms, producer_json, authority_json, + causation_id, correlation_id, causation_depth, subjects_json, + sensitivity, payload_json, content, resource_revision, + previous_resource_revision + FROM session_journal INDEXED BY session_journal_by_kind_sequence + WHERE kind < 'terminal.output' AND sequence > ?1 AND sequence <= ?2 + UNION ALL + SELECT sequence, event_id, schema_version, kind, class, replay_policy, + occurred_at_ms, committed_at_ms, producer_json, authority_json, + causation_id, correlation_id, causation_depth, subjects_json, + sensitivity, payload_json, content, resource_revision, + previous_resource_revision + FROM session_journal INDEXED BY session_journal_by_kind_sequence + WHERE kind > 'terminal.output' AND sequence > ?1 AND sequence <= ?2 + ORDER BY sequence ASC + LIMIT ?3", + )?; + let active = statement + .query_map( + params![ + i64::try_from(sequence).context("journal sequence exceeds SQLite range")?, + i64::try_from(through).context("journal sequence exceeds SQLite range")?, + i64::try_from(remaining).context("journal page limit exceeds SQLite range")?, + ], + stored_record_row, + )? + .map(|row| decode_record(row?)) + .collect::>>()?; + reached = if active.len() < remaining { + through + } else { + active.last().map_or(reached, |record| record.sequence) + }; + records.extend(active); + Ok((records, reached)) +} + +fn query_archived_journal_head(connection: &Connection) -> anyhow::Result { + let end: i64 = connection.query_row( + "SELECT COALESCE(MAX(end_sequence), 0) FROM journal_segments", + [], + |row| row.get(0), + )?; + u64::try_from(end).context("archived journal sequence is negative") +} + +/// Uses the seal-time kind map to skip output-only compressed segments without +/// inflating them. A missing map means an older segment, so callers retain the +/// validating legacy decoder for that range. +fn archived_non_output_after( + connection: &Connection, + sequence: u64, + through: u64, + archive_end: u64, + limit: usize, +) -> anyhow::Result, u64)>> { + backfill_legacy_journal_segment_kinds(connection)?; + // This helper only owns the sealed prefix. Let the active indexed query + // handle an entirely-active range or a cursor at the archive boundary. + if sequence >= archive_end { + return Ok(None); + } + let mut statement = connection.prepare( + "SELECT segment_id, start_sequence, end_sequence + FROM journal_segments + WHERE end_sequence > ?1 AND start_sequence <= ?2 + ORDER BY start_sequence ASC", + )?; + let rows = statement + .query_map(params![i64::try_from(sequence)?, i64::try_from(through)?], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?)) + })? + .collect::, _>>()?; + if rows.is_empty() { + return Ok(None); + } + let first_start = rows.first().map(|row| row.1).unwrap_or_default(); + anyhow::ensure!( + first_start <= i64::try_from(sequence.saturating_add(1))?, + "journal segments contain a gap before sequence {first_start}" + ); + let mut previous_end = None; + for row in &rows { + if let Some(previous_end) = previous_end { + anyhow::ensure!( + row.1 == previous_end + 1, + "journal segments contain a gap before sequence {}", + row.1 + ); + } + previous_end = Some(row.2); + } + let mut reached = sequence; + let mut records = Vec::new(); + for row in rows { + let segment_id = row.0.as_str(); + let (has_kind_rows, has_non_output): (bool, bool) = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM journal_segment_kinds WHERE segment_id = ?1), + EXISTS(SELECT 1 FROM journal_segment_kinds + WHERE segment_id = ?1 AND kind != 'terminal.output')", + [segment_id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + if !has_kind_rows { + return Ok(None); + } + let end = u64::try_from(row.2)?.min(through); + if !has_non_output { + reached = reached.max(end); + continue; + } + let segment = load_journal_segment(connection, segment_id)?; + let decoded = decode_journal_segment(segment)?; + for record in decoded.records.into_iter().filter(|r| { + r.sequence > sequence && r.sequence <= through && r.kind != "terminal.output" + }) { + reached = record.sequence; + records.push(record); + if records.len() == limit { + return Ok(Some((records, reached))); + } + } + reached = reached.max(end); + } + Ok(Some((records, reached.max(through.min(reached))))) +} + +/// Populate the seal-time kind index for segments written before the index was +/// introduced. The marker makes this an open-time migration: subsequent reads +/// use only the indexed metadata and retain the validating fallback for a +/// segment that is concurrently discovered without an index row. +fn backfill_legacy_journal_segment_kinds(connection: &Connection) -> anyhow::Result<()> { + let complete: Option = connection + .query_row( + "SELECT value FROM meta WHERE key = 'journal_segment_kinds_backfill'", + [], + |row| row.get(0), + ) + .optional()?; + if complete.as_deref() == Some("1") { + return Ok(()); + } + connection.execute( + "INSERT OR IGNORE INTO meta(key, value) VALUES('journal_segment_kinds_backfill', '0')", + [], + )?; + let ids = { + let mut statement = connection.prepare( + "SELECT segment_id FROM journal_segments + WHERE NOT EXISTS ( + SELECT 1 FROM journal_segment_kinds + WHERE journal_segment_kinds.segment_id = journal_segments.segment_id + ) + ORDER BY start_sequence ASC", + )?; + statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()? + }; + for segment_id in ids { + let segment = load_journal_segment(connection, &segment_id)?; + let kinds = decode_journal_segment(segment)? + .records + .into_iter() + .map(|record| record.kind) + .collect::>(); + for kind in kinds { + connection.execute( + "INSERT OR IGNORE INTO journal_segment_kinds(segment_id, kind) VALUES(?1, ?2)", + params![segment_id, kind], + )?; + } + } + connection.execute( + "UPDATE meta SET value = '1' WHERE key = 'journal_segment_kinds_backfill'", + [], + )?; + Ok(()) +} + +fn load_journal_segment( + connection: &Connection, + segment_id: &str, +) -> anyhow::Result { + let mut statement = connection.prepare( + "SELECT segment_id, start_sequence, end_sequence, record_count, codec, + content, uncompressed_bytes, sha256 + FROM journal_segments + WHERE segment_id = ?1 + AND length(content) <= ?2", + )?; + statement + .query_row( + params![segment_id, i64::try_from(MAX_JOURNAL_SEGMENT_COMPRESSED_BYTES)?], + journal_segment_row, + ) + .with_context(|| format!("load journal segment {segment_id}")) +} + fn query_journal_head(connection: &Connection) -> anyhow::Result { let head_sequence = connection.query_row( "SELECT MAX( @@ -2323,6 +2602,147 @@ mod tests { ); } + #[test] + fn output_only_archived_segments_skip_corrupt_content() { + let registry = WorkspaceRegistry::in_memory("output-only-archive").unwrap(); + registry + .connection + .execute( + "INSERT INTO journal_segments( + segment_id, start_sequence, end_sequence, record_count, codec, + content, uncompressed_bytes, sha256, sealed_at_ms + ) VALUES('output-only-corrupt', 1, 1, 1, 'gzip-json-v1', X'00', 1, ?1, 1)", + [vec![0_u8; 32]], + ) + .unwrap(); + registry + .connection + .execute( + "INSERT INTO journal_segment_kinds(segment_id, kind) + VALUES('output-only-corrupt', 'terminal.output')", + [], + ) + .unwrap(); + + let (records, reached) = registry.session_journal_without_output_after(0, 1, 512).unwrap(); + assert!(records.is_empty()); + assert_eq!(reached, 1); + } + + #[test] + fn non_output_replay_mixes_archived_prefix_with_active_tail() { + let root = std::env::temp_dir().join(format!("cmux-mixed-archive-{}", new_uuid_v4())); + let mut registry = WorkspaceRegistry::open(&root, "mixed-archive-active").unwrap(); + let first = registry.connection.transaction().unwrap(); + first.execute("UPDATE meta SET value = '1' WHERE key = 'resource_revision'", []).unwrap(); + append_resource_journal_record( + &first, + 1, + 0, + "mixed-test", + "mixed-archive-event", + "workspace.added", + None, + &serde_json::json!({"workspace":"archive"}), + &serde_json::json!([]), + ) + .unwrap(); + first.commit().unwrap(); + let archived_through = registry.session_journal_head().unwrap(); + registry + .create_journal_checkpoint( + archived_through, + 1, + &serde_json::json!({ + "session_snapshot":{"cursor":{"revision":"1"}}, + "journal_extensions":{"producers":[],"hooks":[]}, + }), + &[], + "client_test", + "mixed_archive_checkpoint", + ) + .unwrap(); + let plan = match registry + .begin_journal_segment_seal(archived_through, "client_test", "mixed_archive_segment") + .unwrap() + { + JournalSegmentSealStart::Prepare(plan) => plan, + JournalSegmentSealStart::Replay(_) => { + panic!("first mixed segment seal unexpectedly replayed") + } + }; + let reader = SessionJournalReader::open( + ®istry.session_journal_database_path().expect("persistent registry has a path"), + ) + .unwrap(); + let prepared = plan.prepare(&reader).unwrap(); + registry + .commit_journal_segment_seal(prepared, "client_test", "mixed_archive_segment") + .unwrap() + .expect("mixed segment boundary remained stable"); + let second = registry.connection.transaction().unwrap(); + second.execute("UPDATE meta SET value = '2' WHERE key = 'resource_revision'", []).unwrap(); + append_resource_journal_record( + &second, + 2, + 1, + "mixed-test", + "mixed-active-event", + "workspace.focus", + None, + &serde_json::json!({"workspace":"active"}), + &serde_json::json!([]), + ) + .unwrap(); + second.commit().unwrap(); + let head = registry.session_journal_head().unwrap(); + + let (records, reached) = + registry.session_journal_without_output_after(0, head, 512).unwrap(); + assert_eq!(reached, head); + assert!(records.iter().any(|record| record.sequence <= archived_through)); + assert!(records.iter().any(|record| record.sequence > archived_through)); + + // A cursor at the sealed boundary must still use the active indexed + // query, even though the archive helper has no range to own. + let (tail, tail_reached) = + registry.session_journal_without_output_after(archived_through, head, 512).unwrap(); + assert!(!tail.is_empty()); + assert!(tail.iter().all(|record| record.sequence > archived_through)); + assert_eq!(tail_reached, head); + drop(reader); + drop(registry); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn archived_non_output_replay_rejects_compacted_gaps_before_kind_filtering() { + let registry = WorkspaceRegistry::in_memory("archive-gap-validation").unwrap(); + for (id, start, end) in [("gap-first", 1_i64, 1_i64), ("gap-second", 3_i64, 3_i64)] { + registry + .connection + .execute( + "INSERT INTO journal_segments( + segment_id, start_sequence, end_sequence, record_count, codec, + content, uncompressed_bytes, sha256, sealed_at_ms + ) VALUES(?1, ?2, ?3, 1, 'gzip-json-v1', X'00', 1, ?4, 1)", + params![id, start, end, vec![0_u8; 32]], + ) + .unwrap(); + registry + .connection + .execute( + "INSERT INTO journal_segment_kinds(segment_id, kind) + VALUES(?1, 'terminal.output')", + [id], + ) + .unwrap(); + } + + let error = registry.session_journal_without_output_after(0, 3, 512).unwrap_err(); + assert!(error.to_string().contains("gap"), "{error:#}"); + } + #[test] fn persistent_reader_observes_commits_on_an_independent_connection() { let root = std::env::temp_dir().join(format!("cmux-journal-reader-{}", new_uuid_v4())); diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs index 28e5f07f7d08..7c68c72b0a2a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs @@ -13,6 +13,14 @@ fn temp_root(label: &str) -> PathBuf { std::env::temp_dir().join(format!("cmux-registry-{label}-{}", new_uuid_v4())) } +struct TempRoot(PathBuf); + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + fn workspace(id: u64, key: &str, name: &str) -> RegistryWorkspace { RegistryWorkspace { id, @@ -5345,6 +5353,70 @@ fn terminal_journal_persists_exact_output_and_geometry_in_order() { } } +/// Pages the fresh connection had to read from disk (or the WAL) while the +/// registry opened: a size-independent measure of how much startup touched. +fn registry_open_cache_misses(registry: &WorkspaceRegistry) -> i32 { + let (mut current, mut highwater) = (0, 0); + // SAFETY: the handle is the live connection owned by `registry`, and + // sqlite3_db_status only reads counters into the two locals. + let status = unsafe { + rusqlite::ffi::sqlite3_db_status( + registry.connection.handle(), + rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS, + &mut current, + &mut highwater, + 0, + ) + }; + assert_eq!(status, rusqlite::ffi::SQLITE_OK); + current +} + +fn reopen_cost_after_terminal_output(label: &str, records: usize) -> i32 { + const BATCH_SIZE: usize = 1_024; + let root = TempRoot(temp_root(label)); + { + let mut registry = WorkspaceRegistry::open(&root.0, label).unwrap(); + commit_terminal_topology(&mut registry, &format!("{label}-seed")); + let terminal_id = Arc::new(terminal_resource(TERMINAL_ONE)); + let chunk = vec![b'x'; 256]; + let mut written = 0; + while written < records { + let events = (written..records.min(written + BATCH_SIZE)) + .map(|index| crate::journal_ingress::JournalIngressEvent::TerminalOutput { + terminal_id: terminal_id.clone(), + generation: "startup-scale".into(), + occurred_at_ms: u64::try_from(index).unwrap(), + bytes: chunk.clone(), + }) + .collect::>(); + let references = events.iter().collect::>(); + written += registry.append_journal_ingress_events(&references).unwrap().len(); + } + } + let registry = WorkspaceRegistry::open(&root.0, label).unwrap(); + let misses = registry_open_cache_misses(®istry); + drop(registry); + misses +} + +#[test] +fn registry_reopen_cost_does_not_scale_with_terminal_output_history() { + // Terminal output is most of every long-lived journal (3.6M of 3.6M + // records on one production machine). Reopening must read the registry + // and the journal's recent edge, never the whole output history. + let small = reopen_cost_after_terminal_output("startup-scale-small", 16); + let large = reopen_cost_after_terminal_output("startup-scale-large", 16 * 1_024); + // The two cases use separate databases, so the small run is the control + // for SQLite/schema startup variation. A history-sized increase indicates + // that open touched the output archive rather than only the bounded state + // tables. Keep this margin below one 4 KiB SQLite page per 16 records. + assert!( + large <= small + 16, + "reopen read {large} pages after 16Ki output records but {small} after 16" + ); +} + #[test] #[ignore = "manual release-mode journal writer throughput probe"] fn terminal_journal_writer_throughput_probe() { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1a9b22a5b87c..c8a71b607237 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -941,6 +941,7 @@ A257027522B91E8EB817127A /* CloudSidebarSectionIdentityIconTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B6AC3444D529B1C9EFF580 /* CloudSidebarSectionIdentityIconTests.swift */; }; 4DB42AF8827945DFADF7C4D4 /* CloudSidebarSurfaceRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E5958FB85E4B97957D4F73 /* CloudSidebarSurfaceRegressionTests.swift */; }; A1329C020000000000000101 /* CloudSizingPhoneRelaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1329C020000000000000001 /* CloudSizingPhoneRelaying.swift */; }; + E4B86B47C283B5F2B721E8E8 /* CloudStaleInventoryMaterializationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA688992ADFE3696B36EEC3D /* CloudStaleInventoryMaterializationTests.swift */; }; CACC0558F1DE4DBC920ACAC7 /* CloudSurfaceDragFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528BC06A8197486F91B4E426 /* CloudSurfaceDragFeedbackTests.swift */; }; 92556BB814BE41C3A3891685 /* CloudSurfaceDragFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = E54D698BB1584C2BAC3A61DF /* CloudSurfaceDragFixture.swift */; }; D318853B007D48AD9532F83F /* CloudSurfaceDropGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9DF6ACE0DBF4ACE823A6CE8 /* CloudSurfaceDropGate.swift */; }; @@ -5624,6 +5625,7 @@ A2B6AC3444D529B1C9EFF580 /* CloudSidebarSectionIdentityIconTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarSectionIdentityIconTests.swift"; sourceTree = ""; }; F3E5958FB85E4B97957D4F73 /* CloudSidebarSurfaceRegressionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudSidebarSurfaceRegressionTests.swift; sourceTree = ""; }; A1329C020000000000000001 /* CloudSizingPhoneRelaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSizingPhoneRelaying.swift; sourceTree = ""; }; + DA688992ADFE3696B36EEC3D /* CloudStaleInventoryMaterializationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudStaleInventoryMaterializationTests.swift"; sourceTree = ""; }; 528BC06A8197486F91B4E426 /* CloudSurfaceDragFeedbackTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudSurfaceDragFeedbackTests.swift; sourceTree = ""; }; E54D698BB1584C2BAC3A61DF /* CloudSurfaceDragFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudSurfaceDragFixture.swift; sourceTree = ""; }; D9DF6ACE0DBF4ACE823A6CE8 /* CloudSurfaceDropGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSurfaceDropGate.swift; sourceTree = ""; }; @@ -13836,6 +13838,7 @@ 6363F4EA9220ADE6FA369B16 /* CLIMuseHookInstallTests.swift */, 80B2BBAC8B90CA286F4B2BF7 /* MainWindowHitTestMutationMonitorTests.swift */, DB2534FB1BD7FDBD3A7474EE /* CloudManualMirrorHandshakeTests.swift */, + DA688992ADFE3696B36EEC3D /* CloudStaleInventoryMaterializationTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -18090,6 +18093,7 @@ A12481000000000000000001 /* CloudSidebarScaleTests.swift in Sources */, A257027522B91E8EB817127A /* CloudSidebarSectionIdentityIconTests.swift in Sources */, 4DB42AF8827945DFADF7C4D4 /* CloudSidebarSurfaceRegressionTests.swift in Sources */, + E4B86B47C283B5F2B721E8E8 /* CloudStaleInventoryMaterializationTests.swift in Sources */, CACC0558F1DE4DBC920ACAC7 /* CloudSurfaceDragFeedbackTests.swift in Sources */, 92556BB814BE41C3A3891685 /* CloudSurfaceDragFixture.swift in Sources */, 5CB4AC1D92064236B5C2DDF1 /* CloudSurfaceMoveOwnershipTests.swift in Sources */, diff --git a/cmuxTests/CloudCommandDeadlineTests.swift b/cmuxTests/CloudCommandDeadlineTests.swift index 8fb19a90f379..f7473df80a3a 100644 --- a/cmuxTests/CloudCommandDeadlineTests.swift +++ b/cmuxTests/CloudCommandDeadlineTests.swift @@ -28,7 +28,7 @@ import Testing do { _ = try await command.value Issue.record("a response delivered after resume cannot turn an expired request into success") - } catch CloudMachineLink.LinkError.timedOut {} + } catch CloudMachineLink.LinkError.requestTimedOut {} // An expired request never closes the machine-owned socket or replays work. async let sibling = channel.request(CloudTuiRequest("session.snapshot")) @@ -68,7 +68,7 @@ import Testing do { _ = try await channel.request(CloudTuiRequest("session.ping"), timeout: timeout) Issue.record("an expired request must be rejected") - } catch CloudMachineLink.LinkError.timedOut {} + } catch CloudMachineLink.LinkError.requestTimedOut {} await channel.close() } } diff --git a/cmuxTests/CloudStaleInventoryMaterializationTests.swift b/cmuxTests/CloudStaleInventoryMaterializationTests.swift new file mode 100644 index 000000000000..f4b54000aabb --- /dev/null +++ b/cmuxTests/CloudStaleInventoryMaterializationTests.swift @@ -0,0 +1,61 @@ +import CmuxCloud +import CmuxSurfaceCatalogModel +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Cloud stale inventory admission", .serialized, .exclusiveAppContext) +struct CloudStaleInventoryMaterializationTests { + @Test("a stale graph never materializes an interactive restored terminal") + func staleGraphIsNotInteractive() { + #expect(CmuxTuiSurfaceProvider.shouldShowRecoverableCloudFailure(observation: .stale(reason: "link timeout"))) + #expect(!CmuxTuiSurfaceProvider.shouldShowRecoverableCloudFailure(observation: .current)) + } + + @Test("missing and stale observations fail the actual attachment and fresh state retries it", arguments: [false, true]) + func restoredAttachmentRecovers(hasStaleObservation: Bool) throws { + _ = try #require(AppDelegate.shared) + let manager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) + let workspace = Workspace() + manager.tabs = [workspace] + manager.selectedTabId = workspace.id + defer { workspace.teardownAllPanels(); manager.tabs = [] } + try LiveWorkspaceFixture.withAppRegistration(of: manager) { + let panelID = try #require(workspace.focusedPanelId) + let catalog = SurfaceCatalog() + let summary = VMSummary(id: "stale-\(UUID())", provider: "freestyle", status: "running", image: "fixture", createdAt: 0, base: nil) + let provider = CmuxTuiSurfaceProvider(summary: summary, + links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }), catalog: catalog) + catalog.register(provider) + defer { provider.suspendForFeatureFlag(); catalog.unregister(machine: provider.machine) } + let resource = SurfaceResource(id: .init(machine: provider.machine, kind: .terminal, key: "term_fixture"), title: "Terminal", lifecycle: .running) + let state = CloudVMState(machine: provider.machine, cursor: nil, rawSnapshot: Data("{}".utf8), + workspaces: [], screens: [], panes: [], tabs: [], terminals: [], browsers: [], agents: []) + if hasStaleObservation { + catalog.replaceCloudState(state, resources: [resource], info: provider.info, observation: .stale(reason: "link timeout")) + } + let reservation = CloudTerminalPaneReservation(workspaceID: workspace.id, panelID: panelID, machine: provider.machine) + workspace.cloudPendingCreations[panelID] = reservation + defer { reservation.cancel?(); workspace.cloudPendingCreations[panelID] = nil } + provider.attachReservedTerminalPane(reservation, resource: resource, remoteTabID: nil) + #expect(workspace.cloudMaterializationFailures[panelID] != nil) + #expect(!provider.materializedPanels.contains(panelID)) + #expect(provider.restoredAttachTasks[panelID] == nil) + #expect(workspace.retryReservedCloudTerminalPane(surfaceId: panelID)) + #expect(!provider.materializedPanels.contains(panelID)) + + catalog.record(SurfaceProjection(resource: resource.id, workspaceID: workspace.id, panelID: panelID)) + catalog.replaceCloudState(state, resources: [resource], info: provider.info, observation: .current) + provider.projectionsRestored() + #expect(provider.materializedPanels.contains(panelID)) + #expect(provider.restoredAttachTasks[panelID] != nil) + } + } + +} diff --git a/tests/fixtures/cloud-command-deadlines/StandaloneDependencies.swift b/tests/fixtures/cloud-command-deadlines/StandaloneDependencies.swift index 0e4ac9dce922..5b3cae6fbe92 100644 --- a/tests/fixtures/cloud-command-deadlines/StandaloneDependencies.swift +++ b/tests/fixtures/cloud-command-deadlines/StandaloneDependencies.swift @@ -5,6 +5,7 @@ import Foundation enum CloudMachineLink { enum LinkError: Error { case timedOut + case requestTimedOut case inputTooLarge case exited(status: Int32, output: String) } diff --git a/web/scripts/build-devbox-freestyle.ts b/web/scripts/build-devbox-freestyle.ts index 714710419370..8d78f54abfc7 100644 --- a/web/scripts/build-devbox-freestyle.ts +++ b/web/scripts/build-devbox-freestyle.ts @@ -289,7 +289,7 @@ try { // launch that it is falling back to its bundled copy. await step( "apt-devtools", - "apt-get update -q && apt-get install -y --no-install-recommends git ripgrep build-essential curl ca-certificates unzip zip xz-utils zstd procps iproute2 openssh-client pkg-config jq fd-find fzf sqlite3 tmux less rsync file tree nano vim sudo util-linux e2fsprogs bubblewrap && rm -rf /var/lib/apt/lists/* && ln -sf $(command -v fdfind) /usr/local/bin/fd && echo 'LANG=C.UTF-8' > /etc/default/locale && fd --version && jq --version && fzf --version && sqlite3 --version && tmux -V && bwrap --version", + "apt-get update -q && apt-get install -y --no-install-recommends git ripgrep build-essential curl ca-certificates unzip zip xz-utils zstd procps coreutils iproute2 openssh-client pkg-config jq fd-find fzf sqlite3 tmux less rsync file tree nano vim sudo util-linux e2fsprogs bubblewrap && rm -rf /var/lib/apt/lists/* && ln -sf $(command -v fdfind) /usr/local/bin/fd && echo 'LANG=C.UTF-8' > /etc/default/locale && fd --version && jq --version && fzf --version && sqlite3 --version && tmux -V && bwrap --version", ); await step( diff --git a/web/scripts/cloud-vm/cmux-tui-upgrade.sh b/web/scripts/cloud-vm/cmux-tui-upgrade.sh index 5b1f16c048a5..a80fa6aa8303 100755 --- a/web/scripts/cloud-vm/cmux-tui-upgrade.sh +++ b/web/scripts/cloud-vm/cmux-tui-upgrade.sh @@ -7,11 +7,11 @@ # # Terminals survive because each one lives in its own `__terminal-host` # process: SIGTERM ends only the daemon, cmux-devbox-boot restarts it from the -# new binary within a second, and the new daemon adopts the live hosts. The -# script changes nothing (SKIP) when the machine's supervisor would not -# restart the daemon with the current contract or the running daemon is not -# serving, and restores the replaced binary when the new daemon crashes or -# never listens. +# new binary within a second, and the new daemon adopts the live hosts. A +# crash-looping daemon is handled by a bounded canary: the old binary is +# checksummed into the run directory, the published binary is staged and +# checksummed, and only the daemon child is stopped. The supervisor is never +# restarted as a control group, and terminal hosts are never killed. # # Usage: cmux-tui-upgrade.sh [state-mount] # holds install.cmd (the pinned install command) and receives @@ -31,23 +31,31 @@ flock -n 9 || finish "SKIP busy another upgrade run holds the lock" daemon_pid() { pgrep -f "cmux-tui server start --session cloud" | head -1; } host_pids() { ps -eo pid=,args= | awk '$2 ~ /(cmux-tui|\/proc\/self\/exe)$/ && $3=="__terminal-host"{print $1}' | sort -n | tr '\n' ' '; } +daemon_has_trusted_carrier() { tr '\0' ' ' < "/proc/$1/cmdline" | grep -q -- --remote-ws-trusted-carrier; } term_count() { if [ "$H" = /root ]; then U=root; else U=cmux; fi; sudo -n -u "$U" env HOME="$H" "$BIN" --session cloud --json terminal list 2>/dev/null | python3 -c 'import json,sys d=json.load(sys.stdin); print(len(d if isinstance(d,list) else (d.get("terminals") or [])))' 2>/dev/null || echo "?"; } exe_sha() { sha256sum "/proc/$1/exe" 2>/dev/null | cut -d' ' -f1; } bin_sha() { sha256sum "$BIN" 2>/dev/null | cut -d' ' -f1; } listening() { ss -ltnH 2>/dev/null | grep -q ':1337 '; } +healthy() { + listening || return 1 + timeout --signal=TERM --kill-after=1s 10s \ + sudo -n -u "$( [ "$H" = /root ] && echo root || echo cmux )" \ + env HOME="$H" "$BIN" server status --session cloud >/dev/null 2>&1 +} state_mount_user() { [ "$H" = /root ] && echo root || echo cmux; } STATE_MOUNT_UPDATED=0 +STATE_MOUNT_FAILURE="" STATE_MOUNT_PREV="$RUN/cmux-tui-state-mount.prev" install_state_mount() { [ -f "$RUN/state-mount.sh" ] || return 0 - sh -n "$RUN/state-mount.sh" || finish "FAIL state-mount-syntax" + sh -n "$RUN/state-mount.sh" || { STATE_MOUNT_FAILURE=state-mount-syntax; return 1; } if [ -f "$STATE_MOUNT" ]; then - cp -p "$STATE_MOUNT" "$STATE_MOUNT_PREV" || finish "FAIL state-mount-backup" + cp -p "$STATE_MOUNT" "$STATE_MOUNT_PREV" || { STATE_MOUNT_FAILURE=state-mount-backup; return 1; } fi - install -m 0755 "$RUN/state-mount.sh" "$STATE_MOUNT" || finish "FAIL state-mount-install" + install -m 0755 "$RUN/state-mount.sh" "$STATE_MOUNT.new" && mv -f "$STATE_MOUNT.new" "$STATE_MOUNT" || { STATE_MOUNT_FAILURE=state-mount-install; return 1; } STATE_MOUNT_UPDATED=1 - "$STATE_MOUNT" ensure "$H" "$(state_mount_user)" || finish "FAIL state-reservation-unavailable" + "$STATE_MOUNT" ensure "$H" "$(state_mount_user)" || { STATE_MOUNT_FAILURE=state-reservation-unavailable; return 1; } } restore_state_mount() { [ "$STATE_MOUNT_UPDATED" = 1 ] || return 0 @@ -67,60 +75,125 @@ wait_serving() { [ -n "$N" ] || return 1 i=0; while [ $i -lt 600 ]; do [ "$(daemon_pid)" = "$N" ] || return 1 - if listening; then sleep 5; [ "$(daemon_pid)" = "$N" ] && echo "$N" && return 0; return 1; fi + if healthy; then [ "$(daemon_pid)" = "$N" ] && echo "$N" && return 0; return 1; fi sleep 1; i=$((i+1)) done return 1 } +# pidfd readiness is the kernel's process-exit signal. The two bounded waits +# own TERM/KILL escalation without polling or waiting forever for a D-state task. +stop_child() { + [ -n "${1:-}" ] || return 0 + python3 - "$1" <<'PYTHON' +import os, selectors, signal, sys +pid = int(sys.argv[1]) +try: + fd = os.pidfd_open(pid) +except ProcessLookupError: + sys.exit(0) +with selectors.DefaultSelector() as selector: + selector.register(fd, selectors.EVENT_READ) + try: + signal.pidfd_send_signal(fd, signal.SIGTERM) + if not selector.select(5): + signal.pidfd_send_signal(fd, signal.SIGKILL) + if not selector.select(1): + sys.exit(1) + except ProcessLookupError: + pass + finally: + os.close(fd) +PYTHON +} + +# Every failure after replacement restores the same binary/helper pair. +rollback_upgrade() { + reason=$1 + say "$reason; restoring $OLD_SHA" + helper_restored=1 + restore_state_mount || helper_restored=0 + cp -p "$RUN/cmux-tui.prev" "$BIN.rollback" && mv -f "$BIN.rollback" "$BIN" \ + && [ "$(bin_sha)" = "$OLD_SHA" ] || finish "FAIL rollback-restore reason=$reason" + X=$(daemon_pid) + stop_child "$X" || finish "FAIL rollback-daemon-still-running reason=$reason" + if N=$(wait_serving "$OLD_SHA"); then + [ "$helper_restored" = 1 ] || finish "FAIL rollback-helper reason=$reason old-daemon=$N" + finish "ROLLBACK $reason old-daemon=$N" + fi + finish "FAIL rollback-daemon-unhealthy reason=$reason inspect=$RUN/log" +} + +canary_crash_loop() { + OLD_SHA=$(bin_sha) + HOSTS_BEFORE=$(host_pids) + [ -n "$OLD_SHA" ] || finish "FAIL canary-old-binary-unreadable" + cp -p "$BIN" "$RUN/cmux-tui.prev" || finish "FAIL canary-backup-copy" + [ "$(sha256sum "$RUN/cmux-tui.prev" | cut -d' ' -f1)" = "$OLD_SHA" ] || finish "FAIL canary-backup-checksum" + FREE=$(df -Pm "$H" | awk 'NR==2{print $4}') + [ "$FREE" -ge 300 ] || finish "SKIP disk-free=${FREE}MB" + say "canary before old=$OLD_SHA target=$TARGET_COMMIT hosts=[$HOSTS_BEFORE]" + D=$(daemon_pid) + stop_child "$D" || finish "FAIL canary-daemon-still-running" + install_state_mount || rollback_upgrade "$STATE_MOUNT_FAILURE" + if ! sh "$RUN/install.cmd" >> "$RUN/log" 2>&1; then + [ "$(bin_sha)" = "$TARGET_SHA" ] || rollback_upgrade canary-install + fi + [ "$(bin_sha)" = "$TARGET_SHA" ] || rollback_upgrade canary-pin-mismatch + say "canary staged target=$TARGET_SHA" + if N=$(wait_serving "$TARGET_SHA"); then + LOST=""; for p in $HOSTS_BEFORE; do kill -0 "$p" 2>/dev/null || LOST="$LOST $p"; done + [ -z "$LOST" ] || rollback_upgrade "canary-lost-hosts=[${LOST# }]" + finish "OK canary daemon=$N hosts-before=$(echo $HOSTS_BEFORE | wc -w)" + fi + rollback_upgrade canary-target-unhealthy +} + if id -u cmux >/dev/null 2>&1 && command -v setpriv >/dev/null 2>&1 && setpriv --reuid=cmux --regid=cmux --init-groups test -w /home/cmux 2>/dev/null && setpriv --reuid=cmux --regid=cmux --init-groups sudo -n true >/dev/null 2>&1; then H=/home/cmux; else H=/root; fi BIN="$H/.cmux/bin/cmux-tui" -install_state_mount -# A crash-looping daemon is between restarts most of the time. -D=""; i=0; while [ -z "$D" ] && [ $i -lt 10 ]; do D=$(daemon_pid); [ -n "$D" ] || sleep 0.5; i=$((i+1)); done -[ -n "$D" ] || { [ "$STATE_MOUNT_UPDATED" = 1 ] && finish "OK state-mount-updated no-daemon"; finish "SKIP no-daemon"; } -tr '\0' ' ' < /proc/$D/cmdline | grep -q -- --remote-ws-trusted-carrier || finish "SKIP no-trusted-carrier" +# A crash-looping daemon is between restarts some of the time. Without a +# live child, its carrier contract cannot be proven; retry after inspecting it. +D=$(daemon_pid) +[ -n "$D" ] || finish "SKIP no-daemon" +daemon_has_trusted_carrier "$D" || finish "SKIP no-trusted-carrier" if [ "$(exe_sha "$D")" = "$TARGET_SHA" ]; then + install_state_mount || { + restore_state_mount || finish "FAIL state-mount-restore reason=$STATE_MOUNT_FAILURE" + finish "FAIL $STATE_MOUNT_FAILURE" + } # The next restart runs the binary on disk, so it must match too. [ "$(bin_sha)" = "$TARGET_SHA" ] || sh "$RUN/install.cmd" >> "$RUN/log" 2>&1 [ "$(bin_sha)" = "$TARGET_SHA" ] && finish "OK already-current daemon=$D" || finish "FAIL daemon-current-but-binary-not" fi -wait_listener || finish "SKIP daemon-not-listening (nothing changed)" FREE=$(df -Pm "$H" | awk 'NR==2{print $4}') [ "$FREE" -ge 300 ] || finish "SKIP disk-free=${FREE}MB" +if ! wait_listener; then + canary_crash_loop +fi HOSTS_BEFORE=$(host_pids) TERMS_BEFORE=$(term_count) OLD_SHA=$(bin_sha) say "before daemon=$D old=$OLD_SHA target=$TARGET_COMMIT hosts=[$HOSTS_BEFORE] terminals=$TERMS_BEFORE free=${FREE}MB" cp -p "$BIN" "$RUN/cmux-tui.prev" && [ "$(sha256sum "$RUN/cmux-tui.prev" | cut -d' ' -f1)" = "$OLD_SHA" ] || finish "FAIL backup (nothing changed)" +install_state_mount || rollback_upgrade "$STATE_MOUNT_FAILURE" if ! sh "$RUN/install.cmd" >> "$RUN/log" 2>&1; then # The binary step runs first; a hook-step failure leaves the pinned binary, which is still correct. - [ "$(bin_sha)" = "$TARGET_SHA" ] || finish "FAIL install (binary not replaced)" + [ "$(bin_sha)" = "$TARGET_SHA" ] || rollback_upgrade install say "install: hook step failed, binary is pinned; continuing" fi -[ "$(bin_sha)" = "$TARGET_SHA" ] || finish "FAIL pin-mismatch" +[ "$(bin_sha)" = "$TARGET_SHA" ] || rollback_upgrade pin-mismatch D=$(daemon_pid) -kill -TERM "$D" -i=0; while kill -0 "$D" 2>/dev/null && [ $i -lt 600 ]; do sleep 0.1; i=$((i+1)); done -kill -0 "$D" 2>/dev/null && finish "PENDING old-daemon-ignored-TERM daemon=$D (new binary installed, applies on next restart)" +stop_child "$D" || rollback_upgrade daemon-still-running if N=$(wait_serving "$TARGET_SHA"); then LOST=""; for p in $HOSTS_BEFORE; do kill -0 "$p" 2>/dev/null || LOST="$LOST $p"; done TERMS_AFTER=$(term_count) - [ -z "$LOST" ] || finish "FAIL lost-hosts=[${LOST# }] terminals=$TERMS_BEFORE->$TERMS_AFTER daemon=$N" + [ -z "$LOST" ] || rollback_upgrade "lost-hosts=[${LOST# }]" # Every host survived, but without both counts the terminals are not proven. case "$TERMS_BEFORE$TERMS_AFTER" in *'?'*) finish "UNVERIFIED terminal count unavailable terminals=$TERMS_BEFORE->$TERMS_AFTER daemon=$N" ;; esac - [ "$TERMS_AFTER" -ge "$TERMS_BEFORE" ] || finish "FAIL terminals=$TERMS_BEFORE->$TERMS_AFTER daemon=$N" + [ "$TERMS_AFTER" -ge "$TERMS_BEFORE" ] || rollback_upgrade "terminals=$TERMS_BEFORE->$TERMS_AFTER" finish "OK upgraded terminals=$TERMS_BEFORE->$TERMS_AFTER daemon=$N hosts-before=$(echo $HOSTS_BEFORE | wc -w)" fi -# The new daemon crashed or never listened. It may already have migrated the -# on-disk state, so a restored old binary is only trusted once it serves again. -say "new daemon did not serve; restoring $OLD_SHA" -restore_state_mount -cp -p "$RUN/cmux-tui.prev" "$BIN.rollback" && mv -f "$BIN.rollback" "$BIN" && [ "$(bin_sha)" = "$OLD_SHA" ] \ - || finish "FAIL rollback-restore (target binary may remain; inspect $RUN/log)" -X=$(daemon_pid); [ -n "$X" ] && kill -TERM "$X" -N=$(wait_serving "$OLD_SHA") \ - || finish "FAIL rollback-daemon-unhealthy (old binary restored but does not serve; on-disk state may be migrated)" -finish "ROLLBACK new-daemon-unstable; old daemon serving daemon=$N" +# An old binary is trusted only after it serves the existing registry again. +rollback_upgrade new-daemon-unstable diff --git a/web/services/vms/images/devbox/Dockerfile b/web/services/vms/images/devbox/Dockerfile index 1cb26d9a3386..11b9a95079fb 100644 --- a/web/services/vms/images/devbox/Dockerfile +++ b/web/services/vms/images/devbox/Dockerfile @@ -54,6 +54,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ xz-utils \ zstd \ procps \ + coreutils \ iproute2 \ iputils-arping \ openssh-client \ diff --git a/web/services/vms/images/devbox/cmux-devbox-boot b/web/services/vms/images/devbox/cmux-devbox-boot index 65c8d9b18aa9..26470f019d17 100644 --- a/web/services/vms/images/devbox/cmux-devbox-boot +++ b/web/services/vms/images/devbox/cmux-devbox-boot @@ -136,6 +136,17 @@ export CMUX_TUI_TEMPLATE_BOUND_FILE="$TEMPLATE_RUN_DIR/bound" export CMUX_TUI_TEMPLATE_WORKSPACE_NAME=workspace-1 HOUSEKEEPING_DELAY=10min HOUSEKEEPING_TIMERS="apt-daily.timer apt-daily-upgrade.timer dpkg-db-backup.timer e2scrub_all.timer fstrim.timer logrotate.timer man-db.timer motd-news.timer systemd-tmpfiles-clean.timer" +# A live cmux-tui process can stop servicing its local socket while still +# holding the VM's CPU. Probe the same local lifecycle command the image bake +# uses, with a grace period and consecutive-failure fence before replacing it. +# Restarting the daemon leaves its registry, terminals and agent hosts intact. +DAEMON_HEALTH_INTERVAL=5 +DAEMON_HEALTH_TIMEOUT=5 +DAEMON_HEALTH_FAILURE_LIMIT=3 +# Registry replay is data-dependent. A published 12 GB registry took about +# three minutes before the socket became ready; keep a bounded canary window +# large enough for that legitimate startup. +DAEMON_HEALTH_STARTUP_GRACE=48 # Selects the daemon's user, home and binary; keep identical to # cmuxTuiLayoutSelector() in web/services/vms/drivers/cmuxTuiDaemon.ts. @@ -158,6 +169,9 @@ boot_id_recovered="" daemon_pid="" desktop_pid="" parked="" +daemon_health_ticks=0 +daemon_health_failures=0 +daemon_health_grace=$DAEMON_HEALTH_STARTUP_GRACE # A hard power-off can leave the previous daemon's runtime marker without a # shutdown outcome. After a kernel reboot there cannot be a live predecessor @@ -306,13 +320,75 @@ announce_loop() { } stop_daemon() { - [ -n "$daemon_pid" ] || return 0 + if [ -z "$daemon_pid" ]; then + daemon_health_ticks=0 + daemon_health_failures=0 + daemon_health_grace=$DAEMON_HEALTH_STARTUP_GRACE + return 0 + fi kill "$daemon_pid" 2>/dev/null i=0 while kill -0 "$daemon_pid" 2>/dev/null && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i + 1)); done kill -9 "$daemon_pid" 2>/dev/null - wait "$daemon_pid" 2>/dev/null + # Observe the post-KILL exit separately, then reap the child in this shell. + # Keeping the wait here prevents a replacement daemon from starting while + # the old child is still a zombie owned by the supervisor. + timeout --signal=TERM --kill-after=1s 2s tail --pid="$daemon_pid" -f /dev/null >/dev/null 2>&1 || true + wait "$daemon_pid" 2>/dev/null || true daemon_pid="" + daemon_health_ticks=0 + daemon_health_failures=0 + daemon_health_grace=$DAEMON_HEALTH_STARTUP_GRACE +} + +# `server status` checks the daemon-owned local socket. Run it with the same +# user and HOME as the daemon; probing root's default state would report a +# healthy legacy session while the work-user daemon is wedged. +daemon_health_probe() { + if ! command -v timeout >/dev/null 2>&1; then + if [ -z "${daemon_health_timeout_warning:-}" ]; then + echo "cmux-devbox-boot: timeout is unavailable; daemon health probe is failing closed" >&2 + daemon_health_timeout_warning=1 + fi + return 1 + fi + case "$(cat /etc/cmux/daemon-layout 2>/dev/null)" in + user) CMUX_TUI_USER=cmux; CMUX_TUI_HOME=/home/cmux ;; + root) CMUX_TUI_USER=root; CMUX_TUI_HOME=/root ;; + *) return 1 ;; + esac + CMUX_TUI_BIN="$CMUX_TUI_HOME/.cmux/bin/cmux-tui" + [ -x "$CMUX_TUI_BIN" ] || return 1 + if [ "$CMUX_TUI_USER" = root ]; then + timeout --signal=TERM --kill-after=1s "${DAEMON_HEALTH_TIMEOUT}s" \ + env HOME="$CMUX_TUI_HOME" TERM=xterm-256color TERM_PROGRAM=ghostty \ + "$CMUX_TUI_BIN" server status --session cloud >/dev/null 2>&1 + else + timeout --signal=TERM --kill-after=1s "${DAEMON_HEALTH_TIMEOUT}s" \ + setpriv --reuid="$CMUX_TUI_USER" --regid="$CMUX_TUI_USER" --init-groups \ + env HOME="$CMUX_TUI_HOME" USER="$CMUX_TUI_USER" LOGNAME="$CMUX_TUI_USER" SHELL=/bin/bash \ + TERM=xterm-256color TERM_PROGRAM=ghostty "$CMUX_TUI_BIN" server status --session cloud >/dev/null 2>&1 + fi +} + +check_daemon_health() { + [ -n "$daemon_pid" ] || return 0 + daemon_health_ticks=$((daemon_health_ticks + 1)) + [ "$daemon_health_ticks" -ge "$DAEMON_HEALTH_INTERVAL" ] || return 0 + daemon_health_ticks=0 + if [ "$daemon_health_grace" -gt 0 ]; then + daemon_health_grace=$((daemon_health_grace - 1)) + return 0 + fi + if daemon_health_probe; then + daemon_health_failures=0 + return 0 + fi + daemon_health_failures=$((daemon_health_failures + 1)) + if [ "$daemon_health_failures" -lt "$DAEMON_HEALTH_FAILURE_LIMIT" ]; then return 0; fi + echo "cmux-devbox-boot: cmux-tui health probe failed $DAEMON_HEALTH_FAILURE_LIMIT times; restarting daemon" >&2 + stop_daemon + start_daemon } # Start the daemon (command identical to cmuxTuiDaemonCommand()). @@ -335,6 +411,9 @@ start_daemon() { fi (if id -u cmux >/dev/null 2>&1 && command -v setpriv >/dev/null 2>&1 && setpriv --reuid=cmux --regid=cmux --init-groups test -w /home/cmux 2>/dev/null && setpriv --reuid=cmux --regid=cmux --init-groups sudo -n true >/dev/null 2>&1; then CMUX_TUI_USER=cmux; CMUX_TUI_HOME=/home/cmux; CMUX_TUI_LAYOUT=user; else CMUX_TUI_USER=root; CMUX_TUI_HOME=/root; CMUX_TUI_LAYOUT=root; fi; CMUX_TUI_BIN="$CMUX_TUI_HOME/.cmux/bin/cmux-tui"; { mkdir -p /etc/cmux 2>/dev/null; printf '%s\n' "$CMUX_TUI_LAYOUT" > /etc/cmux/daemon-layout; } 2>/dev/null; cd "$CMUX_TUI_HOME" && if [ "$CMUX_TUI_USER" = root ]; then exec env HOME="$CMUX_TUI_HOME" TERM=xterm-256color TERM_PROGRAM=ghostty TERM_PROGRAM_VERSION="$(cat /etc/cmux/ghostty-version 2>/dev/null)" "$CMUX_TUI_BIN" server start --session cloud --remote-ws "${CMUX_TUI_REMOTE_WS_BIND:-0.0.0.0:1337}" --remote-ws-insecure-bind --remote-ws-trusted-carrier; else exec setpriv --reuid="$CMUX_TUI_USER" --regid="$CMUX_TUI_USER" --init-groups env HOME="$CMUX_TUI_HOME" USER="$CMUX_TUI_USER" LOGNAME="$CMUX_TUI_USER" SHELL=/bin/bash TERM=xterm-256color TERM_PROGRAM=ghostty TERM_PROGRAM_VERSION="$(cat /etc/cmux/ghostty-version 2>/dev/null)" "$CMUX_TUI_BIN" server start --session cloud --remote-ws "${CMUX_TUI_REMOTE_WS_BIND:-0.0.0.0:1337}" --remote-ws-insecure-bind --remote-ws-trusted-carrier; fi) & daemon_pid=$! + daemon_health_ticks=0 + daemon_health_failures=0 + daemon_health_grace=$DAEMON_HEALTH_STARTUP_GRACE } announce_loop & @@ -389,6 +468,7 @@ while true; do [ -n "$daemon_pid" ] && wait "$daemon_pid" 2>/dev/null start_daemon fi + check_daemon_health fi fi sleep "$tick" diff --git a/web/services/vms/images/manifest.json b/web/services/vms/images/manifest.json index af373b0ea5ce..c538a7c8b0d5 100644 --- a/web/services/vms/images/manifest.json +++ b/web/services/vms/images/manifest.json @@ -22007,7 +22007,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "sm", "cpu": 2, @@ -22043,7 +22043,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "md", "cpu": 4, @@ -22079,7 +22079,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lg", "cpu": 8, @@ -22115,7 +22115,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lgx", "cpu": 12, @@ -22150,7 +22150,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "xl", "cpu": 16, @@ -22186,7 +22186,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "2xl", "cpu": 32, @@ -22222,7 +22222,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "sm", "cpu": 2, @@ -22230,7 +22230,7 @@ "storageMb": 16384, "freestyleBase": "freestyle/ubuntu-sm" }, - "defaultForLocalDev": true + "defaultForLocalDev": false }, { "provider": "freestyle", @@ -22259,7 +22259,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "md", "cpu": 4, @@ -22295,7 +22295,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lg", "cpu": 8, @@ -22331,7 +22331,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lgx", "cpu": 12, @@ -22366,7 +22366,7 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "xl", "cpu": 16, @@ -22402,6 +22402,437 @@ "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 5a4095a8f6, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-13 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9a121114396b454885ba471feeb9b578, lg=sh-3b01975e0e1949af939d237656c687c6, lgx=sh-ed016a0eb50649a4abac0efffe36f03e, md=sh-9430c7a10af74b37926b43e59590ae8d, xl=sh-8373bed2c9a442c089769953ecb7f72e, 2xl=sh-9ca1704ad1614736b1ec0fd48fbe16ff.", "cmuxTuiCommit": "5a4095a8f6806ca59227077bc2e4ab58771bd076", "cmuxTuiSha256": "cea713d38acb477e11ff98959d80e9d1d32b3deadac8afb746674b41a597981d", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-sm", + "imageId": "sh-e9749b6da2bb4a6f8990f10e49d151f1", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-md", + "imageId": "sh-2b703866a4a747e89b86ce385997ccc3", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-lg", + "imageId": "sh-58cddf10f8584ab2878db41036d316aa", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-lgx", + "imageId": "sh-2895a7de75d94aa68d83ec852e365fb9", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-xl", + "imageId": "sh-64b86fa46c4544bbaabf4560b2104e77", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-2xl", + "imageId": "sh-8e7374da908f433f8899034bb80e8518", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-sm-base", + "imageId": "sh-e9749b6da2bb4a6f8990f10e49d151f1", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": true + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-md-base", + "imageId": "sh-2b703866a4a747e89b86ce385997ccc3", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-lg-base", + "imageId": "sh-58cddf10f8584ab2878db41036d316aa", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-lgx-base", + "imageId": "sh-2895a7de75d94aa68d83ec852e365fb9", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-xl-base", + "imageId": "sh-64b86fa46c4544bbaabf4560b2104e77", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", + "defaultForKind": true, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-38514254364-2xl-base", + "imageId": "sh-8e7374da908f433f8899034bb80e8518", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "bc4c6b13a1d5f681d3fcbc2ff495ed2234a4fc6e", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "32410a3834e7d0010a3d07b34ac1dbc963bbf7450c0cce38eac4392baed93df8", + "schema": 2 + }, + "builtAt": "2026-10-14T05:10:52.752Z", + "builderScriptVersion": "f7f9fee02a0140597998b16ccaf1bc9f5edff76f5510153e778d4f65cf481e89", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon f9d8ad6988, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-14 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e9749b6da2bb4a6f8990f10e49d151f1, md=sh-2b703866a4a747e89b86ce385997ccc3, lg=sh-58cddf10f8584ab2878db41036d316aa, xl=sh-64b86fa46c4544bbaabf4560b2104e77, lgx=sh-2895a7de75d94aa68d83ec852e365fb9, 2xl=sh-8e7374da908f433f8899034bb80e8518.", + "cmuxTuiCommit": "f9d8ad69882ddb4cbd5fc1d8ba2684a11708f676", + "cmuxTuiSha256": "7d1ee1c39f99e095b10ff843de2db0d2b68de82e17f6fc520f2d99a170d8665a", "defaultForKind": true, "size": { "name": "2xl", diff --git a/web/tests/vm-cmux-tui-upgrade.test.ts b/web/tests/vm-cmux-tui-upgrade.test.ts new file mode 100644 index 000000000000..7ef6a9d76f10 --- /dev/null +++ b/web/tests/vm-cmux-tui-upgrade.test.ts @@ -0,0 +1,85 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { runChild } from "./helpers/run-child"; + +const script = readFileSync(path.join(import.meta.dirname, "../scripts/cloud-vm/cmux-tui-upgrade.sh"), "utf8"); +const entry = script.indexOf("\nif id -u cmux"); +if (entry < 0) throw new Error("upgrade script entry point was not found"); +const functions = script.slice(0, entry); + +describe("Cloud daemon upgrade recovery", () => { + test.each(["install", "pin", "health", "hosts", "helper-syntax", "helper-install", "helper-ensure"])("%s failure restores both binary and helper", async (failure) => { + const root = mkdtempSync(path.join(tmpdir(), "cmux-upgrade-")); + try { + const run = path.join(root, "run"); + mkdirSync(run); + const binary = path.join(run, "cmux-tui"); + const helper = path.join(run, "state-mount"); + writeFileSync(binary, "previous binary\n"); + writeFileSync(helper, "previous helper\n"); + writeFileSync(path.join(run, "cmux-tui-state-mount.prev"), "previous helper\n"); + if (failure.startsWith("helper-")) { + writeFileSync(path.join(run, "state-mount.sh"), failure === "helper-syntax" ? "if\n" : `#!/bin/sh\nexit ${failure === "helper-ensure" ? 1 : 0}\n`); + } + writeFileSync(path.join(run, "install.cmd"), `printf 'target binary\\n' > "$FIXTURE_BIN"\nprintf 'target helper\\n' > "$FIXTURE_HELPER"\n${failure === "install" ? 'printf "bad binary\\n" > "$FIXTURE_BIN"\nexit 1' : failure === "pin" ? 'printf "bad binary\\n" > "$FIXTURE_BIN"' : ":"}\n`); + const fixture = `flock() { return 0; } +${functions} +BIN="$RUN/cmux-tui" +H="$RUN" +STATE_MOUNT="$RUN/state-mount" +STATE_MOUNT_UPDATED=${failure.startsWith("helper-") ? 0 : 1} +sha256sum() { shasum -a 256 "$1"; } +install() { ${failure === "helper-install" ? '[ "$3" = "$RUN/state-mount.sh" ] && return 1;' : ""} cp "$3" "$4" && chmod 755 "$4"; } +daemon_pid() { echo 77; } +stop_child() { :; } +host_pids() { ${failure === "hosts" ? "echo 88" : ":"}; } +kill() { return 1; } +wait_serving() { [ "$1" = "$OLD_SHA" ] && echo 99; ${failure === "hosts" ? '[ "$1" = "$TARGET_SHA" ] && echo 77;' : ""} [ "$1" = "$OLD_SHA" ]${failure === "hosts" ? ' || [ "$1" = "$TARGET_SHA" ]' : ""}; } +canary_crash_loop +`; + const result = await runChild("/bin/sh", ["-c", fixture, "fixture", createHash("sha256").update("target binary\n").digest("hex"), "target-commit", run], { + timeout: 15000, + env: { ...process.env, FIXTURE_BIN: binary, FIXTURE_HELPER: helper }, + }); + expect(result.status).toBe(0); + expect(readFileSync(binary, "utf8")).toBe("previous binary\n"); + expect(readFileSync(helper, "utf8")).toBe("previous helper\n"); + expect(readFileSync(path.join(run, "result"), "utf8")).toContain("ROLLBACK"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 20000); + + test.each(["absent", "untrusted", "trusted"])("no daemon is skipped with a %s supervisor", async (mode) => { + const root = mkdtempSync(path.join(tmpdir(), "cmux-upgrade-gate-")); + const run = path.join(root, "run"); + mkdirSync(run); + try { + const gateStart = script.indexOf("# A crash-looping daemon is between restarts"); + const gateEnd = script.indexOf('if [ "$(exe_sha "$D")"', gateStart); + expect(gateStart).toBeGreaterThanOrEqual(0); + expect(gateEnd).toBeGreaterThan(gateStart); + const fixture = `flock() { return 0; } +${functions} + daemon_pid() { ${mode === "absent" ? ":" : "echo 77"}; } + sleep() { :; } + pgrep() { ${mode === "absent" ? "return 1" : "return 0"}; } + daemon_has_trusted_carrier() { ${mode === "trusted" ? ":" : "return 1"}; } + canary_crash_loop() { finish "OK fixture-canary"; } +${script.slice(gateStart, gateEnd)} +[ "${mode}" = trusted ] && canary_crash_loop +`; + const result = await runChild("/bin/sh", ["-c", fixture, "fixture", "target-sha", "target-commit", run], { timeout: 5000 }); + expect(result.status).toBe(0); + expect(readFileSync(path.join(run, "result"), "utf8").trim()).toBe( + mode === "absent" ? "SKIP no-daemon" : mode === "untrusted" ? "SKIP no-trusted-carrier" : "OK fixture-canary", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 20000); + +}); diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index 3a642466393a..6d95eaa5dfb1 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -637,6 +637,51 @@ describe("devbox image template", () => { } }); + test("the daemon supervisor replaces a live but unresponsive cmux-tui", () => { + // Process liveness alone misses the incident this image is meant to + // recover from: cmux-tui can consume a vCPU while never answering its + // local lifecycle socket. Keep this contract close to the image source so + // a future supervisor edit cannot silently remove the recovery fence. + expect(devboxBoot).toContain("server status --session cloud"); + expect(devboxBoot).toContain('timeout --signal=TERM --kill-after=1s'); + expect(devboxBoot).toContain("DAEMON_HEALTH_FAILURE_LIMIT=3"); + expect(devboxBoot).toContain("DAEMON_HEALTH_STARTUP_GRACE=48"); + // Recovery uses the existing lifecycle functions, which do not delete the + // daemon's state directory or its remote terminal hosts. + expect(devboxBoot).toContain('REMOTE_STATE_DIR="$CMUX_TUI_HOME/.local/state/cmux/remote"'); + }); + + test("the health fence waits through startup and then restarts after consecutive failures", async () => { + // Exercise the actual POSIX functions from the image in isolation. The + // fixture replaces only process start/stop and the probe, so this proves + // the startup grace and consecutive-failure state machine without booting + // a VM or touching the host daemon. + const announceMarker = devboxBoot.indexOf("\nannounce_loop &"); + expect(announceMarker).toBeGreaterThanOrEqual(0); + const prefix = devboxBoot.slice(0, announceMarker); + const fixture = `${prefix} +set -e +DAEMON_HEALTH_STARTUP_GRACE=6 +daemon_health_grace=6 +daemon_pid=fixture +restart_count=0 +daemon_health_probe() { return 1; } +stop_daemon() { daemon_pid=""; } +start_daemon() { restart_count=$((restart_count + 1)); daemon_pid=fixture; daemon_health_ticks=0; daemon_health_failures=0; daemon_health_grace=$DAEMON_HEALTH_STARTUP_GRACE; } + for i in $(seq 1 44); do check_daemon_health; done +test "$restart_count" = 0 +test "$daemon_pid" = fixture + check_daemon_health +test "$restart_count" = 1 +test "$daemon_pid" = fixture +`; + const result = await runChild("/bin/sh", ["-c", fixture], { timeout: 20_000 }); + expect({ status: result.status, stderr: result.stderr }).toEqual({ + status: 0, + stderr: "cmux-devbox-boot: cmux-tui health probe failed 3 times; restarting daemon\n", + }); + }); + test("the Freestyle boot path supervises the daemon through systemd", () => { const freestyleScript = readScript("build-devbox-freestyle.ts"); expect(freestyleScript).toContain("ExecStart=/usr/local/bin/cmux-devbox-boot"); From 063a134cfceb04d61470455c3e03566a430a083b Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 22:38:21 -0700 Subject: [PATCH 009/183] fix: keep Claude idle after helper PreToolUse follows Stop (#20262) * test: reproduce Claude helper reopening a stopped turn * fix: ignore Claude helper tools after turn completion * fix: persist Claude turn settlement across notifications * fix: persist Claude stop transitions --- CLI/cmux.swift | 29 +++ .../ClaudeBackgroundWorkNotifyTests.swift | 169 ++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index dcbecf3a946b..15636bddca43 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -244,6 +244,10 @@ struct ClaudeHookSessionRecord: Codable { var lastPermissionMode: String? var isRestorable: Bool? var agentLifecycle: AgentHibernationLifecycleState? + /// True after a top-level prompt has ended and remains set through later + /// helper notifications until the next accepted UserPromptSubmit. + /// Optional so records written by older builds continue to decode. + var turnSettled: Bool? = nil /// The hook event that most recently established the persisted lifecycle. /// Optional so records written by older builds continue to decode. var hookEventName: String? = nil @@ -1176,6 +1180,11 @@ final class ClaudeHookSessionStore { terminalPromptTurnSet(from: record).contains(normalizedTurnId) { return (staleTerminalTurn: true, nested: false) } + // UserPromptSubmit is the lifecycle boundary that starts a new + // parent turn. Clear the settled-turn fence while holding the + // store lock so a concurrent helper PreToolUse cannot reopen the + // old turn between the prompt and its running update. + record.turnSettled = false update( &record, workspaceId: workspaceId, @@ -1285,6 +1294,7 @@ final class ClaudeHookSessionStore { let depthBeforeStop = max(0, record.activePromptDepth ?? 0) let depthAfterStop = max(0, depthBeforeStop - 1) let settlesTurn = depthAfterStop == 0 + record.turnSettled = settlesTurn update( &record, workspaceId: workspaceId, @@ -1413,6 +1423,7 @@ final class ClaudeHookSessionStore { launchCommand: AgentHookLaunchCommandRecord? = nil, isRestorable: Bool? = nil, agentLifecycle: AgentHibernationLifecycleState? = nil, + turnSettled: Bool? = nil, hookEventName: String? = nil, lastSubtitle: String? = nil, lastBody: String? = nil, @@ -1472,6 +1483,7 @@ final class ClaudeHookSessionStore { launchCommand: launchCommand, isRestorable: isRestorable, agentLifecycle: agentLifecycle, + turnSettled: turnSettled, hookEventName: hookEventName, lastSubtitle: lastSubtitle, lastBody: lastBody, @@ -2028,6 +2040,7 @@ final class ClaudeHookSessionStore { launchCommand: AgentHookLaunchCommandRecord?, isRestorable: Bool?, agentLifecycle: AgentHibernationLifecycleState?, + turnSettled: Bool? = nil, hookEventName: String? = nil, lastSubtitle: String?, lastBody: String?, @@ -2124,6 +2137,9 @@ final class ClaudeHookSessionStore { if let agentLifecycle { record.agentLifecycle = agentLifecycle } + if let turnSettled { + record.turnSettled = turnSettled + } if let hookEventName = normalizeOptional(hookEventName) { record.hookEventName = hookEventName } @@ -31525,6 +31541,7 @@ struct CMUXCLI { // hibernatable .idle state so the planner cannot SIGTERM // a live task (mirrors the antigravity fullyIdle flip). agentLifecycle: stopFailure != nil ? .needsInput : (hasUnsettledWork ? .backgroundWorkPending : .idle), + turnSettled: true, hookEventName: reportedHookEventName(from: parsedInput) ?? "Stop", lastSubtitle: completion?.subtitle, lastBody: completion?.body, @@ -31740,6 +31757,7 @@ struct CMUXCLI { launchCommand: firstSightingLaunchCommand, isRestorable: true, agentLifecycle: .running, + turnSettled: false, hookEventName: reportedHookEventName(from: parsedInput) ?? "UserPromptSubmit", updateLastSummary: true, markActive: true, @@ -32306,6 +32324,17 @@ struct CMUXCLI { let resolvedSurface = resolvedTarget let surfaceId = resolvedSurface.surfaceId sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) + // A helper can invoke PreToolUse after the parent turn's Stop hook. + // That event belongs to the completed turn, so it must not reopen + // the parent session as Running. A new parent turn always passes + // through UserPromptSubmit first, which replaces this settled + // lifecycle and clears the guard. + let parentTurnAlreadyEnded = mappedSession?.turnSettled == true + guard !parentTurnAlreadyEnded else { + telemetry.breadcrumb("claude-hook.pre-tool-use.after-stop") + printClaudeHookAck() + return + } let claudePid = localClaudePID(mapped: mappedSession) // One ancestry walk per hook event, shared by the suppression gate // and the notify payload's subagent tag. diff --git a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift index 852e05441111..87437989fc39 100644 --- a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift +++ b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift @@ -30,6 +30,175 @@ struct ClaudeBackgroundWorkNotifyTests { #expect(statusLine(result.snapshot, value: "Waiting") == nil) } + @Test func preToolUseAfterStopDoesNotReopenRunningState() throws { + try assertToolUseAfterBoundary(boundary: "Stop", pendingWork: false, expectRunning: false) + } + + @Test func pendingBackgroundWorkPreToolUseDoesNotReopenRunningState() throws { + try assertToolUseAfterBoundary(boundary: "Stop", pendingWork: true, expectRunning: false) + } + + @Test func notificationAfterStopKeepsHelperToolFromReopeningTurn() throws { + let session = "helper-after-stop-notification" + let harness = ClaudeHookSurfaceResolutionSwiftTests() + let context = try harness.makeClaudeHookContext(name: "helper-after-stop-notification") + defer { context.cleanup() } + let storeURL = context.root.appendingPathComponent("claude-hook-sessions.json") + let handled = harness.startClaudeSurfaceResolutionServer( + context: context, + surfaces: [(context.surfaceId, "surface:1", true)], + ttyName: "ttys-helper-after-stop-notification", + ttySurfaceId: context.surfaceId + ) + let environment = harness.claudeHookEnvironment( + context: context, + surfaceId: context.surfaceId, + ttyName: "ttys-helper-after-stop-notification", + storeURL: storeURL + ) + func runHook(_ subcommand: String, _ payload: String) { + let result = harness.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", subcommand], + environment: environment, + standardInput: payload, + timeout: ClaudeHookLiveDeliveryHarness.processWallBound + ) + #expect(handled.wait(timeout: .now() + 5) == .success) + harness.assertSuccessfulHook(result) + } + func lifecycle() throws -> String? { + let object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try #require(object["sessions"] as? [String: Any]) + let record = try #require(sessions[session] as? [String: Any]) + return record["agentLifecycle"] as? String + } + func settledTurn() throws -> Bool? { + let object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try #require(object["sessions"] as? [String: Any]) + let record = try #require(sessions[session] as? [String: Any]) + return record["turnSettled"] as? Bool + } + let prompt = #"{"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"UserPromptSubmit","prompt":"finish this"}"# + let stop = #"{"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"Stop","last_assistant_message":"done","background_tasks":[],"session_crons":[]}"# + let notification = #"{"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"Notification","notification_type":"permission_prompt","message":"Claude needs your permission"}"# + let tool = #"{"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status"}}"# + + runHook("prompt-submit", prompt) + runHook("stop", stop) + #expect(try lifecycle() == "idle") + #expect(try settledTurn() == true) + + runHook("notification", notification) + #expect(try lifecycle() == "needsInput") + #expect(try settledTurn() == true) + let commandCountAfterNotification = context.state.snapshot().count + + // The notification overwrites hookEventName, so this specifically + // proves the persisted turn boundary—not the event name—owns the guard. + runHook("pre-tool-use", tool) + let afterTool = Array(context.state.snapshot().dropFirst(commandCountAfterNotification)) + #expect(try lifecycle() == "needsInput") + #expect(try settledTurn() == true) + #expect(AgentJournalAppendCapture.captures(in: afterTool).contains { + $0.kind == "agent.state.changed" && $0.draft["declared_phase"] as? String == "running" + } == false) + #expect(statusLine(afterTool, value: "Running") == nil) + + // The next real prompt clears the fence and allows its own tool call. + runHook("prompt-submit", prompt) + let commandCountAfterPrompt = context.state.snapshot().count + runHook("pre-tool-use", tool) + #expect(try lifecycle() == "running") + #expect(try settledTurn() == false) + #expect(statusLine(Array(context.state.snapshot().dropFirst(commandCountAfterPrompt)), value: "Running") != nil) + } + + @Test(arguments: ["SessionStart", "Notification"]) + func toolUseWithoutCompletedTurnRemainsRunning(boundary: String) throws { + try assertToolUseAfterBoundary(boundary: boundary, pendingWork: false, expectRunning: true) + } + + private func assertToolUseAfterBoundary( + boundary: String, + pendingWork: Bool, + expectRunning: Bool + ) throws { + let session = "helper-after-stop" + let harness = ClaudeHookSurfaceResolutionSwiftTests() + let context = try harness.makeClaudeHookContext(name: "helper-after-stop") + defer { context.cleanup() } + let storeURL = context.root.appendingPathComponent("claude-hook-sessions.json") + let handled = harness.startClaudeSurfaceResolutionServer( + context: context, + surfaces: [(context.surfaceId, "surface:1", true)], + ttyName: "ttys-helper-after-stop", + ttySurfaceId: context.surfaceId + ) + let environment = harness.claudeHookEnvironment( + context: context, + surfaceId: context.surfaceId, + ttyName: "ttys-helper-after-stop", + storeURL: storeURL + ) + func runHook(_ subcommand: String, _ payload: String) { + let result = harness.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", subcommand], + environment: environment, + standardInput: payload, + timeout: ClaudeHookLiveDeliveryHarness.processWallBound + ) + #expect(handled.wait(timeout: .now() + 5) == .success) + harness.assertSuccessfulHook(result) + } + func lifecycle() throws -> String? { + let object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try #require(object["sessions"] as? [String: Any]) + let record = try #require(sessions[session] as? [String: Any]) + return record["agentLifecycle"] as? String + } + let prompt = #"{"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"UserPromptSubmit","prompt":"finish this"}"# + let tool = #"{"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status"}}"# + runHook("prompt-submit", prompt) + switch boundary { + case "SessionStart": + runHook("session-start", #"{"session_id":"\#(session)","hook_event_name":"SessionStart","source":"resume"}"#) + case "Notification": + runHook("notification", #"{"session_id":"\#(session)","hook_event_name":"Notification","notification_type":"permission_prompt","message":"Claude needs your permission"}"#) + default: + let tasks = pendingWork ? #"[{"status":"running"}]"# : "[]" + runHook("stop", #"{"session_id":"\#(session)","cwd":"/tmp/x","hook_event_name":"Stop","last_assistant_message":"done","background_tasks":\#(tasks),"session_crons":[]}"#) + #expect(try lifecycle() == (pendingWork ? "backgroundWorkPending" : "idle")) + } + let commandCountAfterBoundary = context.state.snapshot().count + + // This helper has no subagent marker or turn id, matching #19781. + runHook("pre-tool-use", tool) + let afterTool = Array(context.state.snapshot().dropFirst(commandCountAfterBoundary)) + let expectedLifecycle = expectRunning ? "running" : (pendingWork ? "backgroundWorkPending" : "idle") + #expect(try lifecycle() == expectedLifecycle) + let runningEvents = AgentJournalAppendCapture.captures(in: afterTool).filter { + $0.kind == "agent.state.changed" && $0.draft["declared_phase"] as? String == "running" + } + #expect(!runningEvents.isEmpty == expectRunning) + #expect((statusLine(afterTool, value: "Running") != nil) == expectRunning) + if !expectRunning { + let expectedSettledStatus = pendingWork ? "Waiting" : "Idle" + #expect(lastLine(context.state.snapshot(), prefix: "set_status claude_code ")?.hasPrefix("set_status claude_code \(expectedSettledStatus) ") == true) + if pendingWork { + #expect(try lifecycle() == "backgroundWorkPending") + } + + // The completed-turn guard must release on the next user prompt. + runHook("prompt-submit", prompt) + let commandCountAfterPrompt = context.state.snapshot().count + runHook("pre-tool-use", tool) + #expect(try lifecycle() == "running") + #expect(statusLine(Array(context.state.snapshot().dropFirst(commandCountAfterPrompt)), value: "Running") != nil) + } + } + private func statusLine(_ snapshot: [String], value: String) -> String? { snapshot.first { $0.hasPrefix("set_status claude_code \(value) ") } } From fa7160cc48273086c54e2d3225c2106e64e2ea37 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 22:41:12 -0700 Subject: [PATCH 010/183] fix(coderouter): fail over Claude 408/409 responses (#20350) * test(coderouter): cover Claude 408 and 409 failover * fix(coderouter): fail over Claude timeout responses --- web/services/coderouter/claudeProxy.ts | 4 ++-- web/tests/coderouter-claude-proxy.test.ts | 24 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/web/services/coderouter/claudeProxy.ts b/web/services/coderouter/claudeProxy.ts index 151d52e28a78..c71c8b03455b 100644 --- a/web/services/coderouter/claudeProxy.ts +++ b/web/services/coderouter/claudeProxy.ts @@ -729,7 +729,7 @@ async function probeClaudeStreamFailure(response: Response, signal: AbortSignal) await reader.cancel().catch(() => undefined); throw error; } - let buffered = chunks.slice(); + const buffered = chunks.slice(); let upstreamDone = false; const stream = new ReadableStream({ async pull(controller) { @@ -865,7 +865,7 @@ function classifyAttempt(attempt: Attempt): Verdict { stage: "upstream_response", }; } - if (status === 500 || status === 502 || status === 503 || status === 529) { + if (status === 408 || status === 409 || status === 500 || status === 502 || status === 503 || status === 529) { return { kind: "failover", cooldownMs: UPSTREAM_UNAVAILABLE_COOLDOWN_MS, diff --git a/web/tests/coderouter-claude-proxy.test.ts b/web/tests/coderouter-claude-proxy.test.ts index 56b0efdad202..0b193cf284bd 100644 --- a/web/tests/coderouter-claude-proxy.test.ts +++ b/web/tests/coderouter-claude-proxy.test.ts @@ -643,6 +643,30 @@ describe("claude proxy failover across accounts", () => { expect(outcome).toMatchObject({ outcome: "success", attempts: 2, upstreamAccountId: "acct-api-2" }); }); + test("fails over retryable Claude 408 and 409 responses", async () => { + upstream = apiKeyUpstream; + moreAccounts = [secondApiKey]; + + for (const status of [408, 409]) { + fetchCalls = []; + cooldowns = []; + touched = []; + const responses = [ + () => new Response("{}", { status }), + () => Response.json({ id: `msg-${status}`, model: "claude-sonnet-4-5", usage: { input_tokens: 1, output_tokens: 1 } }), + ]; + upstreamResponse = () => responses.shift()!(); + + const { response } = await routed(messagesRequest({ model: "claude-sonnet-4-5", max_tokens: 8, messages: [{ role: "user", content: "hi" }] })); + expect(response.status).toBe(200); + expect(fetchCalls).toHaveLength(2); + expect(requestHeaders(fetchCalls[0]!).get("x-api-key")).toBe("sk-ant-api03-real-secret-key-value-1234"); + expect(requestHeaders(fetchCalls[1]!).get("x-api-key")).toBe("sk-ant-api03-second-key-value-5678"); + expect(cooldowns).toEqual([{ accountId: "acct-api-1", durationMs: 20_000, failureCode: "upstream_unavailable" }]); + expect((await response.json()).id).toBe(`msg-${status}`); + } + }); + test("a rejected credential cools the account down for fifteen minutes", async () => { upstream = apiKeyUpstream; moreAccounts = [secondApiKey]; From ca9f96d4689a04b91e9bd49082f11cfc51d7c145 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 22:41:24 -0700 Subject: [PATCH 011/183] fix(cloud): bound unresponsive VM stats and pause (#20345) * test(cloud): cover unresponsive VM status and pause probe * fix(cloud): surface wedged guests and bound pause * fix(cloud): bound Go pause preparation * test(cloud): align stats deadline expectation * fix(cloud): preserve pause probe timeout * docs(cloud): cover workflow test helpers --- web/services/vms/workflows.ts | 78 +++++++++++++++++----- web/tests/vm-freestyle-stats.test.ts | 5 +- web/tests/vm-pause-resume-workflow.test.ts | 62 +++++++++++++++-- web/tests/vm-read-deadlines.test.ts | 4 +- 4 files changed, 127 insertions(+), 22 deletions(-) diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index 494324d6c39d..989feb55938f 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -306,6 +306,10 @@ const LEGACY_RESOURCE_RECONCILE_PROVIDER_TIMEOUT = "2 seconds"; // Provider stats are advisory on request paths. A stalled provider must not // keep a snapshot or fork HTTP request open indefinitely. const FOREGROUND_PROVIDER_STATS_TIMEOUT = "2 seconds"; +/** A pause retry must not wait on a provider status read that has stopped answering. */ +export const PAUSE_STATE_PROBE_TIMEOUT_MS = 5_000; +/** A provider pause is an out-of-band lifecycle operation; bound its foreground wait. */ +export const PAUSE_OPERATION_TIMEOUT_MS = 30_000; const RESIZE_PENDING_RECOVERY_AFTER_MS = 15 * 60 * 1000; const RESIZE_UNCONFIRMED_RECOVERY_AFTER_MS = 30 * 60 * 1000; const PREVIEW_ENDPOINT_LEASE_TTL_MS = 12 * 60 * 60 * 1000; @@ -2232,9 +2236,23 @@ export function pauseVm(input: { // powered-off machine is recorded as parked without a pause the provider // would refuse, and only a running machine is paused now. const probed = providers.getPauseState - ? yield* providers.getPauseState(vm.provider, providerVmId).pipe(Effect.either) + ? yield* providers.getPauseState(vm.provider, providerVmId).pipe( + Effect.timeoutFail({ + duration: PAUSE_STATE_PROBE_TIMEOUT_MS, + onTimeout: () => new VmOperationDeadlineError({ + operation: "pause", + mutating: true, + waitedMs: PAUSE_STATE_PROBE_TIMEOUT_MS, + }), + }), + Effect.either, + ) : null; - if (probed?._tag === "Left" || (probed?._tag === "Right" && probed.right === "in_progress")) { + if (probed?._tag === "Left") { + if (probed.left._tag === "VmOperationDeadlineError") return yield* Effect.fail(probed.left); + return yield* Effect.fail(new VmOperationDeadlineError({ operation: "pause", mutating: true, waitedMs: 0 })); + } + if (probed?._tag === "Right" && probed.right === "in_progress") { return yield* Effect.fail(new VmOperationDeadlineError({ operation: "pause", mutating: true, waitedMs: 0 })); } const observed = probed?._tag === "Right" ? probed.right : null; @@ -2243,8 +2261,20 @@ export function pauseVm(input: { return yield* Effect.fail(new VmNotFoundError({ vmId: input.providerVmId })); } if (observed !== "paused" && observed !== "stopped") { - if (vm.billingPlanId === "go") yield* setRuntimeBudget(providers, vm, providerVmId, 0); - yield* pause(vm.provider, providerVmId); + const pauseOperation = Effect.gen(function* () { + if (vm.billingPlanId === "go") yield* setRuntimeBudget(providers, vm, providerVmId, 0); + yield* pause(vm.provider, providerVmId); + }); + yield* pauseOperation.pipe( + Effect.timeoutFail({ + duration: PAUSE_OPERATION_TIMEOUT_MS, + onTimeout: () => new VmOperationDeadlineError({ + operation: "pause", + mutating: true, + waitedMs: PAUSE_OPERATION_TIMEOUT_MS, + }), + }), + ); } const recorded = yield* repo.markProviderObservedStatus({ id: vm.id, providerVmId, status: "paused" }); if (!recorded) { @@ -4388,6 +4418,10 @@ export function statVmFile(input: VmFileInput, path: string): VmWorkflowProgram< }); } +/** + * Read provider stats without waking a sleeping machine and mark an awake VM + * unknown until its guest has supplied a recent resource sample. + */ export function getVmStats(input: { readonly userId: string; readonly billingTeamId?: string | null; @@ -4413,23 +4447,37 @@ export function getVmStats(input: { Effect.flatMap((stats) => { const now = Date.now(); const reported = applyVmResourceUsage(stats, vm.providerMetadata, input.providerVmId, now); + /** Keep provider lifecycle state honest when the guest reporter is stale or absent. */ + const withGuestHealth = (value: VMStats): VMStats => { + if (value.state !== "awake") return value; + const healthNow = Date.now(); + const sampledAt = value.resourceSampledAt; + const guestSampleFresh = sampledAt !== undefined + && sampledAt <= healthNow + && healthNow - sampledAt <= VM_RESOURCE_USAGE_MAX_AGE_MS; + // A provider lifecycle state only says that the allocation exists. + // Without a recent guest sample we cannot claim that the guest is + // awake; reporting `unknown` prevents the UI and CLI from showing a + // healthy machine with empty utilization after the guest wedges. + return guestSampleFresh ? value : { ...value, state: "unknown" }; + }; // The private development backend cannot receive production-edge reports. // Production keeps the push path. Never probe non-awake machines, and // prefer an existing fresh report over another guest round trip. const fresh = reported.resourceSampledAt !== undefined && reported.resourceSampledAt <= now && now - reported.resourceSampledAt <= VM_RESOURCE_USAGE_MAX_AGE_MS; - if (stats.state !== "awake" || fresh || !shouldReadVmResourceStatsDirectly() || !providers.getResourceStats) { - return Effect.succeed(reported); - } - return providers.getResourceStats(vm.provider, input.providerVmId).pipe( - Effect.map((sample) => sample ? applyVmResourceUsage(stats, { - [VM_RESOURCE_USAGE_KEY]: { - ...sample, providerVmId: input.providerVmId, receivedAt: sample.resourceSampledAt, - }, - }, input.providerVmId, Date.now()) : reported), - Effect.catchAll((error) => isProviderNotFoundError(error) ? Effect.fail(error) : Effect.succeed(reported)), - ); + const resourceRead = stats.state !== "awake" || fresh || !shouldReadVmResourceStatsDirectly() || !providers.getResourceStats + ? Effect.succeed(reported) + : providers.getResourceStats(vm.provider, input.providerVmId).pipe( + Effect.map((sample) => sample ? applyVmResourceUsage(stats, { + [VM_RESOURCE_USAGE_KEY]: { + ...sample, providerVmId: input.providerVmId, receivedAt: sample.resourceSampledAt, + }, + }, input.providerVmId, Date.now()) : reported), + Effect.catchAll((error) => isProviderNotFoundError(error) ? Effect.fail(error) : Effect.succeed(reported)), + ); + return resourceRead.pipe(Effect.map(withGuestHealth)); }), Effect.mapError((error): VmWorkflowError => error), Effect.catchAll((error) => { diff --git a/web/tests/vm-freestyle-stats.test.ts b/web/tests/vm-freestyle-stats.test.ts index 93dbbb3c08f2..02ccfa2e7e09 100644 --- a/web/tests/vm-freestyle-stats.test.ts +++ b/web/tests/vm-freestyle-stats.test.ts @@ -21,6 +21,7 @@ type ReadStatsOptions = { readonly resourceOutput?: unknown; }; +/** Run the stats workflow with a controlled provider and optional guest sample. */ async function readStats( state: string, sample: Record | undefined, @@ -135,6 +136,7 @@ describe("Freestyle live machine stats", () => { ])("$label keeps stats on the reporter path", async ({ env }) => { const { result, resourceCalls } = await readStats("running", undefined, { directBackend: true, environment: env }); expect(resourceCalls).toEqual([]); + expect(result.state).toBe("unknown"); expect(result.cpuPercent).toBeUndefined(); }); @@ -167,7 +169,7 @@ describe("Freestyle live machine stats", () => { directBackend: true, resourceExitCode, resourceOutput, }); expect(resourceCalls).toHaveLength(1); - expect(result).toMatchObject({ state: "awake", cpus: 2, memoryTotalMb: 4096, diskTotalMb: 16384 }); + expect(result).toMatchObject({ state: "unknown", cpus: 2, memoryTotalMb: 4096, diskTotalMb: 16384 }); expect(result.cpuPercent).toBeUndefined(); expect(result.memoryUsedMb).toBeUndefined(); expect(result.diskUsedMb).toBeUndefined(); @@ -186,6 +188,7 @@ describe("Freestyle live machine stats", () => { test.each([undefined, { ...gauges, receivedAt: 0, providerVmId: "vm-stats" }, { ...gauges, receivedAt: Date.now(), providerVmId: "replaced-vm" }])("missing, stale, and replaced-VM samples remain unavailable", async (sample) => { const { result } = await readStats("running", sample); + expect(result.state).toBe("unknown"); expect(result.cpuPercent).toBeUndefined(); expect(result.diskTotalMb).toBe(16384); }); diff --git a/web/tests/vm-pause-resume-workflow.test.ts b/web/tests/vm-pause-resume-workflow.test.ts index f24217e27beb..752a5fac2371 100644 --- a/web/tests/vm-pause-resume-workflow.test.ts +++ b/web/tests/vm-pause-resume-workflow.test.ts @@ -8,7 +8,7 @@ import * as TestClock from "effect/TestClock"; import * as TestContext from "effect/TestContext"; import { VmProviderGateway, type VmProviderGatewayShape } from "../services/vms/providerGateway"; import { VmRepository, type CloudVmRow, type VmRepositoryShape } from "../services/vms/repository"; -import { pauseVm, resumeVm } from "../services/vms/workflows"; +import { PAUSE_OPERATION_TIMEOUT_MS, PAUSE_STATE_PROBE_TIMEOUT_MS, pauseVm, resumeVm } from "../services/vms/workflows"; import { VmProviderOperationError } from "../services/vms/errors"; import { providerConflictKind } from "../services/vms/drivers/types"; @@ -20,6 +20,7 @@ type Recorded = { paused: string[]; resumed: string[]; statusProbes: string[]; + runtimeBudgets: Array<{ id: string; seconds: number | null }>; statuses: Array<{ id: string; status: string }>; reservations: string[]; skipActiveLimits: boolean[]; @@ -56,18 +57,21 @@ function machineRow(overrides: Partial = {}): CloudVmRow { }; } +/** Build repository and provider layers that record pause/resume side effects. */ function fakes(options: { row: CloudVmRow; providerStatus?: "running" | "paused"; /** Successive getStatus answers; the last one repeats. */ providerStatuses?: Array<"creating" | "running" | "paused" | "probe_fails">; - pauseState?: "paused" | "in_progress" | "not_paused" | "stopped" | "destroyed" | "probe_fails"; + pauseState?: "paused" | "in_progress" | "not_paused" | "stopped" | "destroyed" | "probe_fails" | "probe_hangs"; + pauseHangs?: boolean; + runtimeBudgetHangs?: boolean; withPause?: boolean; withResume?: boolean; stats?: { cpus: number; memoryTotalMb: number; diskTotalMb: number }; onReservation?: (reservation: { vcpus: number; memoryMb: number; diskMb: number }) => void; }) { - const recorded: Recorded = { paused: [], resumed: [], statusProbes: [], statuses: [], reservations: [], skipActiveLimits: [], reservationWrites: [], events: [] }; + const recorded: Recorded = { paused: [], resumed: [], statusProbes: [], runtimeBudgets: [], statuses: [], reservations: [], skipActiveLimits: [], reservationWrites: [], events: [] }; const repo = { findUserVm: (input: { providerVmId: string }) => Effect.succeed(input.providerVmId === options.row.providerVmId ? options.row : null), @@ -106,7 +110,7 @@ function fakes(options: { ? { getPauseState: () => options.pauseState === "probe_fails" ? Effect.fail(new VmProviderOperationError({ provider: "freestyle", operation: "getPauseState", cause: new Error("Status: 500") })) - : Effect.succeed(options.pauseState), + : options.pauseState === "probe_hangs" ? Effect.never : Effect.succeed(options.pauseState), } : {}), ...(options.withPause === false @@ -114,9 +118,13 @@ function fakes(options: { : { pause: (_provider: string, providerVmId: string) => { recorded.paused.push(providerVmId); - return Effect.void; + return options.pauseHangs ? Effect.never : Effect.void; }, }), + setRuntimeBudget: (_provider: string, providerVmId: string, seconds: number | null) => { + recorded.runtimeBudgets.push({ id: providerVmId, seconds }); + return options.runtimeBudgetHangs ? Effect.never : Effect.void; + }, ...(options.withResume === false ? {} : { @@ -260,6 +268,50 @@ describe("pauseVm", () => { expect(recorded.statuses).toEqual([]); }); + test("a hanging state probe fails within its bound and does not leave pause running", async () => { + const { recorded, layer } = fakes({ row: machineRow(), pauseState: "probe_hangs" }); + const exit = await Effect.runPromise(Effect.gen(function* () { + const fiber = yield* Effect.fork(pauseVm(caller).pipe(Effect.provide(layer), Effect.either)); + yield* TestClock.adjust(PAUSE_STATE_PROBE_TIMEOUT_MS); + return yield* Fiber.join(fiber); + }).pipe(Effect.provide(TestContext.TestContext))); + expect(exit._tag).toBe("Left"); + expect(exit._tag === "Left" ? exit.left._tag : null).toBe("VmOperationDeadlineError"); + expect(exit._tag === "Left" && exit.left._tag === "VmOperationDeadlineError" ? exit.left.waitedMs : null) + .toBe(PAUSE_STATE_PROBE_TIMEOUT_MS); + expect(recorded.paused).toEqual([]); + expect(recorded.statuses).toEqual([]); + }); + + test("a provider pause that never answers fails within its bound and leaves the row running", async () => { + const { recorded, layer } = fakes({ row: machineRow(), pauseState: "not_paused", pauseHangs: true }); + const exit = await Effect.runPromise(Effect.gen(function* () { + const fiber = yield* Effect.fork(pauseVm(caller).pipe(Effect.provide(layer), Effect.either)); + yield* TestClock.adjust(PAUSE_OPERATION_TIMEOUT_MS); + return yield* Fiber.join(fiber); + }).pipe(Effect.provide(TestContext.TestContext))); + expect(exit._tag).toBe("Left"); + expect(exit._tag === "Left" ? exit.left._tag : null).toBe("VmOperationDeadlineError"); + expect(recorded.paused).toEqual(["fs-1"]); + expect(recorded.statuses).toEqual([]); + }); + + test("a Go runtime-budget update that never answers fails within the pause bound", async () => { + const { recorded, layer } = fakes({ + row: machineRow({ billingPlanId: "go" }), pauseState: "not_paused", runtimeBudgetHangs: true, + }); + const exit = await Effect.runPromise(Effect.gen(function* () { + const fiber = yield* Effect.fork(pauseVm(caller).pipe(Effect.provide(layer), Effect.either)); + yield* TestClock.adjust(PAUSE_OPERATION_TIMEOUT_MS); + return yield* Fiber.join(fiber); + }).pipe(Effect.provide(TestContext.TestContext))); + expect(exit._tag).toBe("Left"); + expect(exit._tag === "Left" ? exit.left._tag : null).toBe("VmOperationDeadlineError"); + expect(recorded.runtimeBudgets).toEqual([{ id: "fs-1", seconds: 0 }]); + expect(recorded.paused).toEqual([]); + expect(recorded.statuses).toEqual([]); + }); + test("a provider without pause fails with the unsupported error (the route's 501)", async () => { const { recorded, layer } = fakes({ row: machineRow(), withPause: false }); expect(await failureTag(pauseVm(caller), layer)).toBe("VmOperationUnsupportedError"); diff --git a/web/tests/vm-read-deadlines.test.ts b/web/tests/vm-read-deadlines.test.ts index bca10b2beab0..2e2d0ed8122d 100644 --- a/web/tests/vm-read-deadlines.test.ts +++ b/web/tests/vm-read-deadlines.test.ts @@ -85,7 +85,9 @@ describe("VM read deadlines through the real route path", () => { const run = await statsRoute("vm-ok", 5_000); expect(run.ok).toBe(true); if (!run.ok) return; - expect(run.value).toMatchObject({ state: "awake", cpus: 2 }); + // Provider running state is not enough to claim the guest is healthy; + // this fixture has no resource reporter sample, so stats are unknown. + expect(run.value).toMatchObject({ state: "unknown", cpus: 2 }); expect(fetches.find((entry) => entry.path === "/v5/vms/vm-ok")?.signal?.aborted).toBe(false); }); From a47d61e9d7e6c5153ac724522f9d8c53c2a842ed Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 22:41:49 -0700 Subject: [PATCH 012/183] Fix control-created surfaces replaying inherited startup work (#20312) * test: cover control-created surface startup lifecycle * fix: keep control-created surfaces alive until input * fix: cover startup inheritance for control splits * fix: suppress startup replay for auxiliary control terminals Pane-swap placeholders and mobile-created terminals are automation-owned surfaces too, so they must not replay a source surface's one-shot startup payload. Co-authored-by: Austin Wang (cherry picked from commit 73e5bd2acb5ee0f3f7aefa55ca50e48ebcb7c85c) --- .../TerminalSurfaceExplicitInputTests.swift | 37 ++++++++++++- .../GhosttyRuntimeTestStubs.c | 31 ++++++++++- .../include/GhosttyRuntimeTestStubs.h | 3 ++ .../CmuxSurfaceConfigTemplate.swift | 15 ++++++ .../SurfaceConfigTemplateFontSizeTests.swift | 19 +++++++ ...erminalController+ControlPaneContext.swift | 9 ++-- ...nalController+ControlSidebarContext3.swift | 6 ++- ...nalController+ControlSurfaceContext2.swift | 6 ++- ...inalController+ControlSystemContext2.swift | 3 +- Sources/TerminalController.swift | 6 ++- Sources/Workspace.swift | 36 +++++++++---- .../WorkspaceClosePanelFallbackTests.swift | 18 +++++++ .../WorkspaceSplitStartupCommandTests.swift | 53 +++++++++++++++++++ 13 files changed, 218 insertions(+), 24 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift index 9f04d41d1695..f0e6ee784953 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift @@ -19,6 +19,15 @@ private func cmuxTestGhosttySurfaceKeyUnshiftedCodepoint() -> UInt32 @_silgen_name("cmux_test_ghostty_surface_key_text") private func cmuxTestGhosttySurfaceKeyText() -> UnsafePointer +@_silgen_name("cmux_test_ghostty_input_events_reset") +private func cmuxTestGhosttyInputEventsReset() + +@_silgen_name("cmux_test_ghostty_input_event_count") +private func cmuxTestGhosttyInputEventCount() -> UInt32 + +@_silgen_name("cmux_test_ghostty_input_event_kind") +private func cmuxTestGhosttyInputEventKind(_ index: UInt32) -> Int32 + @MainActor @Suite(.serialized) struct TerminalSurfaceExplicitInputTests { @@ -197,6 +206,29 @@ struct TerminalSurfaceExplicitInputTests { #expect(acceptedInputCount == 1) } + @Test func coldSurfaceKeepsQueuedTextAndKeyInSubmissionOrder() { + let fixture = makeFixture() + defer { fixture.surface.releaseSurfaceForTesting() } + + #expect(fixture.surface.sendTextResult("echo ready\n") == .queued) + #expect(fixture.surface.sendNamedKey("enter") == .queued) + + let pending = fixture.surface.debugPendingSocketInputForTesting() + #expect(pending.items == 2) + #expect(pending.pasteTextItems == 1) + #expect(pending.keyEvents == 1) + + let runtimeSurface = allocatedRuntimeSurface() + fixture.registry.registerRuntimeSurface(runtimeSurface, ownerId: fixture.surface.id) + fixture.surface.installRuntimeSurfaceForTesting(runtimeSurface) + cmuxTestGhosttyInputEventsReset() + fixture.surface.flushPendingSocketInputIfNeeded() + + #expect(cmuxTestGhosttyInputEventCount() == 2) + #expect(cmuxTestGhosttyInputEventKind(0) == 1, "Expected queued text to replay first") + #expect(cmuxTestGhosttyInputEventKind(1) == 2, "Expected queued key to replay second") + } + @Test func rejectedParsedInputDoesNotNotifyItsOwner() { let fixture = makeFixture() defer { fixture.surface.releaseSurfaceForTesting() } @@ -349,7 +381,8 @@ struct TerminalSurfaceExplicitInputTests { ) -> ( surface: TerminalSurface, paneHost: FakeTerminalSurfacePaneHost, - nativeView: FakeTerminalSurfaceNativeView + nativeView: FakeTerminalSurfaceNativeView, + registry: FakeSurfaceRegistry ) { let nativeView = FakeTerminalSurfaceNativeView( frame: NSRect(x: 0, y: 0, width: 800, height: 600) @@ -392,7 +425,7 @@ struct TerminalSurfaceExplicitInputTests { registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) surface.installRuntimeSurfaceForTesting(runtimeSurface) } - return (surface, paneHost, nativeView) + return (surface, paneHost, nativeView, registry) } private func allocatedRuntimeSurface() -> ghostty_surface_t { diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 3f2e60b48333..d4a82cbb49c2 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -42,6 +42,8 @@ static bool cmux_test_surface_key_called = false; static int cmux_test_surface_key_mods_value = 0; static uint32_t cmux_test_surface_key_unshifted_codepoint_value = 0; static char cmux_test_surface_key_text_value[32]; +static int cmux_test_input_event_kinds[32]; +static uint32_t cmux_test_input_event_count_value = 0; static pthread_mutex_t cmux_test_surface_free_mutex = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t cmux_test_surface_free_condition = PTHREAD_COND_INITIALIZER; static bool cmux_test_surface_free_should_block = false; @@ -174,6 +176,7 @@ void cmux_test_ghostty_runtime_stubs_reset(void) { cmux_test_surface_key_mods_value = 0; cmux_test_surface_key_unshifted_codepoint_value = 0; cmux_test_surface_key_text_value[0] = '\0'; + cmux_test_input_event_count_value = 0; } void cmux_test_ghostty_surface_key_reset(void) { @@ -199,6 +202,19 @@ const char* cmux_test_ghostty_surface_key_text(void) { return cmux_test_surface_key_text_value; } +void cmux_test_ghostty_input_events_reset(void) { + cmux_test_input_event_count_value = 0; +} + +uint32_t cmux_test_ghostty_input_event_count(void) { + return cmux_test_input_event_count_value; +} + +int cmux_test_ghostty_input_event_kind(uint32_t index) { + if (index >= cmux_test_input_event_count_value) return 0; + return cmux_test_input_event_kinds[index]; +} + void cmux_test_ghostty_surface_free_blocking_begin(void *surface) { pthread_mutex_lock(&cmux_test_surface_free_mutex); cmux_test_surface_free_should_block = true; @@ -621,6 +637,9 @@ bool ghostty_surface_key(void *surface, cmux_test_ghostty_input_key_s key_event) if (key_event.action != 1) { return true; } + if (cmux_test_input_event_count_value < 32) { + cmux_test_input_event_kinds[cmux_test_input_event_count_value++] = 2; + } cmux_test_surface_key_called = true; cmux_test_surface_key_mods_value = key_event.mods; cmux_test_surface_key_unshifted_codepoint_value = key_event.unshifted_codepoint; @@ -778,8 +797,16 @@ bool ghostty_surface_rebuild_renderer(void *surface) { } void ghostty_surface_set_size(void) {} void ghostty_surface_size(void) {} -void ghostty_surface_text(void) {} -void ghostty_surface_text_input(void) {} +void ghostty_surface_text(void) { + if (cmux_test_input_event_count_value < 32) { + cmux_test_input_event_kinds[cmux_test_input_event_count_value++] = 1; + } +} +void ghostty_surface_text_input(void) { + if (cmux_test_input_event_count_value < 32) { + cmux_test_input_event_kinds[cmux_test_input_event_count_value++] = 1; + } +} void ghostty_surface_update_config(void *surface, void *raw_config) { (void)raw_config; cmux_test_last_updated_surface = surface; diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 35f5eff17e6f..0e4aa4700c4d 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -167,5 +167,8 @@ bool cmux_test_ghostty_surface_key_was_called(void); int cmux_test_ghostty_surface_key_mods(void); uint32_t cmux_test_ghostty_surface_key_unshifted_codepoint(void); const char* cmux_test_ghostty_surface_key_text(void); +void cmux_test_ghostty_input_events_reset(void); +uint32_t cmux_test_ghostty_input_event_count(void); +int cmux_test_ghostty_input_event_kind(uint32_t index); #endif diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift index f7da57d2d03b..086c1d00a0eb 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift @@ -57,6 +57,21 @@ public struct CmuxSurfaceConfigTemplate: Sendable { /// Creates an empty template (runtime defaults for every field). public init() {} + /// Returns the inherited display/runtime configuration without replaying + /// the source surface's one-shot startup payload. + /// + /// A control-created tab must start an ordinary shell unless its caller + /// supplied an explicit command or input. Replaying `command` or + /// `initialInput` from an agent surface can start a short-lived process as + /// soon as the tab is created, before the caller's first queued send. + public func withoutStartupWork() -> Self { + var copy = self + copy.command = nil + copy.initialInput = nil + copy.waitAfterCommand = false + return copy + } + /// Sets the template font size and records whether it is a surface-local override. /// /// Values outside the persistable base-font range clear the font-size diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceConfigTemplateFontSizeTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceConfigTemplateFontSizeTests.swift index 2df0091d172f..c69fd8003eae 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceConfigTemplateFontSizeTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceConfigTemplateFontSizeTests.swift @@ -161,4 +161,23 @@ import GhosttyKit #expect(basePoints == 0.5) #expect(restoredRuntimePoints == 1) } + + @Test func withoutStartupWorkKeepsDisplayConfiguration() { + var template = CmuxSurfaceConfigTemplate() + template.fontSize = 15 + template.workingDirectory = "/tmp/project" + template.command = "agent --resume" + template.environmentVariables = ["PROJECT": "cmux"] + template.initialInput = "resume\r" + template.waitAfterCommand = true + + let sanitized = template.withoutStartupWork() + + #expect(sanitized.fontSize == template.fontSize) + #expect(sanitized.workingDirectory == template.workingDirectory) + #expect(sanitized.environmentVariables == template.environmentVariables) + #expect(sanitized.command == nil) + #expect(sanitized.initialInput == nil) + #expect(!sanitized.waitAfterCommand) + } } diff --git a/Sources/TerminalController+ControlPaneContext.swift b/Sources/TerminalController+ControlPaneContext.swift index e9e7f561eb24..75faf35644cf 100644 --- a/Sources/TerminalController+ControlPaneContext.swift +++ b/Sources/TerminalController+ControlPaneContext.swift @@ -361,7 +361,8 @@ extension TerminalController: ControlPaneContext { tmuxStartCommand: inputs.tmuxStartCommand, startupEnvironment: inputs.startupEnvironment, initialDividerPosition: initialDividerPosition.map { CGFloat($0) }, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false ) { case .created(let panel): newPanelId = panel.id @@ -567,7 +568,8 @@ extension TerminalController: ControlPaneContext { sourcePlaceholder = workspace.newTerminalSurface( inPane: sourcePane, focus: false, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false )?.id if sourcePlaceholder == nil { return .sourcePlaceholderFailed @@ -577,7 +579,8 @@ extension TerminalController: ControlPaneContext { targetPlaceholder = workspace.newTerminalSurface( inPane: targetPane, focus: false, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false )?.id if targetPlaceholder == nil { return .targetPlaceholderFailed diff --git a/Sources/TerminalController+ControlSidebarContext3.swift b/Sources/TerminalController+ControlSidebarContext3.swift index 2b5981eba29f..5d1d07b83432 100644 --- a/Sources/TerminalController+ControlSidebarContext3.swift +++ b/Sources/TerminalController+ControlSidebarContext3.swift @@ -211,7 +211,8 @@ extension TerminalController { orientation: orientation, insertFirst: insertFirst, focus: focus, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false ) { case .created(let panel): return .created(panel.id) @@ -268,7 +269,8 @@ extension TerminalController { inPane: targetPaneId, focus: focus, inheritWorkingDirectoryFallback: true, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false ) { case .created(let panel): return .created(panel.id) diff --git a/Sources/TerminalController+ControlSurfaceContext2.swift b/Sources/TerminalController+ControlSurfaceContext2.swift index 09922299a2a0..11b78d10dfb1 100644 --- a/Sources/TerminalController+ControlSurfaceContext2.swift +++ b/Sources/TerminalController+ControlSurfaceContext2.swift @@ -217,7 +217,8 @@ extension TerminalController { initialDividerPosition: dividerPosition, remotePTYSessionID: inputs.remotePTYSessionID, suppressWorkspaceRemoteStartupCommand: useLocalContext, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false ) { case .created(let panel): newId = panel.id @@ -507,7 +508,8 @@ extension TerminalController { remotePTYSessionID: inputs.remotePTYSessionID, suppressWorkspaceRemoteStartupCommand: useLocalContext, inheritWorkingDirectoryFallback: true, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false ) { case .created(let panel): newPanelId = panel.id diff --git a/Sources/TerminalController+ControlSystemContext2.swift b/Sources/TerminalController+ControlSystemContext2.swift index 5e7386d55253..9946ad9a0989 100644 --- a/Sources/TerminalController+ControlSystemContext2.swift +++ b/Sources/TerminalController+ControlSystemContext2.swift @@ -241,7 +241,8 @@ extension TerminalController { focus: focus, inheritWorkingDirectoryFallback: true, workingDirectoryFallbackSourcePanelId: panelId, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false ) { case .created(let newPanel): _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 474475659e15..4838ad5f8e01 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13502,7 +13502,8 @@ class TerminalController { from: targetSurface, orientation: direction.orientation, insertFirst: direction.insertFirst, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false ) { case .created(let panel): tab.equalizeSplitsAfterCreatingSplitIfEnabled(newPanelId: panel.id) @@ -16073,7 +16074,8 @@ class TerminalController { autoRefreshMetadata: false, preserveFocusWhenUnfocused: false, inheritWorkingDirectoryFallback: true, - allowTextBoxFocusDefault: false + allowTextBoxFocusDefault: false, + inheritStartupWork: false ) else { return .err(code: "internal_error", message: "Failed to create terminal", data: nil) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 27f64779d774..b2adee43cb76 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9201,7 +9201,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos initialDividerPosition: CGFloat? = nil, remotePTYSessionID: String? = nil, suppressWorkspaceRemoteStartupCommand: Bool = false, - allowTextBoxFocusDefault: Bool = true + allowTextBoxFocusDefault: Bool = true, + inheritStartupWork: Bool = true ) -> TerminalPanel? { return newTerminalSplitOutcome( from: panelId, @@ -9215,7 +9216,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos initialDividerPosition: initialDividerPosition, remotePTYSessionID: remotePTYSessionID, suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, - allowTextBoxFocusDefault: allowTextBoxFocusDefault + allowTextBoxFocusDefault: allowTextBoxFocusDefault, + inheritStartupWork: inheritStartupWork ).panel } @@ -9236,7 +9238,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos remotePTYSessionID: String? = nil, suppressWorkspaceRemoteStartupCommand: Bool = false, allowTextBoxFocusDefault: Bool = true, autoLayout: Bool = false, - honorsGhosttySplitInheritance: Bool = false + honorsGhosttySplitInheritance: Bool = false, + inheritStartupWork: Bool = true ) -> TerminalPanelCreationOutcome { guard !isRetiredFromOwningTabManager else { return .failed } // In a remote tmux mirror workspace a split means "split the mirrored @@ -9292,7 +9295,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos remotePTYSessionID: remotePTYSessionID, suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, allowTextBoxFocusDefault: allowTextBoxFocusDefault, autoLayout: autoLayout, - honorsGhosttySplitInheritance: honorsGhosttySplitInheritance + honorsGhosttySplitInheritance: honorsGhosttySplitInheritance, + inheritStartupWork: inheritStartupWork ) else { return .failed } return .created(panel) } @@ -9310,7 +9314,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos remotePTYSessionID: String?, suppressWorkspaceRemoteStartupCommand: Bool, allowTextBoxFocusDefault: Bool, autoLayout: Bool, - honorsGhosttySplitInheritance: Bool + honorsGhosttySplitInheritance: Bool, + inheritStartupWork: Bool ) -> TerminalPanel? { #if DEBUG let splitTimingStart = ProcessInfo.processInfo.systemUptime @@ -9333,6 +9338,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos guard let paneId = sourcePaneId else { return nil } var inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + if !inheritStartupWork { + inheritedConfig = inheritedConfig?.withoutStartupWork() + } let requestedInitialCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let explicitInitialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil let remoteTerminalStartupCommand = suppressWorkspaceRemoteStartupCommand ? nil : remoteTerminalStartupCommand() @@ -9533,7 +9541,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy = .inherit, inheritWorkingDirectoryFallback: Bool = false, workingDirectoryFallbackSourcePanelId: UUID? = nil, - allowTextBoxFocusDefault: Bool = true + allowTextBoxFocusDefault: Bool = true, + inheritStartupWork: Bool = true ) -> TerminalPanel? { return newTerminalSurfaceOutcome( inPane: paneId, @@ -9554,7 +9563,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos terminalFontSizeCreationPolicy: terminalFontSizeCreationPolicy, inheritWorkingDirectoryFallback: inheritWorkingDirectoryFallback, workingDirectoryFallbackSourcePanelId: workingDirectoryFallbackSourcePanelId, - allowTextBoxFocusDefault: allowTextBoxFocusDefault + allowTextBoxFocusDefault: allowTextBoxFocusDefault, + inheritStartupWork: inheritStartupWork ).panel } @@ -9580,7 +9590,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy = .inherit, inheritWorkingDirectoryFallback: Bool = false, workingDirectoryFallbackSourcePanelId: UUID? = nil, - allowTextBoxFocusDefault: Bool = true + allowTextBoxFocusDefault: Bool = true, + inheritStartupWork: Bool = true ) -> TerminalPanelCreationOutcome { guard !isRetiredFromOwningTabManager else { return .failed } // In a remote tmux mirror, a new tab means "create a tmux window"; never @@ -9647,7 +9658,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos terminalFontSizeCreationPolicy: terminalFontSizeCreationPolicy, inheritWorkingDirectoryFallback: inheritWorkingDirectoryFallback, workingDirectoryFallbackSourcePanelId: workingDirectoryFallbackSourcePanelId, - allowTextBoxFocusDefault: allowTextBoxFocusDefault + allowTextBoxFocusDefault: allowTextBoxFocusDefault, + inheritStartupWork: inheritStartupWork ) else { return .failed } return .created(panel) } @@ -9671,7 +9683,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy, inheritWorkingDirectoryFallback: Bool, workingDirectoryFallbackSourcePanelId: UUID?, - allowTextBoxFocusDefault: Bool + allowTextBoxFocusDefault: Bool, + inheritStartupWork: Bool ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId @@ -9684,6 +9697,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos var inheritedConfig = terminalFontSizeCreationPolicy.applying( to: inheritedTerminalConfig(inPane: paneId) ) + if !inheritStartupWork { + inheritedConfig = inheritedConfig?.withoutStartupWork() + } let requestedInitialCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let explicitInitialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil let remoteTerminalStartupCommand = suppressWorkspaceRemoteStartupCommand ? nil : remoteTerminalStartupCommand() diff --git a/cmuxTests/WorkspaceClosePanelFallbackTests.swift b/cmuxTests/WorkspaceClosePanelFallbackTests.swift index d20094798095..7a0f51a82d73 100644 --- a/cmuxTests/WorkspaceClosePanelFallbackTests.swift +++ b/cmuxTests/WorkspaceClosePanelFallbackTests.swift @@ -14,6 +14,24 @@ import Testing @MainActor @Suite("closePanel unmapped fallback", .serialized) struct WorkspaceClosePanelFallbackTests { + @Test func mappedCloseUsesRequestedPanelWhenAnotherTabIsSelected() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let firstPanelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let otherPanel = try #require(workspace.newTerminalSurface(inPane: paneId, focus: true)) + let otherTabId = try #require(workspace.surfaceIdFromPanelId(otherPanel.id)) + workspace.bonsplitController.selectTab(otherTabId) + + // Closing a background surface must use its own tab mapping, even when + // Bonsplit's selected tab belongs to another surface. + #expect(workspace.closePanel(firstPanelId, force: true)) + #expect(workspace.surfaceIdFromPanelId(firstPanelId) == nil) + #expect(workspace.surfaceIdFromPanelId(otherPanel.id) == otherTabId) + #expect(workspace.panels[firstPanelId] == nil) + #expect(workspace.panels[otherPanel.id] != nil) + } + @Test func fallbackRefusesToCloseSelectedTabOwnedByAnotherPanel() throws { let workspace = Workspace() defer { workspace.teardownAllPanels() } diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index 1425cee669d9..75d88541e160 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -352,4 +352,57 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertNil(panelSnapshot.terminal?.tmuxStartCommand) XCTAssertNil(Workspace.restorableTmuxStartCommand(genericCommand)) } + + func testControlCreatedSurfaceDoesNotReplaySourceStartupCommand() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let source = try XCTUnwrap(workspace.newTerminalSurface( + inPane: paneId, + focus: true, + initialCommand: "sleep 30" + )) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + source.hostedView.frame = contentView.bounds + source.hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(source.hostedView) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + + let deadline = Date().addingTimeInterval(2) + while source.surface.surface == nil, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + XCTAssertNotNil(source.surface.surface, "Expected the source command surface to be live") + + let created = try XCTUnwrap(workspace.newTerminalSurface( + inPane: paneId, + focus: false, + inheritStartupWork: false + )) + + XCTAssertFalse( + created.surface.hasDeferredStartupWorkForBackgroundStart(), + "A control-created tab must not replay the source command before its first send" + ) + + let split = try XCTUnwrap(workspace.newTerminalSplit( + from: source.id, + orientation: .horizontal, + focus: false, + inheritStartupWork: false + )) + XCTAssertFalse( + split.surface.hasDeferredStartupWorkForBackgroundStart(), + "A control-created split must not replay the source command before its first send" + ) + } } From aad835069de78d76a43820c2821248e602bca727 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 13 Oct 2026 23:24:13 -0700 Subject: [PATCH 013/183] Fix OpenCode auto-resume after TUI startup race (#20441) * test(opencode): cover restore event before TUI route * fix(opencode): retry restore binding after TUI startup * fix(opencode): wait for TUI ownership changes * fix(opencode): flush queued starts at event boundaries * fix(opencode): cancel queued starts before session end * fix(opencode): observe reactive TUI readiness * test(opencode): exercise real reactive readiness and disposal * fix(opencode): clear queued startup expiry on disposal --- Resources/opencode-tui-plugin.js | 105 ++++++++++++++++++++++++--- tests/test_opencode_v2_tui_bridge.py | 78 ++++++++++++++++++-- 2 files changed, 164 insertions(+), 19 deletions(-) diff --git a/Resources/opencode-tui-plugin.js b/Resources/opencode-tui-plugin.js index 2d50c0387577..45b0bbca8567 100644 --- a/Resources/opencode-tui-plugin.js +++ b/Resources/opencode-tui-plugin.js @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; +import { createEffect, createRoot } from "solid-js"; import { CMUXFeed } from "./index.js"; const firstString = (...values) => values.find((value) => typeof value === "string" && value.trim())?.trim() || null; @@ -79,12 +80,16 @@ export function sessionBelongsToTUI(ctx, id) { return visibleRoots(ctx).has(rootFor(ctx, id)); } -function createOwnership(ctx) { +function createOwnership(ctx, onChange) { let roots = new Set(); - const refresh = () => { + const refreshRoots = () => { roots = visibleRoots(ctx); }; - refresh(); + const refresh = () => { + refreshRoots(); + onChange?.(); + }; + refreshRoots(); const disposers = []; for (const owner of [ctx?.ui?.router, ctx?.ui?.tabs]) { for (const name of ["onChange", "subscribe", "listen"]) { @@ -102,7 +107,7 @@ function createOwnership(ctx) { // notification. Refresh on the event boundary so a session removed from // a background tab, or a closed starter surface, cannot retain ownership. belongs: (id) => { - refresh(); + refreshRoots(); return Boolean(id && roots.has(rootFor(ctx, id))); }, refresh, @@ -190,10 +195,7 @@ function reportError(ctx, _error) { } catch (_) {} } -async function handleEvent(ctx, ownership, feed, details, environment) { - const event = details?.event || details; - const id = sessionID(event); - if (!id || !ownership.belongs(id)) return; +async function deliverEvent(ctx, feed, event, id, environment) { const hook = sessionEventName(event); if (hook) { dispatchSessionHook(hook, { @@ -208,20 +210,101 @@ async function handleEvent(ctx, ownership, feed, details, environment) { await feed.event({ event, ownedSessionId: id }); } +async function handleEvent(ctx, ownership, feed, details, environment, retrySessionStart) { + const event = details?.event || details; + const id = sessionID(event); + const hook = sessionEventName(event); + if (!id) return; + if (hook === "session-end") retrySessionStart?.cancel(id); + if (!ownership.belongs(id)) { + if (hook === "session-start") retrySessionStart?.enqueue(event, id); + return; + } + if (hook === "session-start") retrySessionStart?.cancel(id); + await deliverEvent(ctx, feed, event, id, environment); +} + +// The expiry bounds memory for sessions that never become visible. It only +// removes stale queue entries; route readiness is observed by Solid below. +const SESSION_START_QUEUE_TTL_MS = 30_000; + export async function createCMUXTUIBridge(ctx, options = {}) { const environment = { ...process.env, ...options.environment }; - const ownership = createOwnership(ctx); - const feed = await CMUXFeed(ctx, { + let disposed = false; + // Keep missed session-start events until the TUI reports a route/tab change. + // The nested map lets session-end and delivery cancel one session in O(1). + const pending = new Map(); + const cancelPending = (id) => { + const queued = pending.get(id); + if (!queued) return; + clearTimeout(queued.expiry); + pending.delete(id); + }; + let feed; + let ownership; + const flushPending = () => { + if (disposed || !feed || !ownership) return; + for (const [id, queued] of pending) { + if (!ownership.belongs(id)) continue; + clearTimeout(queued.expiry); + pending.delete(id); + for (const event of queued.events.values()) { + void handleEvent(ctx, ownership, feed, event, environment, { + cancel: cancelPending, + enqueue: enqueueSessionStart, + }).catch((error) => reportError(ctx, error)); + } + } + }; + ownership = createOwnership(ctx, flushPending); + feed = await CMUXFeed(ctx, { tui: true, ownsSession: ownership.belongs, locationForSession: () => ctx?.location, environment, }); + function enqueueSessionStart(event, id) { + if (disposed) return; + let queued = pending.get(id); + if (!queued) { + queued = { events: new Map(), expiry: undefined }; + queued.expiry = setTimeout(() => { + if (pending.get(id) === queued) pending.delete(id); + }, SESSION_START_QUEUE_TTL_MS); + pending.set(id, queued); + } + if (!queued.events.has(event.type)) queued.events.set(event.type, event); + }; const onEvent = ({ details, event }) => { - void handleEvent(ctx, ownership, feed, details || event, environment).catch((error) => reportError(ctx, error)); + const incoming = details || event; + const incomingID = sessionID(incoming); + if (sessionEventName(incoming) === "session-end") cancelPending(incomingID); + // OpenCode v2 exposes route/tab state as reactive reads rather than a + // stable callback. Re-check queued starts at every event boundary too, so + // a restored route that commits between lifecycle events is admitted + // without a wall-clock retry or a dropped final attempt. + flushPending(); + void handleEvent(ctx, ownership, feed, incoming, environment, { + cancel: cancelPending, + enqueue: enqueueSessionStart, + }).catch((error) => reportError(ctx, error)); }; const stop = ctx.data.listen(onEvent); + const disposeReactive = createRoot((dispose) => { + createEffect(() => { + // current() and list() are reactive reads in OpenCode v2. A route/tab + // commit therefore flushes queued lifecycle events even when idle. + ownership.refresh(); + flushPending(); + }); + return dispose; + }); + flushPending(); return () => { + disposed = true; + disposeReactive?.(); + for (const queued of pending.values()) clearTimeout(queued.expiry); + pending.clear(); stop?.(); feed.dispose?.(); ownership.dispose(); diff --git a/tests/test_opencode_v2_tui_bridge.py b/tests/test_opencode_v2_tui_bridge.py index 7f8f98fe2140..b67adc629b68 100755 --- a/tests/test_opencode_v2_tui_bridge.py +++ b/tests/test_opencode_v2_tui_bridge.py @@ -18,12 +18,16 @@ def main() -> int: print("SKIP: node not found") return 0 fixture = json.loads((ROOT / "tests/fixtures/opencode-v2-multi-tui.json").read_text()) + if not (ROOT / "node_modules/solid-js/package.json").is_file(): + print("FAIL: run bun install --frozen-lockfile before the OpenCode bridge test") + return 1 source = r''' const { fixture, packageDir } = JSON.parse(process.env.CMUX_OPENCODE_V2_HARNESS); const fs = await import("node:fs/promises"); const net = await import("node:net"); const path = await import("node:path"); const source = await fs.readFile(path.join(packageDir, "tui.js"), "utf8"); +const { batch, createSignal } = await import("solid-js"); const mod = await import(path.join(packageDir, "tui.js")); const serverMod = await import(path.join(packageDir, "index.js")); if (typeof serverMod.default?.server !== "function") throw new Error("missing inert V2 server entrypoint"); @@ -115,7 +119,12 @@ def main() -> int: const makeLive = (name, environment, replies) => { const live = makeContext(name); live.location = { directory: `/tmp/${name}` }; - live.ui.router.onChange = (refresh) => { live.refresh = refresh; return () => {}; }; + const [route, setRoute] = createSignal(live.ui.router.current()); + const [tabs, setTabs] = createSignal(live.ui.tabs.list()); + live.ui.router.current = route; + live.ui.tabs.list = tabs; + live.setRoute = setRoute; + live.setTabs = setTabs; live.data.listen = (callback) => { live.emit = callback; return () => {}; }; live.client = { permission: { reply: async (value) => { replies.permission?.resolve(value); } }, @@ -145,8 +154,46 @@ def main() -> int: const cleanupA = await mod.createCMUXTUIBridge(liveA, { environment: liveA.environment }); const cleanupB = await mod.createCMUXTUIBridge(liveB, { environment: liveB.environment }); -liveA.emit({ details: { type: "session.created", data: { sessionID: "child-a", location: { directory: "/tmp/a" } }, location: { directory: "/tmp/a" } } }); -await new Promise((resolve) => setImmediate(resolve)); +// OpenCode can publish the restored session before the TUI has committed its +// route/tab state. The session-start event must survive that startup race so +// cmux can record the binding used by the next workspace restore. +const routeA = liveA.ui.router.current(); +const tabsA = liveA.ui.tabs.list(); +liveA.setRoute({ type: "home" }); +liveA.setTabs([]); +liveA.emit({ details: { type: "session.created", data: { sessionID: "child-a", location: { directory: "/tmp/a" } } } }); +// No subsequent service event or invented route callback: only a real Solid +// signal publishes readiness for an otherwise idle restored session. +liveA.setRoute(routeA); +await Promise.race([ + waitForObserved((event) => event.hook_event_name === "SessionStart" && event.session_id === "opencode-child-a"), + new Promise((_, reject) => setTimeout(() => reject(new Error("idle restored session-start was dropped at route readiness")), 2000)), +]); +liveA.setTabs(tabsA); + +// A non-start service event can arrive inside the same batch as the route +// commit, before Solid runs its observer. It must preserve the queued start. +liveA.setRoute({ type: "home" }); +liveA.setTabs([]); +liveA.emit({ details: { type: "session.created", data: { sessionID: "child-status" } } }); +batch(() => { + liveA.setRoute({ type: "session", sessionID: "child-status" }); + liveA.emit({ details: { type: "session.status", data: { sessionID: "child-status", status: { type: "busy" } } } }); +}); +await waitForObserved((event) => event.hook_event_name === "SessionStart" && event.session_id === "opencode-child-status"); + +// Ending the queued session in that batch must cancel it before the observer +// gets a chance to flush it. Assert the normalized ID sent to the actual socket. +liveA.setRoute({ type: "home" }); +liveA.emit({ details: { type: "session.created", data: { sessionID: "child-stale" } } }); +batch(() => { + liveA.setRoute({ type: "session", sessionID: "child-stale" }); + liveA.emit({ details: { type: "session.deleted", data: { sessionID: "child-stale" } } }); +}); +await waitForObserved((event) => event.hook_event_name === "SessionEnd" && event.session_id === "opencode-child-stale"); +if (observed.some((event) => event.session_id === "opencode-child-stale" && event.hook_event_name === "SessionStart")) throw new Error("ended queued session-start was replayed"); +liveA.setRoute(routeA); +liveA.setTabs(tabsA); liveB.emit({ details: { type: "session.execution.succeeded", data: { sessionID: "child-b" } } }); liveA.emit({ details: { type: "permission.asked", data: { sessionID: "child-a", id: "perm-a", action: "edit", resources: ["/tmp/a/file"] } } }); liveB.emit({ details: { type: "permission.asked", data: { sessionID: "child-a", id: "perm-wrong", action: "edit" } } }); @@ -192,7 +239,7 @@ def main() -> int: holdNextResponse = true; liveA.emit({ details: { type: "form.created", data: { form: { sessionID: "child-a", id: "form-route", fields: [{ key: "choice", type: "string", options: [{ value: "yes-value", label: "yes" }] }] } } } }); await waitForObserved((event) => event._opencode_request_id === "form-route"); -liveA.ui.router.current = () => fixture.starterClosed.route; +liveA.setRoute(fixture.starterClosed.route); delayedResponses.get("form-route")?.(); const routeForm = await Promise.race([repliesA.routeForm.promise, new Promise((_, reject) => setTimeout(() => reject(new Error("route-change form reply timed out")), 2000))]); if (routeForm.value.formID !== "form-route" || routeForm.value.answer.choice !== "yes-value") throw new Error("resolved TUI reply was dropped after route change"); @@ -213,14 +260,27 @@ def main() -> int: if (stop?.surface_id !== "surface-b" || stop?.workspace_id !== "workspace-b") throw new Error("second TUI Feed event was routed to the first surface"); const prompt = observed.find((event) => event.hook_event_name === "UserPromptSubmit"); if (prompt?.tool_input?.prompt !== "hello from v2" || prompt?.context?.lastUserMessage !== "hello from v2") throw new Error("V2 inbox prompt was dropped from Feed context"); -const sessionEnd = observed.find((event) => event.hook_event_name === "SessionEnd"); +const sessionEnd = observed.find((event) => event.hook_event_name === "SessionEnd" && event.session_id === "opencode-child-b"); if (!sessionEnd) { - await waitForObserved((event) => event.hook_event_name === "SessionEnd"); + await waitForObserved((event) => event.hook_event_name === "SessionEnd" && event.session_id === "opencode-child-b"); } -if (observed.find((event) => event.hook_event_name === "SessionEnd")?.surface_id !== "surface-b") throw new Error("archived session was not ended on its owning TUI"); +if (observed.find((event) => event.hook_event_name === "SessionEnd" && event.session_id === "opencode-child-b")?.surface_id !== "surface-b") throw new Error("archived session was not ended on its owning TUI"); if (observed.some((event) => event._opencode_request_id === "perm-wrong")) throw new Error("TUI B accepted a session owned by TUI A"); +// A bridge that never owns a queued session must release its expiry timer on +// disposal, rather than retaining the entire TUI context until the TTL elapses. +const expiryTimers = new Set(); +const nativeSetTimeout = globalThis.setTimeout; +const nativeClearTimeout = globalThis.clearTimeout; +globalThis.setTimeout = (...args) => { const timer = nativeSetTimeout(...args); expiryTimers.add(timer); return timer; }; +globalThis.clearTimeout = (timer) => { expiryTimers.delete(timer); nativeClearTimeout(timer); }; +liveA.emit({ details: { type: "session.created", data: { sessionID: "never-owned" } } }); cleanupA(); +const leakedTimers = expiryTimers.size; +for (const timer of expiryTimers) nativeClearTimeout(timer); +globalThis.setTimeout = nativeSetTimeout; +globalThis.clearTimeout = nativeClearTimeout; +if (leakedTimers) throw new Error("disposed TUI retained pending session-start timers"); cleanupB(); server.closeAllConnections?.(); await new Promise((resolve) => server.close(resolve)); @@ -230,9 +290,11 @@ def main() -> int: package_dir = Path(package) (package_dir / "index.js").write_text((ROOT / "Resources/opencode-plugin.js").read_text()) (package_dir / "tui.js").write_text((ROOT / "Resources/opencode-tui-plugin.js").read_text()) + (package_dir / "node_modules").symlink_to(ROOT / "node_modules", target_is_directory=True) + (package_dir / "harness.mjs").write_text(source) env = os.environ.copy() env["CMUX_OPENCODE_V2_HARNESS"] = json.dumps({"packageDir": str(package_dir), "fixture": fixture}) - result = subprocess.run([node, "--input-type=module", "-e", source], cwd=ROOT, env=env, text=True, capture_output=True, check=False, timeout=20) + result = subprocess.run([node, "--conditions=browser", str(package_dir / "harness.mjs")], cwd=ROOT, env=env, text=True, capture_output=True, check=False, timeout=20) if result.returncode: print("FAIL: OpenCode V2 TUI bridge harness") print(result.stdout) From c77b533b4bb2c67f88633767f34b44962ef3243e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 00:42:37 -0700 Subject: [PATCH 014/183] Fix main's red app-host shards and intermittent CI failures (#20459) * Keep a stale Codex record from resuming the current binding #20246 made a restore treat the agent as running when the session index was not authoritative, so a cached quit index could not mark a live session stopped. It read authority from isComplete(), which for Codex is also false whenever the panel's recorded checkpoint fails verification. A fresh index holding only a rejected stale record then left wasAgentRunning nil, and restore replayed the dead current binding's resume command. Base authority on whether the kind's hook store was observed, which is what #20246 meant; per-panel checkpoint gaps no longer count. Co-Authored-By: Claude Opus 5.5 * Fix the Muse resume and minimum-contrast render tests The Muse vault test expected an unquoted resume command, but registered agents have quoted each argument and prefixed a cd since August; assert the exact command as the sibling vault tests do. The minimum-contrast test never rendered on Blacksmith runners: the app-host process is not the active app there, so its window is never key and never reported visible, and the renderer stays released. Let it opt into a window that reports on-screen occlusion, as the simulator theme tests already do. Co-Authored-By: Claude Opus 5.5 * Fix four intermittent main CI failures - cmuxd-remote: a failed async attach write queued its error and then cancelled the connection, and dispatch could observe the cancellation first and return context.Canceled. Report the queued error instead. - ws_pty_test: macOS sh can print a job-control warning between the SIZE marker and stty's output; write both with one printf. - Repeated pastes: four jobs share one FIFO whose deadline starts at admission, so a slow runner expired the fourth. The test checks payload fidelity, so its deadline ends only on cancellation. - Unread sidebar test: allow WorkspaceContentView re-evaluations caused by unrelated Workspace publishes in the measured window, counted directly. Co-Authored-By: Claude Opus 5.5 * Name what keeps a closed window's manager alive during a Cloud create This test fails on every main run since Cloud was enabled for it (#19988), and reading the code did not find the owner. Report whether the shared controller or app state holds it, and whether it is freed after the create completes or after those are reset, so the next run names the holder. Co-Authored-By: Claude Opus 5.5 * Keep never-inspected Codex evidence non-authoritative at quit Review follow-up: the previous commit made every per-panel Codex gap authoritative, including records outside the bounded verification pass or whose verification was unavailable. Those can really miss a live session. Track them separately, and let only an inspected, rejected checkpoint count as observed. Co-Authored-By: Claude Opus 5.5 * Build Claude project fixtures with Claude's real slug The fixtures named a Claude project directory by replacing only / and ., but Claude (and ClaudeProjectSlug) replaces every non-alphanumeric scalar. On runners whose temp path holds an underscore the fixture directory was one Claude never creates, so the transcript-derived cwd fell back to the launch cwd and claudeRestorableIndexMapsNestedTranscriptPathToProjectCwd failed. Share one independent slug helper across the four test files. Co-Authored-By: Claude Opus 5.5 * Check the closed manager after the close's own deferred work The Cloud create path holds the manager only weakly. The test sampled it in the same main-actor job as commitMainWindowClose, while the close's autoreleased notifications (object: the manager) and queued main-queue work still held it, so it read as leaked until the next turn. Drain the close in an autorelease pool and wait one main-queue turn, with the create still parked, before checking. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Sources/DockSplitStore+SessionSnapshot.swift | 2 +- Sources/RestorableAgentSession.swift | 54 +++++++++++++++++++ Sources/Workspace.swift | 2 +- .../AgentSessionAutoResumeSwiftTests.swift | 3 +- ...MUXCLISessionsListTranscriptPidTests.swift | 8 +-- .../CloudWorkspaceCreationRevealTests.swift | 25 ++++++++- ...ompletedRestoredAgentGenerationTests.swift | 3 +- ...yMinimumContrastLayerBackgroundTests.swift | 2 +- cmuxTests/MuseVaultRegistrationTests.swift | 6 ++- .../RestorableAgentSessionIndexTests.swift | 20 ++++--- cmuxTests/ScrollbackTestTerminal.swift | 19 ++++++- ...nsferConcurrencyTests+RepeatedPastes.swift | 8 ++- ...ageTransferConcurrencyTests+Timeouts.swift | 8 ++- .../WorkspaceContentViewVisibilityTests.swift | 15 ++++-- daemon/remote/cmd/cmuxd-remote/main.go | 14 ++++- daemon/remote/cmd/cmuxd-remote/ws_pty_test.go | 3 +- 16 files changed, 158 insertions(+), 34 deletions(-) diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index 2e87b0ff7ad6..a32420e81887 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -66,7 +66,7 @@ extension DockSplitStore { processPresenceProvider: agentProcessPresence, revalidateProcessEvidence: false ), - observationIsAuthoritative: restorableAgentIndex?.isComplete( + observationIsAuthoritative: restorableAgentIndex?.observesEveryLiveSession( forPanelId: panelId, kind: surfaceResumeBindingsByPanelId[panelId]?.kind ) ?? true, diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 089db351e08b..e53c5ae5596a 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1178,6 +1178,11 @@ struct RestorableAgentSessionIndex: Sendable { /// verification pass or had inconclusive durable evidence. private let incompleteCodexPanelKeys: Set private let incompleteCodexPanelIds: Set + /// The subset of `incompleteCodexPanelKeys` whose evidence was never + /// inspected (outside the bounded pass, or unavailable), as opposed to a + /// checkpoint that was inspected and rejected. + private let inconclusiveCodexPanelKeys: Set + private let inconclusiveCodexPanelIds: Set /// Panel owners that completed the bounded Codex verification pass. private let verifiedCodexPanelKeys: Set private let verifiedCodexPanelIds: Set @@ -1260,6 +1265,41 @@ struct RestorableAgentSessionIndex: Sendable { return !hasUnboundedCodexIncompleteness || verifiedCodexPanelIds.contains(panelId) } + /// Whether this index's absence of a `kind` session in a panel is + /// evidence that it stopped. + /// + /// Like `isComplete`, except that a Codex checkpoint that was inspected + /// and rejected still counts as observed: a stale record must not vouch + /// for the panel's current binding. A store that was not read, a cached + /// copy (`markingIncomplete()`, #20186), and Codex evidence that was never + /// inspected all stay unobserved. + func observesEveryLiveSession( + forWorkspaceId workspaceId: UUID, + panelId: UUID, + kind: String? + ) -> Bool { + guard hookStoreIsComplete(forKind: kind) else { return false } + guard Self.isCodexOrUnspecified(kind) else { return true } + let key = PanelKey(workspaceId: workspaceId, panelId: panelId) + if inconclusiveCodexPanelKeys.contains(key) { return false } + if incompleteCodexPanelKeys.contains(key) { return true } + return !hasUnboundedCodexIncompleteness || verifiedCodexPanelKeys.contains(key) + } + + /// `observesEveryLiveSession(forWorkspaceId:panelId:kind:)` for a + /// restart-stable panel identity without its workspace. + func observesEveryLiveSession(forPanelId panelId: UUID, kind: String?) -> Bool { + guard hookStoreIsComplete(forKind: kind) else { return false } + guard Self.isCodexOrUnspecified(kind) else { return true } + if inconclusiveCodexPanelIds.contains(panelId) { return false } + if incompleteCodexPanelIds.contains(panelId) { return true } + return !hasUnboundedCodexIncompleteness || verifiedCodexPanelIds.contains(panelId) + } + + private static func isCodexOrUnspecified(_ kind: String?) -> Bool { + kind == nil || kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "codex" + } + /// Whether the hook store that owns `kind` was read and decoded. /// /// Callers without a kind, and fixtures that mark the whole index @@ -1289,6 +1329,7 @@ struct RestorableAgentSessionIndex: Sendable { isComplete: false, incompleteHookStoreKinds: [], incompleteCodexPanelKeys: incompleteCodexPanelKeys, + inconclusiveCodexPanelKeys: inconclusiveCodexPanelKeys, verifiedCodexPanelKeys: verifiedCodexPanelKeys, hasUnboundedCodexIncompleteness: hasUnboundedCodexIncompleteness ) @@ -1744,6 +1785,7 @@ struct RestorableAgentSessionIndex: Sendable { isComplete: self.isComplete, incompleteHookStoreKinds: self.incompleteHookStoreKinds, incompleteCodexPanelKeys: self.incompleteCodexPanelKeys, + inconclusiveCodexPanelKeys: self.inconclusiveCodexPanelKeys, verifiedCodexPanelKeys: self.verifiedCodexPanelKeys, hasUnboundedCodexIncompleteness: self.hasUnboundedCodexIncompleteness ) @@ -1818,6 +1860,7 @@ struct RestorableAgentSessionIndex: Sendable { var codexRequestKeys = Set() var codexRequestCount = 0 var incompleteCodexPanelKeys = Set() + var inconclusiveCodexPanelKeys = Set() var verifiedCodexPanelKeys = Set() var hasUnboundedCodexIncompleteness = false var codexIndexedStoreByHome: [String: Bool] = [:] @@ -1995,6 +2038,7 @@ struct RestorableAgentSessionIndex: Sendable { } if selectedPanelKeys.contains(panelKey) { incompleteCodexPanelKeys.insert(panelKey) + inconclusiveCodexPanelKeys.insert(panelKey) } else { hasUnboundedCodexIncompleteness = true } @@ -2015,6 +2059,7 @@ struct RestorableAgentSessionIndex: Sendable { guard let key = codexVerificationKey(for: record) else { if let panelKey = codexPanelKey(for: rawRecord) { incompleteCodexPanelKeys.insert(panelKey) + inconclusiveCodexPanelKeys.insert(panelKey) } continue } @@ -2022,6 +2067,7 @@ struct RestorableAgentSessionIndex: Sendable { guard codexRequestCount < CodexSessionResumeVerificationLimits.maximumBatchRequests else { if let panelKey = codexPanelKey(for: rawRecord) { incompleteCodexPanelKeys.insert(panelKey) + inconclusiveCodexPanelKeys.insert(panelKey) } continue } @@ -2130,6 +2176,7 @@ struct RestorableAgentSessionIndex: Sendable { // hit a transient read limit) leaves this panel // inconclusive, but must not poison unrelated owners. incompleteCodexPanelKeys.insert(panelKey) + inconclusiveCodexPanelKeys.insert(panelKey) } let codexOwnerIsAdmitted = hookRecordIsRestorable( effectiveRecord, @@ -2474,6 +2521,7 @@ struct RestorableAgentSessionIndex: Sendable { isComplete: incompleteHookStoreKinds.isEmpty, incompleteHookStoreKinds: incompleteHookStoreKinds, incompleteCodexPanelKeys: incompleteCodexPanelKeys, + inconclusiveCodexPanelKeys: inconclusiveCodexPanelKeys, verifiedCodexPanelKeys: verifiedCodexPanelKeys, hasUnboundedCodexIncompleteness: hasUnboundedCodexIncompleteness ) @@ -3692,6 +3740,7 @@ struct RestorableAgentSessionIndex: Sendable { isComplete: Bool = true, incompleteHookStoreKinds: Set = [], incompleteCodexPanelKeys: Set = [], + inconclusiveCodexPanelKeys: Set? = nil, verifiedCodexPanelKeys: Set = [], hasUnboundedCodexIncompleteness: Bool = false ) { @@ -3701,6 +3750,11 @@ struct RestorableAgentSessionIndex: Sendable { self.incompleteHookStoreKinds = incompleteHookStoreKinds self.incompleteCodexPanelKeys = incompleteCodexPanelKeys self.incompleteCodexPanelIds = Set(incompleteCodexPanelKeys.map(\.panelId)) + // Callers that do not distinguish the two treat every gap as + // inconclusive, the conservative reading. + let inconclusive = inconclusiveCodexPanelKeys ?? incompleteCodexPanelKeys + self.inconclusiveCodexPanelKeys = inconclusive + self.inconclusiveCodexPanelIds = Set(inconclusive.map(\.panelId)) self.verifiedCodexPanelKeys = verifiedCodexPanelKeys self.verifiedCodexPanelIds = Set(verifiedCodexPanelKeys.map(\.panelId)) self.hasUnboundedCodexIncompleteness = hasUnboundedCodexIncompleteness diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b2adee43cb76..c1d789cb3e92 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -618,7 +618,7 @@ extension Workspace { resumeBinding: resumeBinding, terminal: terminalPanel, observation: restorableAgentObservation, - observationIsAuthoritative: restorableAgentIndex?.isComplete( + observationIsAuthoritative: restorableAgentIndex?.observesEveryLiveSession( forWorkspaceId: id, panelId: panelId, kind: resumeBinding?.kind ?? effectiveRestorableAgent?.kind.rawValue diff --git a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift index c39f95c728e5..c7a571d0f97e 100644 --- a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift @@ -1764,8 +1764,7 @@ struct AgentSessionAutoResumeSwiftTests { } private func expectedClaudeProjectDirName(_ path: String) -> String { - path.replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: ".", with: "-") + expectedClaudeProjectSlug(path) } private func writeClaudeTranscript(sessionId: String, transcriptURL: URL) throws { diff --git a/cmuxTests/CMUXCLISessionsListTranscriptPidTests.swift b/cmuxTests/CMUXCLISessionsListTranscriptPidTests.swift index f296870af956..1fab31e04d8d 100644 --- a/cmuxTests/CMUXCLISessionsListTranscriptPidTests.swift +++ b/cmuxTests/CMUXCLISessionsListTranscriptPidTests.swift @@ -144,9 +144,7 @@ extension CMUXCLIErrorOutputRegressionTests { defer { try? FileManager.default.removeItem(at: root) } let sessionId = "claude-config-lookup-session" - let projectDirName = repoDir.path - .replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: ".", with: "-") + let projectDirName = expectedClaudeProjectSlug(repoDir.path) let projectDir = claudeConfigDir .appendingPathComponent("projects", isDirectory: true) .appendingPathComponent(projectDirName, isDirectory: true) @@ -227,9 +225,7 @@ extension CMUXCLIErrorOutputRegressionTests { let containerSessionId = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa" let siblingSessionIds = ["bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb", "cccccccc-3333-3333-3333-cccccccccccc"] - let projectDirName = repoDir.path - .replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: ".", with: "-") + let projectDirName = expectedClaudeProjectSlug(repoDir.path) let projectDir = claudeConfigDir .appendingPathComponent("projects", isDirectory: true) .appendingPathComponent(projectDirName, isDirectory: true) diff --git a/cmuxTests/CloudWorkspaceCreationRevealTests.swift b/cmuxTests/CloudWorkspaceCreationRevealTests.swift index f077bd2a841c..321e0c1a05a4 100644 --- a/cmuxTests/CloudWorkspaceCreationRevealTests.swift +++ b/cmuxTests/CloudWorkspaceCreationRevealTests.swift @@ -269,11 +269,32 @@ struct CloudWorkspaceCreationRevealTests { : app.performNewCloudWorkspaceOnCurrentMachineAction(tabManager: manager, vmID: "a")) for await _ in entered.stream { break } #expect(SurfaceCatalog.shared.cloudWorkspaceCreationCoordinator.reveals.reveal(for: manager) != nil) - #expect(app.commitMainWindowClose(window)) + autoreleasepool { #expect(app.commitMainWindowClose(window)) } } - #expect(closed == nil, "A create in flight must not keep a closed window's manager alive") + // The create is still parked on `release`. Let the close's own + // autoreleased notifications and deferred main-queue work finish + // first, so a manager alive after this has a real owner. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + // Name the holder when this fails: the create task, the shared + // controller/app state, or something that outlives both. + let leakedWhileInFlight = closed != nil + let heldByController = closed != nil + && TerminalController.shared.activeTabManagerForCallerNotification() === closed + let heldAsActive = closed != nil && AppDelegate.shared?.tabManager === closed release.continuation.yield(()) await operations.waitForPendingOperations() + let leakedAfterCompletion = closed != nil + TerminalController.shared.setActiveTabManager(nil) + AppDelegate.shared = previousAppDelegate + let leakedAfterSharedReset = closed != nil + #expect( + !leakedWhileInFlight, + Comment(rawValue: "A create in flight must not keep a closed window's manager alive: " + + "controller=\(heldByController) active=\(heldAsActive) " + + "afterCompletion=\(leakedAfterCompletion) afterSharedReset=\(leakedAfterSharedReset)") + ) } @Test("Device ⌘N reveals the workspace it selects, unless the user navigated first", diff --git a/cmuxTests/CompletedRestoredAgentGenerationTests.swift b/cmuxTests/CompletedRestoredAgentGenerationTests.swift index 092d617901bf..84ac3b89407c 100644 --- a/cmuxTests/CompletedRestoredAgentGenerationTests.swift +++ b/cmuxTests/CompletedRestoredAgentGenerationTests.swift @@ -345,8 +345,7 @@ struct CompletedRestoredAgentGenerationTests { } private func expectedClaudeProjectDirectoryName(_ path: String) -> String { - path.replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: ".", with: "-") + expectedClaudeProjectSlug(path) } private func writeClaudeTranscript(sessionId: String, transcriptURL: URL) throws { diff --git a/cmuxTests/GhosttyMinimumContrastLayerBackgroundTests.swift b/cmuxTests/GhosttyMinimumContrastLayerBackgroundTests.swift index 75ec446af360..e63f5278db48 100644 --- a/cmuxTests/GhosttyMinimumContrastLayerBackgroundTests.swift +++ b/cmuxTests/GhosttyMinimumContrastLayerBackgroundTests.swift @@ -42,7 +42,7 @@ struct GhosttyMinimumContrastLayerBackgroundTests { ghostty_config_finalize(config) defer { ghostty_config_free(config) } - let terminal = try ScrollbackTestTerminal() + let terminal = try ScrollbackTestTerminal(pinsOnScreenOcclusion: true) defer { terminal.close() } try await terminal.launch() let runtime = try #require(terminal.surface.surface) diff --git a/cmuxTests/MuseVaultRegistrationTests.swift b/cmuxTests/MuseVaultRegistrationTests.swift index 95bd5e34c5c9..f8cb2d905594 100644 --- a/cmuxTests/MuseVaultRegistrationTests.swift +++ b/cmuxTests/MuseVaultRegistrationTests.swift @@ -57,8 +57,10 @@ struct MuseVaultRegistrationTests { #expect(entry.cwd == "/work/muse repo") #expect(entry.gitBranch == "main") let resume = try #require(entry.copyResumeCommand) - #expect(resume.contains("muse resume 01a131ce-3556-7142-a0c8-c366cf09cfa5")) - #expect(resume.contains("/work/muse repo")) + #expect( + resume + == "cd -- '/work/muse repo' 2>/dev/null || [ ! -d '/work/muse repo' ] && 'muse' 'resume' '01a131ce-3556-7142-a0c8-c366cf09cfa5'" + ) } private func exec(_ dbURL: URL, _ sql: String) throws { diff --git a/cmuxTests/RestorableAgentSessionIndexTests.swift b/cmuxTests/RestorableAgentSessionIndexTests.swift index f45e75d254e9..1c378c5d05c7 100644 --- a/cmuxTests/RestorableAgentSessionIndexTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexTests.swift @@ -1409,12 +1409,7 @@ struct RestorableAgentSessionIndexTests { /// independently of the production `encodeClaudeProjectDir`, so these regression tests fail if /// that helper regresses instead of masking it by sharing the same code path. private func expectedClaudeProjectDirName(_ path: String) -> String { - String(path.unicodeScalars.map { scalar -> Character in - switch scalar { - case "a"..."z", "A"..."Z", "0"..."9": return Character(scalar) - default: return "-" - } - }) + expectedClaudeProjectSlug(path) } @Test @@ -2052,3 +2047,16 @@ struct RestorableAgentSessionIndexTests { ) } } + +/// Claude Code's project-directory name for `path`, written independently of +/// production code: every non-ASCII-alphanumeric scalar becomes `-`. Runner +/// temp paths can contain `_`, so a fixture that only replaces `/` and `.` +/// builds a directory Claude never creates. +func expectedClaudeProjectSlug(_ path: String) -> String { + String(path.unicodeScalars.map { scalar -> Character in + switch scalar { + case "a"..."z", "A"..."Z", "0"..."9": return Character(scalar) + default: return "-" + } + }) +} diff --git a/cmuxTests/ScrollbackTestTerminal.swift b/cmuxTests/ScrollbackTestTerminal.swift index d10b4d179945..347ba5b6492a 100644 --- a/cmuxTests/ScrollbackTestTerminal.swift +++ b/cmuxTests/ScrollbackTestTerminal.swift @@ -20,7 +20,15 @@ final class ScrollbackTestTerminal { /// `ioMode: .manual` also delivers parser-generated replies (DSR, CSI 996) /// to the input stream; `.manualMirror` suppresses them. - init(initialInput: String? = nil, ioMode: TerminalSurfaceIOMode = .manualMirror) throws { + /// `pinsOnScreenOcclusion` reports `.visible` occlusion so the renderer + /// presents even when the headless app-host process is not the active + /// app: the window server then never sets `.visible` and the window never + /// becomes key. + init( + initialInput: String? = nil, + ioMode: TerminalSurfaceIOMode = .manualMirror, + pinsOnScreenOcclusion: Bool = false + ) throws { _ = NSApplication.shared let pair = AsyncStream.makeStream() inputs = pair.stream @@ -36,7 +44,8 @@ final class ScrollbackTestTerminal { manualInputHandler: { sink.yield($0) } ) view = surface.hostedView.surfaceView - window = NSWindow( + let windowClass: NSWindow.Type = pinsOnScreenOcclusion ? OnScreenScrollbackTestWindow.self : NSWindow.self + window = windowClass.init( contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled, .closable], backing: .buffered, @@ -194,3 +203,9 @@ final class ScrollbackTestTerminal { return bytes } } + +/// Reports the occlusion state an on-screen window has in the running app; +/// see `OnScreenTestWindow` in SimulatorPanelThemeTests.swift. +private final class OnScreenScrollbackTestWindow: NSWindow { + override var occlusionState: NSWindow.OcclusionState { .visible } +} diff --git a/cmuxTests/TerminalImageTransferConcurrencyTests+RepeatedPastes.swift b/cmuxTests/TerminalImageTransferConcurrencyTests+RepeatedPastes.swift index f1aef7eba0d5..73347ae7b67e 100644 --- a/cmuxTests/TerminalImageTransferConcurrencyTests+RepeatedPastes.swift +++ b/cmuxTests/TerminalImageTransferConcurrencyTests+RepeatedPastes.swift @@ -43,7 +43,13 @@ extension TerminalImageTransferConcurrencyTests { #expect(pasteboard.setString("", forType: .html)) } let generation = pasteboard.changeCount - let service = makeLivePreparationService() + // Payload fidelity, not deadlines: four jobs share one FIFO whose + // queued deadline starts at admission, so a slow runner expired the + // fourth. Here a deadline ends only when the service cancels it. + let service = makeLivePreparationService(deadlineSleep: { _ in + for await _ in AsyncStream { _ in } {} + throw CancellationError() + }) let request = TerminalPasteboardReadRequest(pasteboard: pasteboard) let results = await withTaskGroup(of: TerminalImageTransferPreparedContent.self) { group in for _ in 0..<4 { diff --git a/cmuxTests/TerminalImageTransferConcurrencyTests+Timeouts.swift b/cmuxTests/TerminalImageTransferConcurrencyTests+Timeouts.swift index 6b5ab46d1bf5..0cd436e27fbe 100644 --- a/cmuxTests/TerminalImageTransferConcurrencyTests+Timeouts.swift +++ b/cmuxTests/TerminalImageTransferConcurrencyTests+Timeouts.swift @@ -265,10 +265,14 @@ extension TerminalImageTransferConcurrencyTests { } @MainActor - func makeLivePreparationService() - -> TerminalImageTransferPreparationService { + func makeLivePreparationService( + deadlineSleep: @escaping TerminalImageTransferPreparationService.DeadlineSleep = { duration in + try await ContinuousClock().sleep(for: duration) + } + ) -> TerminalImageTransferPreparationService { let pasteboardService = GhosttyApp.terminalPasteboard return TerminalImageTransferPreparationService( + deadlineSleep: deadlineSleep, operation: { request in let client = TerminalPastePreparationWorkerClient .reexecingCurrentBinary( diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 01913928e315..7a88070ec442 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -347,11 +347,12 @@ final class WorkspaceContentViewVisibilityTests { // re-evaluates VerticalTabsSidebar for a reason unrelated to unread // state, so the count below depended on how fast the runner was. let tabManager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) - let workspaceId = tabManager.addWorkspace( + let selectedWorkspace = tabManager.addWorkspace( initialSurface: .cloudVMLoading, select: true, autoWelcomeIfNeeded: false - ).id + ) + let workspaceId = selectedWorkspace.id let unaffectedWorkspace = tabManager.addWorkspace( initialSurface: .cloudVMLoading, select: false, @@ -428,6 +429,14 @@ final class WorkspaceContentViewVisibilityTests { unaffectedApplyCount += 1 } counts.reset() + // SidebarUnreadModel cannot publish a Workspace, so a Workspace publish + // in this window is unrelated settling (main run 38492427482 logged + // "WorkspaceContentView: _workspace changed." here). + var unrelatedWorkspacePublishes = 0 + let workspacePublishes = selectedWorkspace.objectWillChange.sink { _ in + unrelatedWorkspacePublishes += 1 + } + defer { workspacePublishes.cancel() } counts.isMeasuringInvalidations = true defer { counts.isMeasuringInvalidations = false } @@ -453,7 +462,7 @@ final class WorkspaceContentViewVisibilityTests { "Unread changes must update their leaf consumers without rebuilding ContentView." ) #expect( - counts.workspaceContentBody == 0, + counts.workspaceContentBody <= unrelatedWorkspacePublishes, "Unread changes must not rebuild terminal or browser content." ) #expect( diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index a0b14b2a6109..0fcc69b065b4 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -170,7 +170,7 @@ func (d *rpcRequestDispatcher) dispatch(req rpcRequest) error { select { case <-d.ctx.Done(): - return d.ctx.Err() + return d.connectionError() case d.ptyAttachSlots <- struct{}{}: default: return d.server.frameWriter.writeResponse(rpcResponse{ @@ -217,10 +217,20 @@ func (d *rpcRequestDispatcher) dispatch(req rpcRequest) error { case <-reservationReady: return nil case <-d.ctx.Done(): - return d.ctx.Err() + return d.connectionError() } } +// connectionError reports the async failure that cancelled the connection +// (queued on asyncErrors before cancelConnection) rather than the bare +// cancellation, which a select can observe first. +func (d *rpcRequestDispatcher) connectionError() error { + if err := d.takeAsyncError(); err != nil { + return err + } + return d.ctx.Err() +} + func (d *rpcRequestDispatcher) registerPTYAttach(req rpcRequest) (context.Context, *rpcPTYAttachRequest, bool) { ctx, cancel := context.WithCancel(d.ctx) request := &rpcPTYAttachRequest{cancel: cancel} diff --git a/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go b/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go index a705b0f52bf9..1494b713f93a 100644 --- a/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go +++ b/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go @@ -1963,7 +1963,8 @@ func TestWebSocketPTYReplacedAttachmentCannotWriteInput(t *testing.T) { waitForHubSessionSize(t, hub, "sess-replace", 1, 90, 30, 5*time.Second) waitForHubPTYSize(t, hub, "sess-replace", 90, 30, 5*time.Second) - if err := newConn.Write(ctx, websocket.MessageBinary, []byte("printf 'SIZE:'; stty size; printf '%b\\n' '\\106\\122\\105\\123\\110\\137\\111\\116\\120\\125\\124'; exit\r")); err != nil { + // One builtin write: shell job-control warnings on stderr cannot split the marker. + if err := newConn.Write(ctx, websocket.MessageBinary, []byte("s=$(stty size); printf 'SIZE:%s\\n' \"$s\"; printf '%b\\n' '\\106\\122\\105\\123\\110\\137\\111\\116\\120\\125\\124'; exit\r")); err != nil { t.Fatalf("write fresh command: %v", err) } output := waitForBinaryContains(t, ctx, newConn, "FRESH_INPUT", 5*time.Second) From bc23feb4826ff2636393bc921bd5afbffc40e784 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 00:47:05 -0700 Subject: [PATCH 015/183] Trust cmux's own Codex hooks so Codex stops asking to review them (#20482) * style(cmux-tui): apply rustfmt to session journal backfill main fails the cmux-tui cargo fmt check here, which stops hosted verification before any test runs. Co-Authored-By: Claude Opus 5.5 * test: reproduce untrusted cmux Codex hooks Codex applies -c overrides by splitting the key on every '.', so the session-flag trust that cmux-tui and the ssh-tmux helper pass as hooks.state."//config.toml:...".trusted_hash never lands and every injected hook needs review. The wrapper test now parses -c the way Codex does. Also cover a cmux-tui reinstall moving its hook group behind the cmux app's group in a shared hooks.json, which shifts the app's hook off its trusted key. Co-Authored-By: Claude Opus 5.5 * test: key Codex hook trust by the default home verbatim Co-Authored-By: Claude Opus 5.5 * fix: trust every cmux Codex hook Codex sees cmux-tui and the ssh-tmux helper passed session-flag hook trust as -c hooks.state."".trusted_hash. Codex splits -c keys on every '.', including inside the quotes, and each key contains one (config.toml, hooks.json), so none of that trust or the matching enabled=false applied. Codex listed each event twice and asked the user to review the injected copy. Pass the whole state as one inline table at hooks.state, which Codex merges with the persistent trust. A cmux-tui reinstall also dropped its hook group and appended a new one, moving it behind hooks another producer (the cmux app) added later and shifting that hook off its trusted positional key. Reinsert the group in the slot it came from. The cmux CLI keyed trust by the fully resolved hooks.json path, but Codex resolves only an explicit CODEX_HOME and uses $HOME/.codex verbatim, so a symlinked home left every app hook untrusted. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CLI/CMUXCLI+AgentHookDefinitions.swift | 10 ++ CLI/cmux.swift | 24 ++++- Resources/bin/cmux-tmux-agent.py | 6 +- .../src/workspace_registry/session_journal.rs | 10 +- .../crates/cmux-tui/src/agent_hook_install.rs | 94 ++++++++++++++++--- .../crates/cmux-tui/tests/codex_wrapper.rs | 31 ++++-- .../CLIGenericHookPersistenceTests.swift | 38 ++++++++ .../tests/test_remote_tmux_agent_helper.py | 21 +++++ 8 files changed, 206 insertions(+), 28 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index c57b47e3d63f..28ede8052799 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -105,6 +105,16 @@ extension CMUXCLI { case codexConfigToml // write hooks = true to config.toml on install, remove on uninstall } + /// Whether `resolvedConfigDir()` comes from a non-empty env override. + var resolvesConfigDirFromEnvOverride: Bool { + guard configDirResolver == nil, + let envKey = configDirEnvOverride, + let value = ProcessInfo.processInfo.environment[envKey] else { + return false + } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + /// Resolves the config directory, respecting env override if set. func resolvedConfigDir() -> String { if let configDirResolver { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 15636bddca43..7d03ccdccd95 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -37814,7 +37814,7 @@ export default { isCmuxOwnedHookCommand(command, for: def, includeLegacy: includeLegacyOwnedCommands) } var entries: [CodexHookTrustEntry] = [] - let keySource = codexNormalizedHookSourcePath(hooksFilePath) + let keySource = codexHookTrustKeySourcePath(hooksFilePath, def: def) for eventName in codexHookEventNames { guard let groups = hooks[eventName] as? [[String: Any]], @@ -37852,7 +37852,12 @@ export default { def: AgentHookDef ) -> Set { guard def.name == "codex" else { return [] } - return ["\(codexNormalizedHookSourcePath(hooksFilePath)):"] + // Earlier installs keyed trust by the fully resolved path; keep + // clearing those stale entries from the cmux-owned block. + return [ + "\(codexHookTrustKeySourcePath(hooksFilePath, def: def)):", + "\(codexNormalizedHookSourcePath(hooksFilePath)):", + ] } private static func codexLegacyHookTrustHashes(def: AgentHookDef) -> Set { @@ -37913,6 +37918,21 @@ export default { return hashes } + /// The hooks.json path exactly as Codex embeds it in trust keys. Codex + /// canonicalizes an explicit `CODEX_HOME` directory and joins `hooks.json` + /// to it, but uses `$HOME/.codex` verbatim (codex-rs/utils/home-dir + /// `find_codex_home`), so a symlinked default home must not be resolved. + private static func codexHookTrustKeySourcePath(_ path: String, def: AgentHookDef) -> String { + guard def.resolvesConfigDirFromEnvOverride else { return path } + let url = URL(fileURLWithPath: path) + guard let resolvedParent = realPath(url.deletingLastPathComponent().path) else { + return path + } + return URL(fileURLWithPath: resolvedParent, isDirectory: true) + .appendingPathComponent(url.lastPathComponent) + .path + } + private static func codexNormalizedHookSourcePath(_ path: String) -> String { let url = URL(fileURLWithPath: path).standardizedFileURL if let resolved = realPath(url.path) { diff --git a/Resources/bin/cmux-tmux-agent.py b/Resources/bin/cmux-tmux-agent.py index be75933acd2d..214d207417a4 100644 --- a/Resources/bin/cmux-tmux-agent.py +++ b/Resources/bin/cmux-tmux-agent.py @@ -119,6 +119,9 @@ def launch(agent, args): tmux_call(context, 'set-option', '-p', '-t', pane, '@cmux_agent_owner', context['owner']) if agent == 'codex': injected = ['--enable', 'hooks'] + # Codex splits a -c key on every '.', even inside quotes, so the + # trust entries go in one inline table at hooks.state. + trust = [] for event, (label, state) in EVENTS.items(): command = hook_command(context, state) timeout = 3 if event == 'SessionEnd' else 5 @@ -127,7 +130,8 @@ def launch(agent, args): handler = '{hooks=[{type="command",command=' + json.dumps(command, ensure_ascii=False) + ',timeout=' + str(timeout) + '}]}' injected += ['-c', 'hooks.' + event + '=[' + handler + ']'] key = '//config.toml:' + label + ':0:0' - injected += ['-c', 'hooks.state.' + json.dumps(key) + '.trusted_hash="sha256:' + digest + '"'] + trust.append(json.dumps(key) + '={trusted_hash="sha256:' + digest + '"}') + injected += ['-c', 'hooks.state={' + ','.join(trust) + '}'] args = hoist_codex_globals(args) else: hooks = {event: [{'hooks': [{'type': 'command', 'command': hook_command(context, state), 'timeout': 5}]}] for event, (_, state) in EVENTS.items()} diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs index e68e3bbe932d..253062d0e53e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs @@ -1583,9 +1583,7 @@ fn backfill_legacy_journal_segment_kinds(connection: &Connection) -> anyhow::Res ) ORDER BY start_sequence ASC", )?; - statement - .query_map([], |row| row.get::<_, String>(0))? - .collect::, _>>()? + statement.query_map([], |row| row.get::<_, String>(0))?.collect::, _>>()? }; for segment_id in ids { let segment = load_journal_segment(connection, &segment_id)?; @@ -1601,10 +1599,8 @@ fn backfill_legacy_journal_segment_kinds(connection: &Connection) -> anyhow::Res )?; } } - connection.execute( - "UPDATE meta SET value = '1' WHERE key = 'journal_segment_kinds_backfill'", - [], - )?; + connection + .execute("UPDATE meta SET value = '1' WHERE key = 'journal_segment_kinds_backfill'", [])?; Ok(()) } diff --git a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs index ca1b6781a30b..308be2dc316c 100644 --- a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs +++ b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs @@ -1645,9 +1645,16 @@ fn rewrite_json_hooks( ) -> anyhow::Result<()> { let hooks = root.entry("hooks").or_insert_with(|| json!({})); let hooks = hooks.as_object_mut().context("agent hooks field must be a JSON object")?; - for entries in hooks.values_mut() { + // Where each event's first fully-owned group was, so a reinstall puts our + // group back in that slot instead of behind every other producer's: codex + // keys hook trust by position, and moving ours shifts theirs off their + // trusted keys. + let mut owned_slots = BTreeMap::new(); + for (event, entries) in hooks.iter_mut() { if nested { - rewrite_nested_entries(entries)?; + if let Some(slot) = rewrite_nested_entries(entries)? { + owned_slots.insert(event.clone(), slot); + } } else { rewrite_flat_entries(entries)?; } @@ -1670,20 +1677,27 @@ fn rewrite_json_hooks( json!({"command":command,"timeout":timeout}) }; hooks.entry(*event).or_insert_with(|| json!([])); - hooks + let entries = hooks .get_mut(*event) .and_then(Value::as_array_mut) - .with_context(|| format!("hook event {event:?} must be an array"))? - .push(entry); + .with_context(|| format!("hook event {event:?} must be an array"))?; + match owned_slots.get(*event) { + Some(slot) if *slot <= entries.len() => entries.insert(*slot, entry), + _ => entries.push(entry), + } } Ok(()) } -fn rewrite_nested_entries(value: &mut Value) -> anyhow::Result<()> { +/// Removes the owned commands from every group, dropping groups left empty. +/// Returns the position, among the kept groups, of the first group that held +/// only owned commands. +fn rewrite_nested_entries(value: &mut Value) -> anyhow::Result> { let Some(groups) = value.as_array_mut() else { - return Ok(()); + return Ok(None); }; let mut rewritten = Vec::with_capacity(groups.len()); + let mut owned_slot = None; for mut group in std::mem::take(groups) { let Some(group_object) = group.as_object_mut() else { rewritten.push(group); @@ -1693,13 +1707,16 @@ fn rewrite_nested_entries(value: &mut Value) -> anyhow::Result<()> { rewritten.push(group); continue; }; + let was_empty = commands.is_empty(); rewrite_command_entries(commands); if !commands.is_empty() { rewritten.push(group); + } else if !was_empty && owned_slot.is_none() { + owned_slot = Some(rewritten.len()); } } *groups = rewritten; - Ok(()) + Ok(owned_slot) } fn rewrite_flat_entries(value: &mut Value) -> anyhow::Result<()> { @@ -1942,6 +1959,12 @@ fn codex_contextual_hook_command( /// commands generated here, leaving all other hooks' trust decisions intact. /// Codex appends hooks from lower layers, so disable the installed cmux copies /// for this invocation instead of sending each event twice. +/// +/// Codex splits a `-c` key on every `.`, even inside TOML quotes +/// (codex-rs/config/src/overrides.rs `apply_toml_override`), and every state +/// key contains one (`config.toml`, `hooks.json`). The whole state therefore +/// goes in one inline-table value at `hooks.state`, which Codex merges with +/// the persistent config's trust instead of replacing it. #[cfg(unix)] pub(crate) fn codex_session_hook_settings( socket: &Path, @@ -1965,8 +1988,11 @@ fn codex_session_hook_settings_with_context( let root = read_json_object(&path)?; let key_path = codex_state_key_hooks_path(&path, context.provider_path_from_env_override(provider)); + let mut state = toml_edit::InlineTable::new(); for key in codex_owned_entry_keys(&key_path, &root) { - settings.push(format!("hooks.state.{}.enabled=false", toml_edit::Key::new(key))); + let mut entry = toml_edit::InlineTable::new(); + entry.insert("enabled", false.into()); + state.insert(&key, entry.into()); } for event in CODEX_EVENTS { let command = codex_contextual_hook_command(event, socket, terminal, launcher); @@ -1977,9 +2003,11 @@ fn codex_session_hook_settings_with_context( "hooks.{event}=[{{hooks=[{{type=\"command\",command={},timeout={timeout}}}]}}]", toml_edit::Value::from(command), )); - let key = toml_edit::Key::new(format!("//config.toml:{label}:0:0")); - settings.push(format!("hooks.state.{key}.trusted_hash={}", toml_edit::Value::from(hash))); + let mut entry = toml_edit::InlineTable::new(); + entry.insert("trusted_hash", hash.into()); + state.insert(&format!("//config.toml:{label}:0:0"), entry.into()); } + settings.push(format!("hooks.state={state}")); Ok(settings) } @@ -3123,6 +3151,50 @@ mod tests { assert_eq!(result.value["providers"][0]["state"], "installed", "{}", result.value); } + #[test] + fn codex_reinstall_keeps_a_later_producers_hook_and_trust_in_place() { + // The cmux app writes its own Codex hooks into the same hooks.json + // after ours, trusting them at their positions. Reinstalling must not + // move our group behind theirs: that shifts their hook to a key with + // no trust and overwrites their key with our hash, so Codex asks the + // user to review one cmux hook per event. + let tmp = tempfile::tempdir().unwrap(); + let context = context(tmp.path()); + let install = Plan { action: Action::Install, providers: vec!["codex".into()] }; + assert!(!run_with_context(&install, &context).failed); + let hooks_path = context.home.join(".codex/hooks.json"); + let app_command = "/Users/me/.cmux/hooks/cmux-codex-hook-persistent-stop-0123.sh"; + let mut hooks: Value = serde_json::from_slice(&fs::read(&hooks_path).unwrap()).unwrap(); + hooks["hooks"]["Stop"] + .as_array_mut() + .unwrap() + .push(json!({"hooks": [{"type": "command", "command": app_command, "timeout": 10}]})); + atomic_write(&hooks_path, &serde_json::to_vec_pretty(&hooks).unwrap(), Some(0o600)) + .unwrap(); + let app_key = format!("{}:stop:1:0", hooks_path.display()); + let app_hash = codex_trust_hash("stop", app_command, 10); + let config_path = context.home.join(".codex/config.toml"); + let mut config = fs::read_to_string(&config_path).unwrap(); + config.push_str(&format!("\n[hooks.state.\"{app_key}\"]\ntrusted_hash = \"{app_hash}\"\n")); + fs::write(&config_path, config).unwrap(); + + assert!(!run_with_context(&install, &context).failed); + + let hooks: Value = serde_json::from_slice(&fs::read(&hooks_path).unwrap()).unwrap(); + assert_eq!(hooks["hooks"]["Stop"][1]["hooks"][0]["command"], app_command, "{hooks}"); + assert_eq!(codex_owned_hook_position(hooks.as_object().unwrap(), "Stop"), Some((0, 0))); + let state = codex_state_table(&context); + let trusted = |key: &str| { + state.get(key).and_then(|entry| entry.get("trusted_hash")).and_then(toml::Value::as_str) + }; + assert_eq!(trusted(&app_key), Some(app_hash.as_str()), "{state:?}"); + assert_eq!( + trusted(&format!("{}:stop:0:0", hooks_path.display())), + Some(codex_trust_hash("stop", &hook_command("codex", "Stop"), 5).as_str()), + "{state:?}" + ); + } + #[test] fn codex_status_detects_a_user_edited_hook_entry() { let root = tempfile::tempdir().unwrap(); diff --git a/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs index 07fa1d932b0e..fdad0c149a06 100644 --- a/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs +++ b/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs @@ -85,14 +85,31 @@ impl Launch { } } +/// Applies the `-c` overrides exactly as Codex does (codex-rs/utils/cli +/// `parse_overrides` plus codex-rs/config `apply_toml_override`): the key is +/// everything before the first `=` and is split on EVERY `.`, ignoring TOML +/// quoting, and each override replaces the value at its path. Parsing the +/// overrides as a TOML document instead would honor quoted keys that Codex +/// splits, hiding settings Codex never applies. fn settings(argv: &[String]) -> toml::Value { - let text = argv - .windows(2) - .filter(|pair| pair[0] == "-c") - .map(|pair| pair[1].as_str()) - .collect::>() - .join("\n"); - toml::from_str(&text).unwrap() + let mut root = toml::Value::Table(toml::Table::new()); + for pair in argv.windows(2).filter(|pair| pair[0] == "-c") { + let (path, raw) = pair[1].split_once('=').unwrap(); + let value = toml::from_str::(&format!("_x_ = {}", raw.trim())) + .unwrap_or_else(|error| panic!("Codex cannot parse override {:?}: {error}", pair[1])) + .remove("_x_") + .unwrap(); + let segments: Vec<&str> = path.trim().split('.').collect(); + let (last, parents) = segments.split_last().unwrap(); + let mut current = &mut root; + for segment in parents { + let table = current.as_table_mut().unwrap(); + current = + table.entry(*segment).or_insert_with(|| toml::Value::Table(toml::Table::new())); + } + current.as_table_mut().unwrap().insert((*last).to_owned(), value); + } + root } fn hook_command(config: &toml::Value, event: &str) -> String { diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index ed3257034023..09a65efd1692 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -1870,6 +1870,44 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(customEntries.compactMap { $0["command"] as? String }, [userCustomCommand]) } + func testCodexHookTrustKeysUseTheDefaultCodexHomeVerbatim() throws { + // Codex canonicalizes an explicit CODEX_HOME for its hook trust keys + // but uses $HOME/.codex verbatim. Keying by the resolved path leaves + // every cmux hook untrusted when the home is reached through a symlink. + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-hook-trust-\(UUID().uuidString)", isDirectory: true) + let realHome = root.appendingPathComponent("real-home", isDirectory: true) + let linkedHome = root.appendingPathComponent("linked-home", isDirectory: true) + try FileManager.default.createDirectory( + at: realHome.appendingPathComponent(".codex", isDirectory: true), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(at: linkedHome, withDestinationURL: realHome) + defer { try? FileManager.default.removeItem(at: root) } + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "install", "--yes"], + environment: [ + "HOME": linkedHome.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + timeout: 10 + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let config = try String( + contentsOf: realHome.appendingPathComponent(".codex/config.toml"), + encoding: .utf8 + ) + let codexKeyPrefix = "[hooks.state.\"\(linkedHome.path)/.codex/hooks.json:stop:" + XCTAssertTrue(config.contains(codexKeyPrefix), config) + XCTAssertFalse(config.contains(realHome.resolvingSymlinksInPath().path + "/.codex/hooks.json"), config) + } + func testHermesAgentSessionEndIsTurnBoundaryButFinalizeTearsDown() throws { // Hermes fires the `on_session_end` plugin hook once per conversation turn // (end of every run_conversation()), not at the true session boundary, and a diff --git a/scripts/tests/test_remote_tmux_agent_helper.py b/scripts/tests/test_remote_tmux_agent_helper.py index 2826ab28e9a4..afc0b4e6dee1 100644 --- a/scripts/tests/test_remote_tmux_agent_helper.py +++ b/scripts/tests/test_remote_tmux_agent_helper.py @@ -15,6 +15,16 @@ HELPER = ROOT / "Resources/bin/cmux-tmux-agent.py" +def codex_override_paths(argv): + """Key paths of the -c overrides as Codex applies them: the key is split + on every '.', ignoring TOML quoting.""" + return [ + (setting.split("=", 1)[0].strip().split("."), setting.split("=", 1)[1]) + for flag, setting in zip(argv, argv[1:]) + if flag == "-c" + ] + + class RemoteTmuxAgentHelperTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() @@ -98,6 +108,17 @@ def test_codex_launch_embeds_exact_pane_route_and_trust(self): self.assertEqual(argv[0:2], ["--enable", "hooks"]) self.assertIn("hooks.SessionStart", " ".join(argv)) self.assertIn("//config.toml:session_start:0:0", " ".join(argv)) + # Every trust entry must land under hooks.state with its whole key: + # a dotted override such as hooks.state."//config.toml:..." + # is split inside the quotes and never trusts the hook. + state = [value for path, value in codex_override_paths(argv) if path[:2] == ["hooks", "state"]] + self.assertEqual(len(state), 1, state) + self.assertEqual( + [path for path, _ in codex_override_paths(argv) if path[:2] == ["hooks", "state"]], + [["hooks", "state"]], + ) + for label in ("session_start", "user_prompt_submit", "stop"): + self.assertIn(f'"//config.toml:{label}:0:0"={{trusted_hash="sha256:', state[0]) self.assertEqual(argv[-2:], ["exec", "hello"]) calls = self._tmux_calls() self.assertTrue(any("@cmux_agent_owner" in call for call in calls)) From 2f08b3ef2e2ca43527cc99bfcb635c4c2879621c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:03:19 -0700 Subject: [PATCH 016/183] fix: show the Cloud welcome only once (#20243) * test: Cloud creation actions must not repeat the welcome * fix: show the Cloud welcome only once across entry points * fix: preserve shared-team Cloud welcome routing --- Sources/AppDelegate+NewCloudWorkspace.swift | 4 +- .../Cloud/CloudWelcomeWindowController.swift | 100 ++++++++++++++--- Sources/Debug/CloudWelcomeDebugMenu.swift | 6 +- cmux.xcodeproj/project.pbxproj | 4 + .../CloudCmdYActivationRoutingTests.swift | 7 ++ cmuxTests/CloudWelcomePresentationTests.swift | 103 ++++++++++++++++++ cmuxTests/CloudWelcomeTests.swift | 44 ++++---- cmuxTests/CloudWorkspaceTargetingTests.swift | 7 ++ .../NewCloudWorkspaceShortcutTests.swift | 16 ++- 9 files changed, 244 insertions(+), 47 deletions(-) create mode 100644 cmuxTests/CloudWelcomePresentationTests.swift diff --git a/Sources/AppDelegate+NewCloudWorkspace.swift b/Sources/AppDelegate+NewCloudWorkspace.swift index da87c35e3982..67e75349b409 100644 --- a/Sources/AppDelegate+NewCloudWorkspace.swift +++ b/Sources/AppDelegate+NewCloudWorkspace.swift @@ -246,8 +246,8 @@ extension AppDelegate { } /// Cloud creation shortcuts and menu rows introduce Cloud while it is off - /// or signed out, instead of doing nothing. The welcome card's own next - /// step decides between Enable and Upgrade; Enable then resumes `resume`. + /// or signed out. Returning users skip the card and take its next step; + /// Enable then resumes `resume`. private func introduceCloudIfNeeded( over window: NSWindow?, resume: @escaping @MainActor () -> Void diff --git a/Sources/Cloud/CloudWelcomeWindowController.swift b/Sources/Cloud/CloudWelcomeWindowController.swift index 9f829915845d..50e994d8bcef 100644 --- a/Sources/Cloud/CloudWelcomeWindowController.swift +++ b/Sources/Cloud/CloudWelcomeWindowController.swift @@ -34,11 +34,8 @@ enum CloudCreationGate: Equatable { /// can reopen it from Help. @MainActor final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { - /// The welcome is a release announcement, rather than a permanent prompt. - /// The persisted marker holds this campaign id, so a user sees the campaign - /// once across all of its releases, and a later campaign with a new id is - /// shown once without bringing back one a user already dismissed. The id is - /// the release that first shipped the campaign, which is what 0.65.1 wrote. + /// Retain the shipped marker value; any recorded version means the user + /// has seen the welcome, even if the release campaign changes later. nonisolated static let campaignVersion = "0.65.1" /// App versions that automatically show this campaign. Add a release here /// to extend the campaign; keep ``campaignVersion`` so earlier viewers are @@ -46,6 +43,31 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { nonisolated static let campaignAppVersions: Set = ["0.65.1", "0.65.2"] nonisolated static let seenVersionDefaultsKey = "cmux.cloud.welcome.seenVersion" + private let defaults: UserDefaults + private let nextStep: @MainActor () -> CloudWelcomeNextStep + private let performNextStep: @MainActor (CloudWelcomeNextStep, (@MainActor () -> Void)?) -> Void + + init( + defaults: UserDefaults = .standard, + nextStep: @escaping @MainActor () -> CloudWelcomeNextStep = { + let account = AppDelegate.shared?.auth?.accountFlow + return CloudWelcomeNextStep.resolve( + isAuthenticated: account?.isAuthenticated == true, + isPlanKnown: account?.hasLoadedBillingPlan == true, + isPro: account?.isProActive == true, + hasCloudTeamAccess: CloudWelcomeNextStep.hasSharedTeamAccess( + availableTeamCount: account?.availableTeams.count ?? 0 + ) + ) + }, + performNextStep: @escaping @MainActor (CloudWelcomeNextStep, (@MainActor () -> Void)?) -> Void = CloudWelcomeWindowController.perform + ) { + self.defaults = defaults + self.nextStep = nextStep + self.performNextStep = performNextStep + super.init() + } + private var window: NSWindow? /// The Cloud action that opened this card, run again once Enable succeeds. private var onEnabled: (@MainActor () -> Void)? @@ -68,10 +90,14 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { ) -> Bool { guard !isDebugBuild, !isRunningUnderXCTest, !isUITestMode else { return false } return campaignAppVersions.contains(appVersion) - && defaults.string(forKey: seenVersionDefaultsKey) != campaignVersion + && !hasSeenWelcome(in: defaults) && !policy.isEnforced(.disableCloud) } + nonisolated static func hasSeenWelcome(in defaults: UserDefaults) -> Bool { + defaults.string(forKey: seenVersionDefaultsKey) != nil + } + /// Records that this campaign's announcement has been considered, on any of /// its releases. The caller writes this before presenting so a crash or /// quit cannot replay it. @@ -81,7 +107,7 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { /// Presents at launch when it applies, and marks it seen on the way so a /// quit or crash while it is open does not show it again. - func presentIfNeeded(over parent: NSWindow?, defaults: UserDefaults = .standard) { + func presentIfNeeded(over parent: NSWindow?) { let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" let environment = ProcessInfo.processInfo.environment #if DEBUG @@ -90,21 +116,19 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { let isDebugBuild = false #endif guard claimLaunchPresentation( - defaults: defaults, appVersion: appVersion, policy: ManagedDevicePolicy(), isDebugBuild: isDebugBuild, isRunningUnderXCTest: MacSentryStartupPolicy.isRunningUnderXCTest(environment: environment), isUITestMode: environment["CMUX_UI_TEST_MODE"] == "1" ) else { return } - present(over: parent) + show(over: parent) } /// Claims the release announcement synchronously before creating its window. /// Overlapping main-window callbacks and a later launch share the persisted /// marker; an ineligible first window still consumes this launch's decision. func claimLaunchPresentation( - defaults: UserDefaults, appVersion: String, policy: ManagedDevicePolicy, isDebugBuild: Bool = false, @@ -121,13 +145,52 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { isRunningUnderXCTest: isRunningUnderXCTest, isUITestMode: isUITestMode ) else { return false } + return claimPresentation() + } + + /// Every normal presentation claims the same persisted marker. A direct + /// Cloud action from a returning user continues at the card's next step. + func present(over parent: NSWindow?, onEnabled: (@MainActor () -> Void)? = nil) { + guard claimPresentation() else { + continueToNextStep(nextStep(), onEnabled: onEnabled) + return + } + show(over: parent, onEnabled: onEnabled) + } + + private func claimPresentation() -> Bool { + guard !Self.hasSeenWelcome(in: defaults) else { return false } Self.markCampaignSeen(in: defaults) return true } - /// Help deliberately reopens the same headline card without consuming or - /// resetting the automatic release announcement's persistence marker. - func present( + #if DEBUG + /// Debug previews deliberately bypass the gate, but still count as seen. + func presentForDebug( + over parent: NSWindow?, + posterShowsTitle: Bool = true, + posterShowsIcon: Bool = true, + showsNewBadge: Bool = false, + footerHasNotNow: Bool = false + ) { + Self.markCampaignSeen(in: defaults) + show( + over: parent, + posterShowsTitle: posterShowsTitle, + posterShowsIcon: posterShowsIcon, + showsNewBadge: showsNewBadge, + footerHasNotNow: footerHasNotNow + ) + } + + func resetForDebug() { + dismiss() + defaults.removeObject(forKey: Self.seenVersionDefaultsKey) + didConsiderLaunchPresentation = false + } + #endif + + private func show( over parent: NSWindow?, sliderShowsFeatureList: Bool = true, sliderListUsesDots: Bool = false, @@ -176,7 +239,9 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { showsNewBadge: showsNewBadge, footerHasNotNow: footerHasNotNow, onNotNow: { [weak self] in self?.dismiss() }, - onNext: { [weak self] step in self?.perform(step) } + onNext: { [weak self] step in + self?.continueToNextStep(step, onEnabled: self?.onEnabled) + } ) let hosting = NSHostingView(rootView: rootView) // The content clears the traffic lights with its own top padding, so it @@ -251,9 +316,12 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { window.setFrameOrigin(origin) } - private func perform(_ step: CloudWelcomeNextStep) { - let onEnabled = self.onEnabled + private func continueToNextStep(_ step: CloudWelcomeNextStep, onEnabled: (@MainActor () -> Void)?) { dismiss() + performNextStep(step, onEnabled) + } + + private static func perform(_ step: CloudWelcomeNextStep, onEnabled: (@MainActor () -> Void)?) { let app = AppDelegate.shared switch step { case .signIn: diff --git a/Sources/Debug/CloudWelcomeDebugMenu.swift b/Sources/Debug/CloudWelcomeDebugMenu.swift index 1b55dbab9c8f..0de33872d288 100644 --- a/Sources/Debug/CloudWelcomeDebugMenu.swift +++ b/Sources/Debug/CloudWelcomeDebugMenu.swift @@ -7,10 +7,10 @@ extension cmuxApp { @ViewBuilder var cloudWelcomeDebugMenuItems: some View { Button(String(localized: "debug.menu.showCloudWelcome", defaultValue: "Show Cloud Welcome…")) { - AppDelegate.shared?.cloudWelcomeWindowController.present(over: NSApp.mainWindow) + AppDelegate.shared?.cloudWelcomeWindowController.presentForDebug(over: NSApp.mainWindow) } Button(String(localized: "debug.menu.showCloudWelcomeFBadge", defaultValue: "Show Cloud Welcome (F, New Badge)…")) { - AppDelegate.shared?.cloudWelcomeWindowController.present( + AppDelegate.shared?.cloudWelcomeWindowController.presentForDebug( over: NSApp.mainWindow, posterShowsTitle: false, showsNewBadge: true, @@ -18,7 +18,7 @@ extension cmuxApp { ) } Button(String(localized: "debug.menu.showCloudWelcomeFOnClip", defaultValue: "Show Cloud Welcome (F, On Clip)…")) { - AppDelegate.shared?.cloudWelcomeWindowController.present( + AppDelegate.shared?.cloudWelcomeWindowController.presentForDebug( over: NSApp.mainWindow, posterShowsIcon: false, footerHasNotNow: true diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c8a71b607237..04dc1577e11d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1124,6 +1124,7 @@ 3B7661AE47EE36CB4BEBEEED /* CloudWelcomeDebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883EC42C240445DBCE78E8A7 /* CloudWelcomeDebugMenu.swift */; }; 22D165EB062C603B0C3A0A82 /* CloudWelcomeHero.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA8C3407967FE40B15A41A3 /* CloudWelcomeHero.swift */; }; 6CDA457FF93E33F097EB4F7C /* CloudWelcomeMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66CDAC0E0F1B791CA9260310 /* CloudWelcomeMedia.swift */; }; + 8C04C58CA7FA77072602DAAA /* CloudWelcomePresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220FD07E397B72CAE8025EC7 /* CloudWelcomePresentationTests.swift */; }; 22EABB05BDD5DD6B52A4320C /* CloudWelcomeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6A733B8AA5EAFC5C30907F /* CloudWelcomeTests.swift */; }; C0F314FC0BDC236EF220A9E3 /* CloudWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C632B1DEF42152DBA995A37 /* CloudWelcomeView.swift */; }; D0158861231945076F7C049D /* CloudWelcomeWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09A9B78125D188EEE10DEC4 /* CloudWelcomeWindowController.swift */; }; @@ -5808,6 +5809,7 @@ 883EC42C240445DBCE78E8A7 /* CloudWelcomeDebugMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug/CloudWelcomeDebugMenu.swift; sourceTree = ""; }; 3CA8C3407967FE40B15A41A3 /* CloudWelcomeHero.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWelcomeHero.swift; sourceTree = ""; }; 66CDAC0E0F1B791CA9260310 /* CloudWelcomeMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWelcomeMedia.swift; sourceTree = ""; }; + 220FD07E397B72CAE8025EC7 /* CloudWelcomePresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudWelcomePresentationTests.swift"; sourceTree = ""; }; 6E6A733B8AA5EAFC5C30907F /* CloudWelcomeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudWelcomeTests.swift"; sourceTree = ""; }; 6C632B1DEF42152DBA995A37 /* CloudWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWelcomeView.swift; sourceTree = ""; }; E09A9B78125D188EEE10DEC4 /* CloudWelcomeWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWelcomeWindowController.swift; sourceTree = ""; }; @@ -13838,6 +13840,7 @@ 6363F4EA9220ADE6FA369B16 /* CLIMuseHookInstallTests.swift */, 80B2BBAC8B90CA286F4B2BF7 /* MainWindowHitTestMutationMonitorTests.swift */, DB2534FB1BD7FDBD3A7474EE /* CloudManualMirrorHandshakeTests.swift */, + 220FD07E397B72CAE8025EC7 /* CloudWelcomePresentationTests.swift */, DA688992ADFE3696B36EEC3D /* CloudStaleInventoryMaterializationTests.swift */, ); path = cmuxTests; @@ -18154,6 +18157,7 @@ 2D7F228001C24ABD865AFD66 /* CloudVMStateSnapshotComparisonTests.swift in Sources */, C12093000000000000000002 /* CloudVPNExplicitActivationTests.swift in Sources */, 5189C042A99401477E4DE49A /* CloudVPNSetupTests.swift in Sources */, + 8C04C58CA7FA77072602DAAA /* CloudWelcomePresentationTests.swift in Sources */, 22EABB05BDD5DD6B52A4320C /* CloudWelcomeTests.swift in Sources */, 62AABF1720674956200EC388 /* CloudWireGuardHubTests.swift in Sources */, D12439010000000000000001 /* CloudWorkspaceBindingRegressionTests.swift in Sources */, diff --git a/cmuxTests/CloudCmdYActivationRoutingTests.swift b/cmuxTests/CloudCmdYActivationRoutingTests.swift index 9ae99ac430a9..4e385cb1ea0d 100644 --- a/cmuxTests/CloudCmdYActivationRoutingTests.swift +++ b/cmuxTests/CloudCmdYActivationRoutingTests.swift @@ -15,6 +15,13 @@ import Testing struct CloudCmdYActivationRoutingTests { @Test("Cloud activation-off opens the Cloud welcome") func commandShiftYOpensCloudWelcomeBeforeProvisioning() { + let seenKey = CloudWelcomeWindowController.seenVersionDefaultsKey + let previousSeen = UserDefaults.standard.object(forKey: seenKey) + UserDefaults.standard.removeObject(forKey: seenKey) + defer { + if let previousSeen { UserDefaults.standard.set(previousSeen, forKey: seenKey) } + else { UserDefaults.standard.removeObject(forKey: seenKey) } + } let key = RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey let previousMarker = UserDefaults.standard.object(forKey: key) let previousDelegate = AppDelegate.shared diff --git a/cmuxTests/CloudWelcomePresentationTests.swift b/cmuxTests/CloudWelcomePresentationTests.swift new file mode 100644 index 000000000000..d2d836d94a89 --- /dev/null +++ b/cmuxTests/CloudWelcomePresentationTests.swift @@ -0,0 +1,103 @@ +import AppKit +import CmuxSettings +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Cloud welcome presentation gate", .serialized, .exclusiveAppContext) +struct CloudWelcomePresentationTests { + private var isVisible: Bool { + NSApp.windows.contains { $0.identifier?.rawValue == "cmux.cloud.welcome" && $0.isVisible } + } + + @Test("a direct action shows and marks once, then continues without the card") + func directActionMarksSeenAndContinues() throws { + let suite = "CloudWelcomePresentationTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var steps: [CloudWelcomeNextStep] = [] + var resumed = 0 + let controller = CloudWelcomeWindowController( + defaults: defaults, + nextStep: { .enable }, + performNextStep: { step, resume in steps.append(step); resume?() } + ) + defer { controller.dismiss() } + + controller.present(over: nil, onEnabled: { resumed += 1 }) + #expect(isVisible) + #expect(defaults.string(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == "0.65.1") + #expect(steps.isEmpty) + #expect(resumed == 0) + controller.dismiss() + controller.present(over: nil, onEnabled: { resumed += 1 }) + #expect(!isVisible) + #expect(steps == [.enable]) + #expect(resumed == 1) + + let relaunchedDefaults = try #require(UserDefaults(suiteName: suite)) + let relaunched = CloudWelcomeWindowController(defaults: relaunchedDefaults) + let policy = ManagedDevicePolicy(defaults: relaunchedDefaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) + #expect(!relaunched.claimLaunchPresentation(appVersion: "0.65.2", policy: policy)) + } + + @Test("a launch viewer takes the current next step on every later Cloud action", + arguments: [CloudWelcomeNextStep.signIn, .enable, .upgrade, .openCloud]) + func launchViewerSkipsCard(step: CloudWelcomeNextStep) throws { + let suite = "CloudWelcomePresentationTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) + let launch = CloudWelcomeWindowController(defaults: defaults) + #expect(launch.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) + var steps: [CloudWelcomeNextStep] = [] + var resumed = 0 + let controller = CloudWelcomeWindowController( + defaults: defaults, + nextStep: { step }, + performNextStep: { step, resume in steps.append(step); resume?() } + ) + defer { controller.dismiss() } + for _ in 0..<2 { + controller.present(over: nil, onEnabled: { resumed += 1 }) + #expect(!isVisible) + } + #expect(steps == [step, step]) + #expect(resumed == 2) + #expect(!controller.claimLaunchPresentation(appVersion: "0.65.2", policy: policy)) + } + + #if DEBUG + @Test("debug force-show marks seen and reset permits another first presentation") + func debugForceAndReset() throws { + let suite = "CloudWelcomePresentationTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let controller = CloudWelcomeWindowController(defaults: defaults, performNextStep: { _, _ in }) + defer { controller.dismiss() } + let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) + controller.presentForDebug(over: nil) + #expect(isVisible) + #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) + controller.dismiss() + #expect(!controller.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) + controller.presentForDebug(over: nil) + #expect(isVisible) + controller.dismiss() + controller.present(over: nil) + #expect(!isVisible) + controller.resetForDebug() + #expect(!CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) + controller.present(over: nil) + #expect(isVisible) + #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) + controller.resetForDebug() + #expect(controller.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) + } + #endif +} diff --git a/cmuxTests/CloudWelcomeTests.swift b/cmuxTests/CloudWelcomeTests.swift index f327d7b9dc5e..8cc28bc25841 100644 --- a/cmuxTests/CloudWelcomeTests.swift +++ b/cmuxTests/CloudWelcomeTests.swift @@ -32,19 +32,19 @@ struct CloudWelcomeTests { let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) - let controller = CloudWelcomeWindowController() + let controller = CloudWelcomeWindowController(defaults: defaults) - #expect(controller.claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: policy)) + #expect(controller.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) // The marker exists before the caller creates any window or awaits work. #expect(defaults.string(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == "0.65.1") - #expect(!controller.claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: policy)) + #expect(!controller.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) controller.dismiss() - #expect(!controller.claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: policy)) + #expect(!controller.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) // A fresh controller and defaults handle model another launch/process. let relaunchedDefaults = try #require(UserDefaults(suiteName: suiteName)) - let relaunched = CloudWelcomeWindowController() - #expect(!relaunched.claimLaunchPresentation(defaults: relaunchedDefaults, appVersion: "0.65.1", policy: policy)) + let relaunched = CloudWelcomeWindowController(defaults: relaunchedDefaults) + #expect(!relaunched.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) } @Test("users who saw the 0.65.0 welcome still get the 0.65.1 card") @@ -56,7 +56,7 @@ struct CloudWelcomeTests { let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) // 0.65.0 recorded its welcome under this boolean key. defaults.set(true, forKey: "cmux.cloud.welcome.seen") - #expect(CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: policy)) + #expect(CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) } @Test("a blocked first window waits for the next launch, even when policy changes") @@ -69,11 +69,11 @@ struct CloudWelcomeTests { key == ManagedDevicePolicyKey.disableCloud.rawValue ? true : nil } let allowed = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) - let controller = CloudWelcomeWindowController() - #expect(!controller.claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: blocked)) - #expect(!controller.claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: allowed)) + let controller = CloudWelcomeWindowController(defaults: defaults) + #expect(!controller.claimLaunchPresentation(appVersion: "0.65.1", policy: blocked)) + #expect(!controller.claimLaunchPresentation(appVersion: "0.65.1", policy: allowed)) #expect(defaults.object(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == nil) - #expect(CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: allowed)) + #expect(CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.1", policy: allowed)) } @Test("ineligible release and debug/test launches leave the campaign available", arguments: ["0.65.0", "0.65.3", "0.66.0", "0.65.1-nightly", "0.65.1", "0.65.2"]) @@ -85,14 +85,14 @@ struct CloudWelcomeTests { let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) let modes = ["0.65.1", "0.65.2"].contains(appVersion) ? [(true, false, false), (false, true, false), (false, false, true)] : [(false, false, false)] for (debug, xctest, uiTest) in modes { - let controller = CloudWelcomeWindowController() + let controller = CloudWelcomeWindowController(defaults: defaults) #expect(!controller.claimLaunchPresentation( - defaults: defaults, appVersion: appVersion, policy: policy, + appVersion: appVersion, policy: policy, isDebugBuild: debug, isRunningUnderXCTest: xctest, isUITestMode: uiTest )) } #expect(defaults.object(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == nil) - #expect(CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: policy)) + #expect(CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) } @Test("a fresh user on 0.65.2 sees the campaign once and records the 0.65.1 marker") @@ -103,11 +103,11 @@ struct CloudWelcomeTests { defer { defaults.removePersistentDomain(forName: suiteName) } let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) - #expect(CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.2", policy: policy)) + #expect(CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.2", policy: policy)) // The marker keeps the value 0.65.1 wrote, so both releases share it. #expect(defaults.string(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == "0.65.1") - #expect(!CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.2", policy: policy)) - #expect(!CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: policy)) + #expect(!CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.2", policy: policy)) + #expect(!CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) } @Test("a user who saw the welcome on 0.65.1 does not see it again on 0.65.2") @@ -118,11 +118,11 @@ struct CloudWelcomeTests { defer { defaults.removePersistentDomain(forName: suiteName) } let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) - #expect(CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.1", policy: policy)) + #expect(CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) // The literal value 0.65.1 already wrote for users before this change. #expect(defaults.string(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == "0.65.1") #expect(!CloudWelcomeWindowController.shouldPresentAutomatically(defaults: defaults, appVersion: "0.65.2", policy: policy)) - #expect(!CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.2", policy: policy)) + #expect(!CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.2", policy: policy)) } @Test("MDM DisableCloud blocks the campaign on 0.65.2 without consuming it") @@ -135,7 +135,7 @@ struct CloudWelcomeTests { key == ManagedDevicePolicyKey.disableCloud.rawValue ? true : nil } #expect(!CloudWelcomeWindowController.shouldPresentAutomatically(defaults: defaults, appVersion: "0.65.2", policy: blocked)) - #expect(!CloudWelcomeWindowController().claimLaunchPresentation(defaults: defaults, appVersion: "0.65.2", policy: blocked)) + #expect(!CloudWelcomeWindowController(defaults: defaults).claimLaunchPresentation(appVersion: "0.65.2", policy: blocked)) #expect(defaults.object(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == nil) } @@ -237,7 +237,7 @@ struct CloudWelcomeTests { ) == ["0.65.1", "0.65.2"].contains(appVersion)) } - @Test("an absent or older marker upgrades to 0.65.1 and prevents a repeat", arguments: [nil, "0.64.25", "0.65.0"] as [String?], ["0.65.1", "0.65.2"]) + @Test("any existing version marker prevents a repeat", arguments: [nil, "0.64.25", "0.65.0"] as [String?], ["0.65.1", "0.65.2"]) func campaignMarkerUpgradeAndNoRepeat(previousMarker: String?, appVersion: String) throws { let suiteName = "CloudWelcomeTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) @@ -251,7 +251,7 @@ struct CloudWelcomeTests { defaults: defaults, appVersion: appVersion, policy: policy - )) + ) == (previousMarker == nil)) CloudWelcomeWindowController.markCampaignSeen(in: defaults) diff --git a/cmuxTests/CloudWorkspaceTargetingTests.swift b/cmuxTests/CloudWorkspaceTargetingTests.swift index d3b192d637ac..a0b2c3cac4a4 100644 --- a/cmuxTests/CloudWorkspaceTargetingTests.swift +++ b/cmuxTests/CloudWorkspaceTargetingTests.swift @@ -255,6 +255,13 @@ struct CloudWorkspaceTargetingTests { /// card (#19876) and still never falls back to a local workspace. @Test("Signed-out Cloud access introduces Cloud without creating a workspace") func unavailableAction() { + let seenKey = CloudWelcomeWindowController.seenVersionDefaultsKey + let previousSeen = UserDefaults.standard.object(forKey: seenKey) + UserDefaults.standard.removeObject(forKey: seenKey) + defer { + if let previousSeen { UserDefaults.standard.set(previousSeen, forKey: seenKey) } + else { UserDefaults.standard.removeObject(forKey: seenKey) } + } let fixture = CloudWorkspaceTargetingFixture() defer { fixture.app.cloudWelcomeWindowController.dismiss() diff --git a/cmuxTests/NewCloudWorkspaceShortcutTests.swift b/cmuxTests/NewCloudWorkspaceShortcutTests.swift index 51e34db42d48..4c053994a559 100644 --- a/cmuxTests/NewCloudWorkspaceShortcutTests.swift +++ b/cmuxTests/NewCloudWorkspaceShortcutTests.swift @@ -46,12 +46,15 @@ final class NewCloudWorkspaceShortcutTests { private var originalFileStore: KeyboardShortcutSettingsFileStore? private var originalCloudOptIn: Any? + private var originalWelcomeSeen: Any? private var originalBrowserDisabled: Any? init() { originalFileStore = KeyboardShortcutSettings.installIsolatedTestFileStore(prefix: "new-cloud-workspace") let defaults = UserDefaults.standard originalCloudOptIn = defaults.object(forKey: Self.cloudOptInKey) + originalWelcomeSeen = defaults.object(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) + defaults.removeObject(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) originalBrowserDisabled = defaults.object(forKey: BrowserAvailabilitySettings.disabledKey) defaults.removeObject(forKey: BrowserAvailabilitySettings.disabledKey) } @@ -65,6 +68,11 @@ final class NewCloudWorkspaceShortcutTests { KeyboardShortcutSettings.settingsFileStore = originalFileStore } let defaults = UserDefaults.standard + if let originalWelcomeSeen { + defaults.set(originalWelcomeSeen, forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) + } else { + defaults.removeObject(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) + } if let originalCloudOptIn { defaults.set(originalCloudOptIn, forKey: Self.cloudOptInKey) } else { @@ -334,7 +342,7 @@ final class NewCloudWorkspaceShortcutTests { NSApp.windows.contains { $0.identifier?.rawValue == "cmux.cloud.welcome" && $0.isVisible } } - @Test func testSharedActionsIntroduceCloudBeforePresentingSheet() { + @Test func testSharedActionsIntroduceCloudOnlyOnceBeforePresentingSheet() { defer { restoreState() } setCloudMachinesEnabled(false) let presenter = RecordingSheetPresenter() @@ -353,14 +361,14 @@ final class NewCloudWorkspaceShortcutTests { #expect(!Self.isCloudWelcomeVisible()) // Cmd-Y, which otherwise falls back to New Cloud Machine. #expect(appDelegate.performNewCloudWorkspaceOnResolvedMachineAction(debugSource: "test.cmdY.activationOff")) - #expect(Self.isCloudWelcomeVisible()) + #expect(!Self.isCloudWelcomeVisible()) #expect(!NSApp.windows.contains { $0.identifier?.rawValue == SettingsWindowPresenter.windowIdentifier && $0.isVisible }) #expect(presenter.presentCount == 0) } - @Test func testSharedActionsIntroduceCloudWhenSignedOut() { + @Test func testSharedActionsIntroduceCloudOnlyOnceWhenSignedOut() { defer { restoreState() } setCloudMachinesEnabled(true) let presenter = RecordingSheetPresenter() @@ -371,7 +379,7 @@ final class NewCloudWorkspaceShortcutTests { #expect(Self.isCloudWelcomeVisible()) appDelegate.cloudWelcomeWindowController.dismiss() #expect(appDelegate.performNewCloudWorkspaceOnResolvedMachineAction(debugSource: "test.cmdY.signedOut")) - #expect(Self.isCloudWelcomeVisible()) + #expect(!Self.isCloudWelcomeVisible()) #expect(presenter.presentCount == 0) } From b04b54f1e847564df327c996f4ffe1286356375e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:04:46 -0700 Subject: [PATCH 017/183] Cloud: push the machine prompt name as root again (#20496) * test: push a new machine's prompt name as root The prompt name push writes the root-owned /etc/cmux. It ran as root until #16769 moved public vm exec to the work user, and has failed with a permission error since; a dev backend's machines then keep cmux@cmux. Co-Authored-By: Claude Opus 5.5 * fix(cloud): push the prompt name as root again #16769 moved public vm exec to the work user so commands see the same home as Cloud terminals. The create and rename prompt pushes share that exec and write the root-owned /etc/cmux, so they have failed since. Production hid it behind the guest's reflection fetch; dev backends can't be reached that way and kept cmux@cmux. Restore root for exactly these pushes through an explicit asRoot exec option; user vm exec stays on the work user. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- web/services/vms/drivers/freestyle.ts | 5 +++-- web/services/vms/drivers/types.ts | 6 ++++++ web/services/vms/workflows.ts | 4 ++++ web/tests/vm-create-critical-path.test.ts | 10 ++++++++-- web/tests/vm-freestyle-provider.test.ts | 11 ++++++++++- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index 00521916f01c..727d3b3a7924 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -1389,8 +1389,9 @@ export class FreestyleProvider implements VMProvider { // terminals and coding agents. Leaving `linuxUser` unset asks the // provider for its uid-1000 account (`cmux` on current images), while // preserving the provider's legacy fallback to root on older images. - // Driver maintenance stays explicitly root below. - const r = await vm.exec({ command, timeoutMs }); + // Driver maintenance stays explicitly root below, as does cmux's own + // guest maintenance that asks for it. + const r = await vm.exec({ command, timeoutMs, ...(opts?.asRoot ? { linuxUser: GUEST_LINUX_USER } : {}) }); // statusCode is null when the guest killed the command at its timeout. const exitCode = r.statusCode ?? 124; setSpanAttributes(span, { "cmux.exec.exit_code": exitCode }); diff --git a/web/services/vms/drivers/types.ts b/web/services/vms/drivers/types.ts index dbc13d9adc9c..2939f5f7b3b8 100644 --- a/web/services/vms/drivers/types.ts +++ b/web/services/vms/drivers/types.ts @@ -388,6 +388,12 @@ export type VMFileStat = { export type ExecOptions = { readonly timeoutMs?: number; + /** + * Run as root instead of the work user. Every exec ran as root before + * public `vm exec` moved to the work user (#16769); cmux's own guest + * maintenance that writes root-owned paths still needs it. + */ + readonly asRoot?: boolean; /** Server-side metadata persisted with the VM row, used for durable-home routing. */ readonly providerMetadata?: Record; }; diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index 989feb55938f..c70de0cbe829 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -459,6 +459,8 @@ export function renameVm(input: { yield* providers.exec(updated.provider, input.providerVmId, guestPromptInstallCommand(vmPromptIdentity(updated)), { timeoutMs: 10_000, providerMetadata: updated.providerMetadata, + // /etc/cmux is root-owned. + asRoot: true, }).pipe( Effect.flatMap((result) => result.exitCode === 0 ? Effect.void : Effect.fail(new Error(`prompt update exited ${result.exitCode}`))), // A saved rename must remain available when a guest is unreachable. @@ -1446,6 +1448,8 @@ function schedulePromptIdentityPush( providers.exec(row.provider, providerVmId, guestPromptInstallCommand(vmPromptIdentity(row)), { timeoutMs: 10_000, providerMetadata: row.providerMetadata, + // /etc/cmux is root-owned. + asRoot: true, }) ).pipe( Effect.flatMap((result) => result.exitCode === 0 ? Effect.void : Effect.fail(new Error(`prompt push exited ${result.exitCode}`))), diff --git a/web/tests/vm-create-critical-path.test.ts b/web/tests/vm-create-critical-path.test.ts index 5a7e1b39b609..49e254329e7e 100644 --- a/web/tests/vm-create-critical-path.test.ts +++ b/web/tests/vm-create-critical-path.test.ts @@ -82,10 +82,12 @@ function fakeProviders(input: { readonly onCreate?: () => Effect.Effect; readonly fail?: boolean; readonly execs?: string[]; + readonly execOptions?: Array<{ readonly asRoot?: boolean } | undefined>; }): VmProviderGatewayShape { return { - exec: (_provider: string, _vmId: string, command: string) => Effect.sync(() => { + exec: (_provider: string, _vmId: string, command: string, options?: { readonly asRoot?: boolean }) => Effect.sync(() => { input.execs?.push(command); + input.execOptions?.push(options); return { exitCode: 0, stdout: "", stderr: "" }; }), create: () => @@ -167,10 +169,11 @@ describe("createVm critical path", () => { test("the prompt name is pushed into the guest only after the response", async () => { const execs: string[] = []; + const execOptions: Array<{ readonly asRoot?: boolean } | undefined> = []; const deferred: Effect.Effect[] = []; await Effect.runPromise( createVm({ ...createInput, deferAfterResponse: (work) => deferred.push(work) }).pipe( - Effect.provide(layer(fakeRepo({ events: [] }), fakeProviders({ execs }))), + Effect.provide(layer(fakeRepo({ events: [] }), fakeProviders({ execs, execOptions }))), ), ); // Nothing runs in the guest before create returns. @@ -178,6 +181,9 @@ describe("createVm critical path", () => { for (const work of deferred) await Effect.runPromise(work); expect(execs).toHaveLength(1); expect(execs[0]).toContain("vm-name"); + // /etc/cmux is root-owned; the push ran as root until public exec moved + // to the work user (#16769), and must keep doing so. + expect(execOptions[0]?.asRoot).toBe(true); }); test("a provider failure records its failure row after the requested row", async () => { diff --git a/web/tests/vm-freestyle-provider.test.ts b/web/tests/vm-freestyle-provider.test.ts index 2260b419db73..b4c64c191872 100644 --- a/web/tests/vm-freestyle-provider.test.ts +++ b/web/tests/vm-freestyle-provider.test.ts @@ -467,6 +467,13 @@ describe("Freestyle platform contract", () => { expect(fake.writes).toHaveLength(0); }); + test("cmux's own guest maintenance through exec can still ask for root", async () => { + const fake = fakeFreestyle({ probeExit: 0 }); + const result = await providerWith(fake).exec(VM_ID, "true", { timeoutMs: 5_000, asRoot: true }); + expect(result.exitCode).toBe(0); + expect(fake.execUsers).toEqual(["root"]); + }); + test("exec does not repair an absent guest CLI during a command", async () => { const fake = fakeFreestyle({ probeExit: 0, guestCliExit: 1 }); const result = await providerWith(fake).exec(VM_ID, "cmux self --json"); @@ -909,7 +916,9 @@ describe("Freestyle client configuration", () => { }); test("public vm exec leaves the guest user to the provider default", () => { - expect(driverSource).toContain("const r = await vm.exec({ command, timeoutMs });"); + expect(driverSource).toContain( + "const r = await vm.exec({ command, timeoutMs, ...(opts?.asRoot ? { linuxUser: GUEST_LINUX_USER } : {}) });", + ); }); test("the driver talks to the SDK's default public edge unless overridden", () => { From b328cc67bad7b819cea834d90f12b8e3773d202e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:05:30 -0700 Subject: [PATCH 018/183] Cloud: recover frozen machines with provider restart and health alerts (#20361) * test(cloud): cover hard restart for frozen VMs * fix(cloud): recover frozen VMs through provider restart * fix(cloud): finish restart health alert integration * fix(cloud): satisfy Swift lifecycle timeout typing * fix(cloud): make fallback restart retries idempotent * test(cloud): reject unverified recovery and stale restart links * fix(cloud): fail closed without provider hard restart * test(cloud): expect restart capability to fail closed * fix(cloud): keep health alerts snapshot consistent * docs(cloud): describe restart capability honestly * test(cloud): cover capability-gated recovery alerts * test(cloud): reject pause resume guidance for frozen guests * fix(cloud): preserve frozen machines in recovery guidance --- CLI/CMUXCLI+TaskHelp.swift | 2 +- CLI/CMUXCLI+VMTui.swift | 3 +- CLI/cmux.swift | 8 +- .../CloudAPIRequestBuilder.swift | 12 + .../CloudAPIResponseDecoding.swift | 13 +- .../CmuxMobileCloud/CloudMachine.swift | 9 +- .../CmuxMobileCloud/CloudMachineAction.swift | 1 + .../CloudSessionController.swift | 14 +- .../CmuxMobileCloud/CloudVMService.swift | 13 + .../CmuxMobileCloud/CloudVMServing.swift | 2 + .../CloudMachineLifecycleTests.swift | 56 +++++ .../Tests/CmuxMobileCloudTests/Fakes.swift | 7 + .../CmuxMobileCloudUI/CloudSectionView.swift | 18 +- .../CmuxCloud/Machines/MachineSnapshot.swift | 4 +- .../Operations/CloudOperationKind.swift | 3 +- .../CmuxCloud/Reads/CloudReadMutation.swift | 2 +- .../CmuxCloud/VMClient/VMCapabilities.swift | 16 +- .../Sources/CmuxCloud/VMClient/VMClient.swift | 36 ++- Resources/Localizable.xcstrings | 238 +++++++++++++++++- Sources/Cloud/CloudMachineMenuVerbs.swift | 5 +- Sources/Cloud/VMClientSocketCommands.swift | 16 +- Sources/TerminalController+Capabilities.swift | 1 + cmuxTests/CLIVMLayoutEnvTests.swift | 13 + cmuxTests/CloudMenuContentTests.swift | 10 + cmuxTests/MachinesPanelModelTests.swift | 8 +- ...erminalControllerSocketSecurityTests.swift | 1 + ios/cmux/Resources/Localizable.xcstrings | 118 +++++++++ skills/cmux-cloud-vm/references/commands.md | 13 +- web/app/api/vm/[id]/restart/route.ts | 38 +++ web/messages/ar.json | 2 +- web/messages/bs.json | 2 +- web/messages/da.json | 2 +- web/messages/de.json | 2 +- web/messages/en.json | 2 +- web/messages/es.json | 2 +- web/messages/fr.json | 2 +- web/messages/it.json | 2 +- web/messages/ja.json | 2 +- web/messages/km.json | 2 +- web/messages/ko.json | 2 +- web/messages/no.json | 2 +- web/messages/pl.json | 2 +- web/messages/pt-BR.json | 2 +- web/messages/ru.json | 2 +- web/messages/th.json | 2 +- web/messages/tr.json | 2 +- web/messages/uk.json | 2 +- web/messages/zh-CN.json | 2 +- web/messages/zh-TW.json | 2 +- .../observability/cloudTelemetryContract.ts | 2 +- web/services/observability/vmAlerts.ts | 63 +++++ web/services/vms/drivers/freestyle.ts | 16 +- web/services/vms/drivers/index.ts | 1 + web/services/vms/drivers/types.ts | 6 + web/services/vms/providerGateway.ts | 9 + web/services/vms/routeDeadline.ts | 14 +- web/services/vms/routeHelpers.ts | 6 +- web/services/vms/workflows.ts | 68 ++++- web/tests/vm-alerts.test.ts | 46 ++++ web/tests/vm-capabilities.test.ts | 3 + web/tests/vm-freestyle-provider.test.ts | 7 + web/tests/vm-freestyle-stats.test.ts | 7 +- web/tests/vm-pause-resume-route.test.ts | 25 +- web/tests/vm-provider-conflicts.test.ts | 7 +- web/tests/vm-read-deadlines.test.ts | 4 +- web/tests/vm-route-auth.test.ts | 1 + web/tests/vm-route-deadlines.test.ts | 1 + web/tests/vm-scp.test.ts | 5 +- web/tests/vm-workflows.test.ts | 71 ++++++ 69 files changed, 998 insertions(+), 84 deletions(-) create mode 100644 web/app/api/vm/[id]/restart/route.ts diff --git a/CLI/CMUXCLI+TaskHelp.swift b/CLI/CMUXCLI+TaskHelp.swift index 9c6524e51661..6c4332d5b9bf 100644 --- a/CLI/CMUXCLI+TaskHelp.swift +++ b/CLI/CMUXCLI+TaskHelp.swift @@ -398,7 +398,7 @@ extension CMUXCLI { return """ auth login | logout (aliases for auth login/logout) - vm [args...] (alias: cloud) + vm [args...] (alias: cloud) remotes [--route ] [--tag ] [--json] (alias: remote) \(simulatorCommandUsageLine) \(iosCommandUsageLine) diff --git a/CLI/CMUXCLI+VMTui.swift b/CLI/CMUXCLI+VMTui.swift index 765ca69b5522..c4cbbaad7efe 100644 --- a/CLI/CMUXCLI+VMTui.swift +++ b/CLI/CMUXCLI+VMTui.swift @@ -992,6 +992,7 @@ extension CMUXCLI { workspaces and terminal history stay. `vm ls` shows it paused. cmux vm resume Wake a paused machine: the daemon, terminals and files come back. Opening or exec'ing a paused machine also wakes it. + cmux vm restart \(String(localized: "cli.vm.restart.help", defaultValue: "Request recovery when supported. Running processes may be lost.")) A provider without pause says so; such machines stay available until `cmux vm rm`. Add --json for the raw result. """ @@ -1026,7 +1027,7 @@ extension CMUXCLI { case "tree": return vmTreeUsage case "tui": return vmTuiUsage case "exec": return vmExecUsage - case "pause", "resume": return vmLifecycleUsage + case "pause", "resume", "restart": return vmLifecycleUsage case "agent": return vmAgentUsage case "run": return vmRunUsage case "route": return vmRouteUsage diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7d03ccdccd95..186241a49249 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7401,14 +7401,14 @@ struct CMUXCLI { case "agent-updates": try runVMAgentUpdatesCommand(rest: rest, client: client, jsonOutput: jsonOutput) - case "pause", "resume": + case "pause", "resume", "restart": // Lifecycle, not sizing: pausing parks the machine (compute stops, the volume // stays); resuming brings the daemon and its terminals back. let lifecycleArgs = rest.filter { $0 != "--json" } guard lifecycleArgs.count == 1, let vmId = lifecycleArgs.first, !vmId.hasPrefix("-") else { throw CLIError(message: Self.vmLifecycleUsage) } - let response = try client.sendV2(method: "vm.\(sub)", params: ["id": vmId], responseTimeout: 180) + let response = try client.sendV2(method: "vm.\(sub)", params: ["id": vmId], responseTimeout: sub == "restart" ? 60 : 180) if jsonOutput { print(jsonString(response)) break @@ -7416,8 +7416,10 @@ struct CMUXCLI { let status = (response["status"] as? String) ?? "?" if sub == "pause" { print("OK \(vmId) paused (status=\(status)); `cmux vm resume \(vmId)` wakes it") - } else { + } else if sub == "resume" { print("OK \(vmId) resumed (status=\(status))") + } else { + print(String(format: String(localized: "cli.vm.restart.result", defaultValue: "%1$@ restarted (status=%2$@)"), vmId, status)) } case "base": diff --git a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudAPIRequestBuilder.swift b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudAPIRequestBuilder.swift index cea783b1a3c7..5b39f0fdd3dc 100644 --- a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudAPIRequestBuilder.swift +++ b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudAPIRequestBuilder.swift @@ -63,6 +63,9 @@ public struct CloudAPIRequestBuilder: Sendable, Equatable { /// Resuming waits for the provider to boot compute back up. public static let resumeTimeout: TimeInterval = 16 * 60 + /// A provider restart should be quick, but frozen machines need more than + /// the ordinary request timeout while the control plane confirms recovery. + public static let restartTimeout: TimeInterval = 60 /// `POST /api/vm/{id}/pause`: stops compute and billing, keeps the disk. public func pauseMachine(id: String, accessToken: String, refreshToken: String) throws -> URLRequest { @@ -78,6 +81,15 @@ public struct CloudAPIRequestBuilder: Sendable, Equatable { return request } + /// `POST /api/vm/{id}/restart`: recovers a running machine without + /// entering its guest or changing its disk. + public func restartMachine(id: String, accessToken: String, refreshToken: String) throws -> URLRequest { + var request = try request("POST", path: "/api/vm/\(try Self.pathSegment(id))/restart", body: nil, + accessToken: accessToken, refreshToken: refreshToken) + request.timeoutInterval = Self.restartTimeout + return request + } + /// `DELETE /api/vm/{id}`. public func deleteMachine(id: String, accessToken: String, refreshToken: String) throws -> URLRequest { try request("DELETE", path: "/api/vm/\(try Self.pathSegment(id))", body: nil, diff --git a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudAPIResponseDecoding.swift b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudAPIResponseDecoding.swift index 5dd7a6ed2476..469d412ac497 100644 --- a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudAPIResponseDecoding.swift +++ b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudAPIResponseDecoding.swift @@ -1,3 +1,4 @@ +import CoreFoundation public import Foundation /// Decodes `/api/vm` response bodies into the domain values. @@ -38,7 +39,8 @@ public struct CloudAPIResponseDecoding: Sendable { status: status, displayName: displayName, slug: slug, - resources: Self.resources(dict["resources"]) + resources: Self.resources(dict["resources"]), + supportsRestart: Self.supportsRestart(dict) ) } let availableKinds: Set? @@ -170,7 +172,14 @@ public struct CloudAPIResponseDecoding: Sendable { let status = rawStatus.flatMap { $0.isEmpty ? nil : $0 } ?? "unknown" let displayName = (dict["displayName"] as? String).flatMap { $0.isEmpty ? nil : $0 } let slug = (dict["slug"] as? String).flatMap { $0.isEmpty ? nil : $0 } - return CloudMachine(id: id, provider: provider, status: status, displayName: displayName, slug: slug) + return CloudMachine(id: id, provider: provider, status: status, displayName: displayName, slug: slug, supportsRestart: Self.supportsRestart(dict)) + } + + private static func supportsRestart(_ dict: [String: Any]) -> Bool { + guard let capabilities = dict["capabilities"] as? [String: Any], + let value = capabilities["restart"] as? NSNumber, + CFGetTypeID(value) == CFBooleanGetTypeID() else { return false } + return value.boolValue } private static func int(_ value: Any?) -> Int? { diff --git a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudMachine.swift b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudMachine.swift index 25984891ca28..ac95cb6fda3e 100644 --- a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudMachine.swift +++ b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudMachine.swift @@ -57,6 +57,9 @@ public struct CloudMachine: Sendable, Equatable, Identifiable, Hashable { /// The vCPUs and memory this machine draws from the plan's shared pool /// while it is active; nil from a control plane that predates the field. public var resources: CloudMachineResources? + /// Hard recovery is available only with explicit server support. + public var supportsRestart: Bool + public var canRestart: Bool { supportsRestart && lifecycle.canRestart } /// Creates a machine row. /// - Parameters: @@ -72,7 +75,8 @@ public struct CloudMachine: Sendable, Equatable, Identifiable, Hashable { status: String, displayName: String? = nil, slug: String? = nil, - resources: CloudMachineResources? = nil + resources: CloudMachineResources? = nil, + supportsRestart: Bool = false ) { self.id = id self.provider = provider @@ -80,6 +84,7 @@ public struct CloudMachine: Sendable, Equatable, Identifiable, Hashable { self.displayName = displayName self.slug = slug self.resources = resources + self.supportsRestart = supportsRestart } /// The name to show everywhere a machine appears: the label, else the @@ -225,6 +230,8 @@ public enum CloudMachineLifecycle: Sendable, Equatable { public var canPause: Bool { self == .running } /// Resuming brings a paused machine's compute back. public var canResume: Bool { self == .paused } + /// Restarting recovers a running machine whose guest is unresponsive. + public var canRestart: Bool { self == .running } /// Deleting is offered for every state that still has a machine behind it. public var canDelete: Bool { switch self { diff --git a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudMachineAction.swift b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudMachineAction.swift index 3437406ded60..72bd3b074907 100644 --- a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudMachineAction.swift +++ b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudMachineAction.swift @@ -2,6 +2,7 @@ public enum CloudMachineAction: Sendable, Equatable { case pause case resume + case restart case delete } diff --git a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudSessionController.swift b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudSessionController.swift index 5acf648b320b..df1a039d1a6b 100644 --- a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudSessionController.swift +++ b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudSessionController.swift @@ -19,10 +19,10 @@ public final class CloudSessionController { public private(set) var isCreatingMachine = false /// The latest create failure, shown beside the create action. public private(set) var lastCreateFailure: CloudSessionFailure? - /// Machines with a pause, resume or delete in flight. Their rows show + /// Machines with a pause, resume, restart or delete in flight. Their rows show /// progress and refuse a second action until the first settles. public private(set) var machineActionsInFlight: Set = [] - /// The latest pause, resume or delete failure, with the machine it hit. + /// The latest pause, resume, restart or delete failure, with the machine it hit. public private(set) var lastMachineActionFailure: CloudMachineActionFailure? /// How many Cloud screens are on screen; the tunnel is wanted while > 0. public private(set) var visibleScreenCount = 0 @@ -221,6 +221,12 @@ public final class CloudSessionController { await runMachineAction(.resume, on: machine) { try await $0.resumeMachine(id: machine.id) } } + /// Hard-restarts a running machine through the provider recovery path. + @discardableResult + public func restartMachine(_ machine: CloudMachine) async -> Bool { + await runMachineAction(.restart, on: machine) { try await $0.restartMachine(id: machine.id) } + } + /// Deletes a machine and its disk. Its link closes first, so nothing keeps /// talking to a machine that is being destroyed. @discardableResult @@ -257,6 +263,10 @@ public final class CloudSessionController { do { try await perform(self.service) guard self.accountGeneration == generation, !Task.isCancelled else { return false } + if action == .restart { + self.connections.removeValue(forKey: machine.id)?.close() + self.connectionRetryGeneration &+= 1 + } if self.lastMachineActionFailure?.machineID == machine.id { self.lastMachineActionFailure = nil } self.refreshMachines() return true diff --git a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudVMService.swift b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudVMService.swift index 51bdbd799db1..43e4898673a8 100644 --- a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudVMService.swift +++ b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudVMService.swift @@ -235,6 +235,19 @@ public actor CloudVMService: CloudVMServing { ) } + /// Hard-restarts a running machine through the provider recovery path. + public func restartMachine(id: String) async throws { + let credentials = try await credentials() + _ = try await send( + requests.restartMachine( + id: id, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken + ), + teamID: credentials.teamID + ) + } + public func deleteMachine(id: String) async throws { let credentials = try await credentials() _ = try await send( diff --git a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudVMServing.swift b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudVMServing.swift index e2db5d496bfe..d82a216c3418 100644 --- a/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudVMServing.swift +++ b/Packages/iOS/CmuxMobileCloud/Sources/CmuxMobileCloud/CloudVMServing.swift @@ -51,6 +51,8 @@ public protocol CloudVMServing: Sendable { func pauseMachine(id: String) async throws /// Bring a paused machine's compute back. func resumeMachine(id: String) async throws + /// Hard-restart a running machine through the provider recovery path. + func restartMachine(id: String) async throws /// Delete a machine and its disk. func deleteMachine(id: String) async throws } diff --git a/Packages/iOS/CmuxMobileCloud/Tests/CmuxMobileCloudTests/CloudMachineLifecycleTests.swift b/Packages/iOS/CmuxMobileCloud/Tests/CmuxMobileCloudTests/CloudMachineLifecycleTests.swift index 1f7646c6c944..169c7cdc8b55 100644 --- a/Packages/iOS/CmuxMobileCloud/Tests/CmuxMobileCloudTests/CloudMachineLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileCloud/Tests/CmuxMobileCloudTests/CloudMachineLifecycleTests.swift @@ -48,6 +48,21 @@ import Testing #expect(!CloudMachineLifecycle.unknown.canDelete) } + @Test func restartRequiresRunningAndExplicitServerCapability() throws { + let decoder = CloudAPIResponseDecoding() + for raw in ["true", "false", "null", "1", "\"true\""] { + let data = Data("{\"vms\":[{\"id\":\"vm-1\",\"provider\":\"freestyle\",\"status\":\"running\",\"capabilities\":{\"restart\":\(raw)}}]}".utf8) + let machine = try #require(decoder.catalog(from: data).machines.first) + #expect(machine.canRestart == (raw == "true")) + var paused = machine + paused.status = "paused" + #expect(!paused.canRestart) + } + #expect(!Self.running.canRestart) + let created = try decoder.createdMachine(from: Data(#"{"id":"vm-1","provider":"freestyle","status":"running","capabilities":{"restart":true}}"#.utf8)) + #expect(created.canRestart) + } + @Test func requestsReachTheServersLifecycleRoutes() throws { let builder = CloudAPIRequestBuilder(baseURL: "https://cmux.com/") let pause = try builder.pauseMachine(id: "vm-1", accessToken: "a", refreshToken: "r") @@ -59,6 +74,11 @@ import Testing #expect(resume.url?.absoluteString == "https://cmux.com/api/vm/vm-1/resume") #expect(resume.timeoutInterval == CloudAPIRequestBuilder.resumeTimeout) + let restart = try builder.restartMachine(id: "vm-1", accessToken: "a", refreshToken: "r") + #expect(restart.httpMethod == "POST") + #expect(restart.url?.absoluteString == "https://cmux.com/api/vm/vm-1/restart") + #expect(restart.timeoutInterval == CloudAPIRequestBuilder.restartTimeout) + let delete = try builder.deleteMachine(id: "vm-1", accessToken: "a", refreshToken: "r") #expect(delete.httpMethod == "DELETE") #expect(delete.url?.absoluteString == "https://cmux.com/api/vm/vm-1") @@ -74,6 +94,42 @@ import Testing } } + @Test func restartCallsTheControlPlaneAndReconcilesFromTheList() async { + let service = FakeCloudVMService() + service.machines = .success([Self.running]) + let controller = makeController(service: service) + + let ok = await controller.restartMachine(Self.running) + + #expect(ok) + #expect(service.calls.restart == ["vm-1"]) + #expect(controller.machineActionsInFlight.isEmpty) + } + + @Test func restartClosesTheCachedConnectionEvenWhenLifecycleStaysRunning() async throws { + let service = FakeCloudVMService() + service.machines = .success([Self.running]) + let connector = FakeConnector() + let controller = makeController(service: service, connector: connector) + controller.sectionDidAppear() + await settle { + controller.machines.elements == [Self.running] + && controller.tunnel == .ready(fingerprint: "ios-abc") + } + let oldConnection = try #require(controller.connection(for: Self.running)) + _ = try await oldConnection.loadCatalog() + let generation = controller.connectionRetryGeneration + + #expect(await controller.restartMachine(Self.running)) + #expect(connector.session.state.disconnected == 1) + #expect(controller.connectionRetryGeneration == generation + 1) + let replacement = try #require(controller.connection(for: Self.running)) + #expect(replacement !== oldConnection) + _ = try await replacement.loadCatalog() + #expect(connector.connects.count == 2) + controller.sectionDidDisappear() + } + @Test func pauseCallsTheControlPlaneAndReconcilesFromTheList() async { let service = FakeCloudVMService() service.machines = .success([Self.paused]) diff --git a/Packages/iOS/CmuxMobileCloud/Tests/CmuxMobileCloudTests/Fakes.swift b/Packages/iOS/CmuxMobileCloud/Tests/CmuxMobileCloudTests/Fakes.swift index 4edefede88e9..66ed2473a294 100644 --- a/Packages/iOS/CmuxMobileCloud/Tests/CmuxMobileCloudTests/Fakes.swift +++ b/Packages/iOS/CmuxMobileCloud/Tests/CmuxMobileCloudTests/Fakes.swift @@ -15,6 +15,7 @@ final class FakeCloudVMService: CloudVMServing, @unchecked Sendable { var approve: [(machineID: String, invitationId: String)] = [] var pause: [String] = [] var resume: [String] = [] + var restart: [String] = [] var delete: [String] = [] } @@ -230,6 +231,12 @@ final class FakeCloudVMService: CloudVMServing, @unchecked Sendable { if let lifecycleFailure { throw lifecycleFailure } } + func restartMachine(id: String) async throws { + lock.withLock { $0.restart.append(id) } + await waitForLifecycleActionIfNeeded() + if let lifecycleFailure { throw lifecycleFailure } + } + func deleteMachine(id: String) async throws { lock.withLock { $0.delete.append(id) } await waitForLifecycleActionIfNeeded() diff --git a/Packages/iOS/CmuxMobileCloudUI/Sources/CmuxMobileCloudUI/CloudSectionView.swift b/Packages/iOS/CmuxMobileCloudUI/Sources/CmuxMobileCloudUI/CloudSectionView.swift index d5417c4032db..d665a26dafc2 100644 --- a/Packages/iOS/CmuxMobileCloudUI/Sources/CmuxMobileCloudUI/CloudSectionView.swift +++ b/Packages/iOS/CmuxMobileCloudUI/Sources/CmuxMobileCloudUI/CloudSectionView.swift @@ -125,6 +125,7 @@ public struct CloudSectionView: View { retryConnection: { controller.retryConnections() }, pause: { Task { await controller.pauseMachine(machine) } }, resume: { Task { await controller.resumeMachine(machine) } }, + restart: { Task { await controller.restartMachine(machine) } }, delete: { Task { await controller.deleteMachine(machine) } } ) } @@ -563,7 +564,7 @@ struct CloudCreateMachineSheet: View { /// One machine row: its name and a lowercased status line. struct CloudMachineRow: View { let machine: CloudMachine - /// A pause, resume or delete is running for this machine. + /// A pause, resume, restart or delete is running for this machine. let isBusy: Bool /// The last lifecycle failure, when it hit this machine. let failure: CloudMachineActionFailure? @@ -573,6 +574,7 @@ struct CloudMachineRow: View { let retryConnection: () -> Void let pause: () -> Void let resume: () -> Void + let restart: () -> Void let delete: () -> Void @State private var isDeleteConfirmationPresented = false @@ -642,6 +644,13 @@ struct CloudMachineRow: View { .tint(.green) .disabled(isBusy) } + if machine.canRestart { + Button(action: restart) { + Label(L10n.string("mobile.cloud.action.restart", defaultValue: "Restart"), systemImage: "arrow.counterclockwise.circle") + } + .tint(.blue) + .disabled(isBusy) + } } .confirmationDialog( String( @@ -694,6 +703,12 @@ struct CloudMachineRow: View { } .disabled(isBusy) } + if machine.canRestart { + Button(action: restart) { + Label(L10n.string("mobile.cloud.action.restart", defaultValue: "Restart"), systemImage: "arrow.counterclockwise.circle") + } + .disabled(isBusy) + } if machine.lifecycle.canDelete { Button(action: requestDelete) { Label(L10n.string("mobile.cloud.action.delete", defaultValue: "Delete"), systemImage: "trash") @@ -734,6 +749,7 @@ struct CloudMachineRow: View { switch failure.action { case .pause: format = L10n.string("mobile.cloud.action.pauseFailedFormat", defaultValue: "Couldn't pause: %@") case .resume: format = L10n.string("mobile.cloud.action.resumeFailedFormat", defaultValue: "Couldn't resume: %@") + case .restart: format = L10n.string("mobile.cloud.action.restartFailedFormat", defaultValue: "Couldn't restart: %@") case .delete: format = L10n.string("mobile.cloud.action.deleteFailedFormat", defaultValue: "Couldn't delete: %@") } return String(format: format, failure.failure.action ?? failure.failure.localizedMessage) diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachineSnapshot.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachineSnapshot.swift index bae097ebb6da..b1275d71b1c4 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachineSnapshot.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachineSnapshot.swift @@ -8,7 +8,7 @@ public struct MachineSnapshot: Equatable, Identifiable, Sendable { provider: String, image: String, isDesktop: Bool, - capabilities: VMCapabilities = .all, + capabilities: VMCapabilities = .legacy, activity: Activity, createdAt: Date? = nil, createdBy: VMCreator? = nil, @@ -71,7 +71,7 @@ public struct MachineSnapshot: Equatable, Identifiable, Sendable { public let image: String public let isDesktop: Bool /// Verbs the provider can honor; menus omit Checkpoint/Fork when unsupported. - public var capabilities: VMCapabilities = .all + public var capabilities: VMCapabilities = .legacy public let activity: Activity public let createdAt: Date? /// Who made this machine; nil for machines the surface catalog discovered diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudOperationKind.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudOperationKind.swift index fc5a2c6dca78..b3e238c760a3 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudOperationKind.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudOperationKind.swift @@ -1,7 +1,7 @@ import Foundation public enum CloudOperationKind: String, Codable, Sendable { - case create, open, list, status, stats, rename, delete, pause, resume + case create, open, list, status, stats, rename, delete, pause, resume, restart case snapshot, fork, restore, resize, exec, port, publication, domain case base, session, workspace, terminal, file, environment, tunnel case connect, refresh, notification, agent, network, unknown @@ -16,6 +16,7 @@ public enum CloudOperationKind: String, Codable, Sendable { case .delete: return String(localized: "cloud.operation.kind.delete", defaultValue: "Delete machine") case .pause: return String(localized: "cloud.operation.kind.pause", defaultValue: "Pause machine") case .resume: return String(localized: "cloud.operation.kind.resume", defaultValue: "Resume machine") + case .restart: return String(localized: "cloud.operation.kind.restart", defaultValue: "Restart machine") case .snapshot: return String(localized: "cloud.operation.kind.snapshot", defaultValue: "Save snapshot") case .fork: return String(localized: "cloud.operation.kind.fork", defaultValue: "Copy machine") case .restore: return String(localized: "cloud.operation.kind.restore", defaultValue: "Restore machine") diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Reads/CloudReadMutation.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Reads/CloudReadMutation.swift index 21ed16a7dc15..83b6ce5bfd1d 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Reads/CloudReadMutation.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Reads/CloudReadMutation.swift @@ -24,7 +24,7 @@ public struct CloudReadMutation: Sendable { paths.insert("/api/coderouter/vm-usage/team") if method == "DELETE" { paths.insert("\(machinePath)/stats") } } else if parts.count == 4, method == "POST", [ - "pause", "resume", "resize", "fork", "attach-endpoint", "ssh-endpoint", + "pause", "resume", "restart", "resize", "fork", "attach-endpoint", "ssh-endpoint", "scp-endpoint", "sessions", "exec", "open-port" ].contains(parts[3]) { // Open/exec/file-transfer routes can wake a suspended machine. diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMCapabilities.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMCapabilities.swift index 0225122b660c..840db4d27476 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMCapabilities.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMCapabilities.swift @@ -10,6 +10,8 @@ public struct VMCapabilities: Equatable, Sendable { public var fork: Bool public var exec: Bool public var stats: Bool + /// The provider can recover a frozen machine without using its guest channel. + public var restart: Bool /// The provider can mint a browser preview URL for a machine port. public var ports: Bool public var desktop: Bool @@ -25,14 +27,17 @@ public struct VMCapabilities: Equatable, Sendable { public var ssh: Bool { attachTransports?.contains("ssh") ?? true } public var cmuxRemote: Bool { attachTransports?.contains("cmux-remote") ?? true } + /// Legacy servers did not advertise hard restart. + public static let legacy = VMCapabilities(json: nil) + public static let all = VMCapabilities( snapshot: true, restore: true, fork: true, - exec: true, stats: true, ports: true, desktop: true, + exec: true, stats: true, restart: true, ports: true, desktop: true, sizing: true, persistentHome: true, attachTransports: nil) public init( snapshot: Bool, restore: Bool, fork: Bool, - exec: Bool = true, stats: Bool = true, ports: Bool = true, + exec: Bool = true, stats: Bool = true, restart: Bool = false, ports: Bool = true, desktop: Bool = true, sizing: Bool = true, persistentHome: Bool = true, attachTransports: [String]? = nil ) { @@ -41,6 +46,7 @@ public struct VMCapabilities: Equatable, Sendable { self.fork = fork self.exec = exec self.stats = stats + self.restart = restart self.ports = ports self.desktop = desktop self.sizing = sizing @@ -48,7 +54,7 @@ public struct VMCapabilities: Equatable, Sendable { self.attachTransports = attachTransports } - /// Missing flags preserve legacy support; stats can use the historical kind fallback. + /// Missing legacy flags preserve support; restart requires an explicit opt-in. public init(json: Any?, legacyStatsSupported: Bool = true) { let dict = json as? [String: Any] func flag(_ key: String, fallback: Bool = true) -> Bool { @@ -60,7 +66,7 @@ public struct VMCapabilities: Equatable, Sendable { .compactMap { $0 as? String } self.init( snapshot: flag("snapshot"), restore: flag("restore"), fork: flag("fork"), - exec: flag("exec"), stats: flag("stats", fallback: legacyStatsSupported), ports: flag("ports"), + exec: flag("exec"), stats: flag("stats", fallback: legacyStatsSupported), restart: flag("restart", fallback: false), ports: flag("ports"), desktop: flag("desktop"), sizing: flag("sizing"), persistentHome: flag("persistentHome"), attachTransports: transports) } @@ -68,7 +74,7 @@ public struct VMCapabilities: Equatable, Sendable { public var jsonObject: [String: Any] { var object: [String: Any] = [ "snapshot": snapshot, "restore": restore, "fork": fork, - "exec": exec, "stats": stats, "ports": ports, "desktop": desktop, + "exec": exec, "stats": stats, "restart": restart, "ports": ports, "desktop": desktop, "sizing": sizing, "persistentHome": persistentHome, ] if let attachTransports { object["attach_transports"] = attachTransports } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift index f4f1d04a4230..284720dec94d 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift @@ -302,7 +302,7 @@ public struct VMSummary: Sendable { createdAt: Int64, base: VMBaseSummary? = nil, kind: VMMachineKind? = nil, - capabilities: VMCapabilities = .all, + capabilities: VMCapabilities = .legacy, displayName: String? = nil, slug: String? = nil, freeAccessExpiresAt: Int64? = nil, @@ -369,7 +369,7 @@ public struct VMSummary: Sendable { /// The backend's `kind` (desktop/base); when omitted, ``resolvedKind`` infers it from the image id. public var kind: VMMachineKind? = nil /// Verbs the provider can honor (`GET /api/vm` → `capabilities`); none sent means everything. - public var capabilities: VMCapabilities = .all + public var capabilities: VMCapabilities = .legacy /// User-chosen label; the id stays the machine's address. public var displayName: String? /// Who made this machine (`GET /api/vm` → `createdBy`). Nil when the @@ -1876,7 +1876,12 @@ public actor VMClient { /// now reports. A provider that cannot pause answers 501 `vm_pause_unsupported`. public func pause(id: String, teamID: String? = nil) async throws -> String { return try await withOperation(.pause, foreground: true) { - try await lifecycleTransition(id: id, action: "pause", teamID: teamID) + try await lifecycleTransition( + id: id, + action: "pause", + teamID: teamID, + timeoutSeconds: Self.createTimeoutSeconds + ) } } @@ -1884,17 +1889,36 @@ public actor VMClient { /// files come back. Plan limits apply exactly as they do to an implicit wake. public func resume(id: String, teamID: String? = nil) async throws -> String { return try await withOperation(.resume, foreground: true) { - try await lifecycleTransition(id: id, action: "resume", teamID: teamID) + try await lifecycleTransition( + id: id, + action: "resume", + teamID: teamID, + timeoutSeconds: Self.createTimeoutSeconds + ) } } - private func lifecycleTransition(id: String, action: String, teamID: String?) async throws -> String { + /// `POST /api/vm//restart`: recover a frozen machine through the provider control plane. + /// This call does not probe the guest first, so it remains usable when exec and terminal access + /// have stopped answering. The persistent disk and machine identity are retained. + public func restart(id: String, teamID: String? = nil) async throws -> String { + return try await withOperation(.restart, foreground: true) { + try await lifecycleTransition(id: id, action: "restart", teamID: teamID, timeoutSeconds: 60) + } + } + + private func lifecycleTransition( + id: String, + action: String, + teamID: String?, + timeoutSeconds: TimeInterval + ) async throws -> String { let encodedID = try pathSegment(id, fieldName: "vm id") let (data, http) = try await request( "POST", path: "/api/vm/\(encodedID)/\(action)", jsonBody: [:], - timeoutSeconds: Self.createTimeoutSeconds, + timeoutSeconds: timeoutSeconds, teamID: teamID ) if http.statusCode == 501 { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1b4f12cf3f4a..7cf523f3adcc 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -652377,7 +652377,243 @@ } } } + }, + "cli.vm.restart.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request recovery when supported. Running processes may be lost." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiederherstellung anfordern, wenn unterstützt. Laufende Prozesse können verloren gehen." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Demander la récupération si elle est prise en charge. Les processus en cours peuvent être perdus." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اطلب الاسترداد عند دعمه. قد تُفقد العمليات قيد التشغيل." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solicita la recuperación cuando sea compatible. Los procesos en ejecución pueden perderse." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在支援時要求復原。執行中的程序可能會遺失。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在支持时请求恢复。正在运行的进程可能会丢失。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되는 경우 복구를 요청합니다. 실행 중인 프로세스가 손실될 수 있습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対応している場合は復旧を要求します。実行中のプロセスが失われる可能性があります。" + } + } + } + }, + "cli.vm.restart.result": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ restarted (status=%2$@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ neu gestartet (Status=%2$@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ redémarré (état=%2$@)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت إعادة تشغيل %1$@ (الحالة=%2$@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ reiniciado (estado=%2$@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 已重新啟動(狀態=%2$@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 已重新启动(状态=%2$@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 재시작됨(상태=%2$@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ を再起動しました(ステータス=%2$@)" + } + } + } + }, + "machines.menu.restart": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart Machine" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تشغيل الجهاز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maschine neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar máquina" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer la machine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシンを再起動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "머신 재시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启机器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動機器" + } + } + } + }, + "cloud.operation.kind.restart": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart machine" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تشغيل الجهاز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maschine neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar máquina" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer la machine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシンを再起動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "머신 재시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启机器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動機器" + } + } + } } - }, +}, "version": "1.0" } diff --git a/Sources/Cloud/CloudMachineMenuVerbs.swift b/Sources/Cloud/CloudMachineMenuVerbs.swift index 0d8c58f708bb..d0544f198360 100644 --- a/Sources/Cloud/CloudMachineMenuVerbs.swift +++ b/Sources/Cloud/CloudMachineMenuVerbs.swift @@ -36,12 +36,15 @@ struct CloudMachineMenuVerbs { return entries } - /// Rename, Copy IP Address, Status, and the provider-honored Checkpoint/Fork. + /// Rename, recovery, Copy IP Address, Status, and the provider-honored Checkpoint/Fork. func manageEntries(_ machine: MachineSnapshot) -> [CloudMenuEntry] { let id = machine.id var entries = [ action("rename", id, String(localized: "machines.menu.rename", defaultValue: "Rename…")) { promptRename(machine) }, ] + if machine.freeAccess != .expired, machine.capabilities.restart { + entries.append(action("restart", id, String(localized: "machines.menu.restart", defaultValue: "Restart Machine")) { runCommand(id, ["vm", "restart"]) }) + } if let address = machine.privateAddress { entries.append(action("copyIP", id, String(localized: "machines.menu.copyIPAddress", defaultValue: "Copy IP Address")) { copyToPasteboard(address) }) } diff --git a/Sources/Cloud/VMClientSocketCommands.swift b/Sources/Cloud/VMClientSocketCommands.swift index f8c8762b0af0..1745e49e7890 100644 --- a/Sources/Cloud/VMClientSocketCommands.swift +++ b/Sources/Cloud/VMClientSocketCommands.swift @@ -387,16 +387,22 @@ extension TerminalController { "displayName": stored ?? NSNull(), ] } - case "vm.pause", "vm.resume": + case "vm.pause", "vm.resume", "vm.restart": guard let vmId = Self.socketWorkerString(params["id"]), !vmId.isEmpty else { return v2Error(id: id, code: "invalid_params", message: "\(method) requires `id`. Run `cmux vm ls` to find one.") } let resume = method == "vm.resume" + let restart = method == "vm.restart" return v2CloudCall(id: id, method: method, params: params) { - let status = resume - ? try await VMClient.shared.resume(id: vmId) - : try await VMClient.shared.pause(id: vmId) - if !resume { + let status: String + if restart { + status = try await VMClient.shared.restart(id: vmId) + } else if resume { + status = try await VMClient.shared.resume(id: vmId) + } else { + status = try await VMClient.shared.pause(id: vmId) + } + if !resume && !restart { await CmuxTuiSurfaceProviderRegistry.shared.machineBecameInactive(vmId, status: status) } return ["id": vmId, "status": status] diff --git a/Sources/TerminalController+Capabilities.swift b/Sources/TerminalController+Capabilities.swift index ace4d8c0aa18..8cb8ec58267c 100644 --- a/Sources/TerminalController+Capabilities.swift +++ b/Sources/TerminalController+Capabilities.swift @@ -134,6 +134,7 @@ extension TerminalController { "vm.rename", "vm.pause", "vm.resume", + "vm.restart", "vm.snapshot", "vm.snapshot_list", "vm.snapshot_delete", diff --git a/cmuxTests/CLIVMLayoutEnvTests.swift b/cmuxTests/CLIVMLayoutEnvTests.swift index fb2ed11d20ed..9e3853bf86a1 100644 --- a/cmuxTests/CLIVMLayoutEnvTests.swift +++ b/cmuxTests/CLIVMLayoutEnvTests.swift @@ -952,6 +952,19 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertTrue(unsupported.stderr.contains("Unexpected method vm.pause"), unsupported.stderr) } + func testVMRestartSendsTheProviderRecoveryMethod() throws { + let (restarted, log) = try runVMCommandAgainstMock( + "vm-restart", + arguments: ["vm", "restart", "frozen-vm"] + ) { method, params in + method == "vm.restart" ? ["id": params["id"] ?? "?", "status": "running"] : nil + } + XCTAssertEqual(restarted.status, 0, "stdout=\(restarted.stdout) stderr=\(restarted.stderr)") + XCTAssertEqual(log.methods, ["vm.restart"], log.methods.description) + XCTAssertEqual(log.params(ofFirst: "vm.restart")?["id"] as? String, "frozen-vm") + XCTAssertTrue(restarted.stdout.contains("OK frozen-vm restarted (status=running)"), restarted.stdout) + } + func testVMTerminalWaitExitReportsTheOutcomeAndFailsWhilePending() throws { let (exited, exitLog) = try runVMCommandAgainstMock( "vm-wait-exit", diff --git a/cmuxTests/CloudMenuContentTests.swift b/cmuxTests/CloudMenuContentTests.swift index 828ee8c94cce..96d95331c114 100644 --- a/cmuxTests/CloudMenuContentTests.swift +++ b/cmuxTests/CloudMenuContentTests.swift @@ -138,6 +138,16 @@ struct CloudMenuContentTests { #expect(plainIDs == ["machine.plain.openShell", "machine.plain.newWorkspace", "machine.plain.openFullClient", "machine.plain.rename", "machine.plain.status", "machine.plain.delete"]) + // Missing or false capability metadata must hide recovery in every + // menu. An explicit server opt-in routes to the shared CLI action. + limited.capabilities.restart = true + let recoveryEntries = recorder.actions.machine.submenuEntries(limited) + try Self.perform("machine.plain.restart", in: recoveryEntries) + #expect(recorder.log == ["run:plain:vm restart"]) + recorder.log.removeAll() + limited.capabilities.restart = false + #expect(!recorder.actions.machine.submenuEntries(limited).contains { $0.id == "machine.plain.restart" }) + // Freestyle: no native fork, but the backend forks through snapshot + create. var snapshotForked = Self.machine("snap") snapshotForked.capabilities = VMCapabilities(snapshot: true, restore: true, fork: false) diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index db1fcafde937..34eac84da803 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -1019,12 +1019,16 @@ final class MachinesPanelModelTests: XCTestCase { @MainActor final class CloudTreeScopeAndSignatureTests: XCTestCase { func testMachineCapabilitiesDecodeWithSupportedDefaults() { - XCTAssertEqual(VMCapabilities(json: nil), .all, "an older control plane supports everything") + var legacy = VMCapabilities.all + legacy.restart = false + XCTAssertEqual(VMCapabilities(json: nil), legacy, "restart requires explicit server support") + XCTAssertTrue(VMCapabilities(json: ["restart": true]).restart) + XCTAssertFalse(VMCapabilities(json: ["restart": false]).restart) XCTAssertEqual(VMCapabilities(json: ["snapshot": false, "fork": false]), VMCapabilities(snapshot: false, restore: true, fork: false)) XCTAssertEqual(VMCapabilities(json: ["snapshot": NSNumber(value: false), "restore": NSNumber(value: true), "fork": true]), VMCapabilities(snapshot: false, restore: true, fork: true)) let summary = VMSummary(id: "m", provider: "freestyle", status: "running", image: "cmux-devbox:devbox-20260828b", createdAt: 0, base: nil) - XCTAssertEqual(summary.capabilities, .all) + XCTAssertEqual(summary.capabilities, legacy) var declared = summary declared.capabilities = VMCapabilities(json: ["snapshot": false, "restore": false, "fork": false]) XCTAssertFalse(declared.capabilities.snapshot) diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index d3900d2e3ebf..b81944bb901b 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -1185,6 +1185,7 @@ final class TerminalControllerSocketSecurityTests { "vm.file_put", "vm.pause", "vm.resume", + "vm.restart", "vm.reflection", "vm.snapshot_list", "vm.snapshot_delete", diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ed298e144bf4..c9103ee65d3c 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1,6 +1,124 @@ { "sourceLanguage": "en", "strings": { + "mobile.cloud.action.restart": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu starten" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新启动" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시작" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再起動" + } + } + } + }, + "mobile.cloud.action.restartFailedFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't restart: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neustart nicht möglich: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de redémarrer : %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت إعادة التشغيل: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo reiniciar: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法重新啟動:%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法重新启动:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시작할 수 없음: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再起動できませんでした: %@" + } + } + } + }, "mobile.workspace.newOnMachine": { "extractionState": "manual", "localizations": { diff --git a/skills/cmux-cloud-vm/references/commands.md b/skills/cmux-cloud-vm/references/commands.md index 3efe2d934850..43ad80354e86 100644 --- a/skills/cmux-cloud-vm/references/commands.md +++ b/skills/cmux-cloud-vm/references/commands.md @@ -152,6 +152,16 @@ cmux vm stats [--json] # alias: cmux vm top Socket `vm.stats`. CPU, memory, and disk right now; a sleeping machine reports `asleep` and is not woken. `--json`: `{id, state: awake|asleep, cpu_percent, cpus, memory_used_mb, memory_total_mb, disk_used_mb, disk_total_mb}`. The router uses this to pick the least-loaded pool machine. +### `cmux vm restart` + +```bash +cmux vm restart [--json] +``` + +Requests hard recovery through a provider control-plane operation that discards a frozen guest's memory and boots its existing disk. The server currently rejects this action when the provider does not advertise a verified restart capability; the Freestyle API in this release exposes pause/start only, and pause/start resumes the same memory rather than recovering a frozen guest. The machine's disk and identity are preserved when a provider advertises the operation. + +Socket `vm.restart` is the same capability-gated recovery action used by the macOS machine menu and iOS Cloud controls. It does not run a guest command or infer guest health from provider-running state. + ### `cmux vm resize` ```bash @@ -805,7 +815,7 @@ cmux rpc [json-params] # call any v2 method directly, e.g. cmux | `vm.stats` | `vm stats`; the router's load scoring | | `vm.env_set` | `vm env set` | | `vm.file_put` | `vm push` | -| `vm.pause`, `vm.resume` | `vm pause`, `vm resume` | +| `vm.pause`, `vm.resume`, `vm.restart` | `vm pause`, `vm resume`, `vm restart` | | `vm.reflection` | `vm self` | | `vm.snapshot_list`, `vm.snapshot_delete` | `cmux rpc vm.snapshot_list`, `cmux rpc vm.snapshot_delete` | | `vm.terminal_output`, `vm.terminal_wait_exit` | `vm terminal output`, `vm terminal wait-exit` | @@ -851,6 +861,7 @@ The current CLI also exposes these machine-scoped operations: cmux vm self [] [--json] cmux vm pause cmux vm resume +cmux vm restart cmux vm dev [] [--name ] [--layout ] [--no-open] cmux vm layout export [] [--raw] cmux vm layout apply |- [--name ] [--workspace ] diff --git a/web/app/api/vm/[id]/restart/route.ts b/web/app/api/vm/[id]/restart/route.ts new file mode 100644 index 000000000000..3b65b7d1c6eb --- /dev/null +++ b/web/app/api/vm/[id]/restart/route.ts @@ -0,0 +1,38 @@ +import { + jsonResponse, + resolveVmRouteAccountScope, + withAuthedVmApiRoute, +} from "../../../../../services/vms/routeHelpers"; +import { setSpanAttributes } from "../../../../../services/telemetry"; +import { runVmRoute } from "../../../../../services/vms/routeWorkflow"; +import { restartVm } from "../../../../../services/vms/workflows"; + +// Force a provider-level reboot. The route never probes the guest first, so a +// machine whose exec/daemon channel is frozen can still be recovered. +export const maxDuration = 300; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + return withAuthedVmApiRoute( + request, + "/api/vm/[id]/restart", + { "cmux.vm.operation": "restart" }, + "/api/vm/[id]/restart POST failed", + async ({ user, span }) => { + const { id } = await params; + const account = resolveVmRouteAccountScope(user, request); + if (!account.ok) return account.response; + setSpanAttributes(span, { "cmux.vm.id": id }); + const run = await runVmRoute(restartVm({ + userId: user.id, + billingTeamId: account.entitlements.billingTeamId, + teamIds: user.teamIds, + providerVmId: id, + }), { request, deadline: { operation: "restart" } }); + if (!run.ok) return run.response; + return jsonResponse({ id: run.value.id, status: run.value.status }); + }, + ); +} diff --git a/web/messages/ar.json b/web/messages/ar.json index d0dfc5fa08e4..1cc76cda2849 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "الجهاز لا يستجيب", "message": "لم يستجب هذا الجهاز في الوقت المحدد، لذا تعذّر إكمال الطلب.", - "action": "أوقف الجهاز مؤقتًا، ثم استأنفه، وحاول مرة أخرى. في تطبيق Mac، اختر «إيقاف الجهاز مؤقتًا» ثم «استئناف الجهاز». في الطرفية، شغّل cmux vm pause '' ثم cmux vm resume ''." + "action": "اتصل بالدعم مع معرّف التتبّع قبل إنشاء جهاز آخر." }, "inProgress": { "title": "لا يزال قيد التنفيذ", diff --git a/web/messages/bs.json b/web/messages/bs.json index 02d816609bc1..a0c979c0e298 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "Mašina ne odgovara", "message": "Ova mašina nije odgovorila na vrijeme, pa zahtjev nije mogao biti završen.", - "action": "Pauzirajte mašinu, zatim je nastavite i pokušajte ponovo. U Mac aplikaciji odaberite Pauziraj mašinu, zatim Nastavi mašinu. U terminalu pokrenite cmux vm pause '', zatim cmux vm resume ''." + "action": "Obratite se podršci s ID-om praćenja prije nego što napravite novu mašinu." }, "inProgress": { "title": "Još je u toku", diff --git a/web/messages/da.json b/web/messages/da.json index 9e93421ca3bc..b7ddc831e60e 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "Maskinen svarer ikke", "message": "Maskinen svarede ikke i tide, så anmodningen kunne ikke gennemføres.", - "action": "Sæt maskinen på pause, genoptag den, og prøv igen. I Mac-appen skal du vælge Sæt maskine på pause og derefter Genoptag maskine. I en terminal skal du køre cmux vm pause '' og derefter cmux vm resume ''." + "action": "Kontakt support med sporings-id'et, før du opretter en ny maskine." }, "inProgress": { "title": "Stadig i gang", diff --git a/web/messages/de.json b/web/messages/de.json index 5883c5feedac..b669fa637adc 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "Maschine antwortet nicht", "message": "Diese Maschine hat nicht rechtzeitig geantwortet, daher konnte die Anfrage nicht abgeschlossen werden.", - "action": "Pausiere die Maschine, setze sie dann fort und versuche es erneut. Wähle in der Mac-App „Maschine pausieren“ und dann „Maschine fortsetzen“. Führe im Terminal cmux vm pause '' und dann cmux vm resume '' aus." + "action": "Wenden Sie sich mit der Trace-ID an den Support, bevor Sie eine neue Maschine erstellen." }, "inProgress": { "title": "Noch in Bearbeitung", diff --git a/web/messages/en.json b/web/messages/en.json index e68c17da8b1e..ec267a14bf08 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -192,7 +192,7 @@ "guestUnresponsive": { "title": "Machine not responding", "message": "This machine did not respond in time, so the request could not finish.", - "action": "Pause the machine, then resume it, and try again. In the Mac app, choose Pause machine, then Resume machine. In a terminal, run cmux vm pause '', then cmux vm resume ''." + "action": "Contact support with the trace ID before creating another machine." }, "inProgress": { "title": "Still in progress", diff --git a/web/messages/es.json b/web/messages/es.json index e0211da71242..abef5a1dff6e 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "La máquina no responde", "message": "Esta máquina no respondió a tiempo, así que la solicitud no pudo completarse.", - "action": "Pausa la máquina, luego reanúdala y vuelve a intentarlo. En la app de Mac, elige Pausar máquina y luego Reanudar máquina. En una terminal, ejecuta cmux vm pause '' y luego cmux vm resume ''." + "action": "Contacta con soporte con el ID de traza antes de crear otra máquina." }, "inProgress": { "title": "Todavía en curso", diff --git a/web/messages/fr.json b/web/messages/fr.json index 73c8976e359f..2134da0a86e3 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "La machine ne répond pas", "message": "Cette machine n'a pas répondu à temps, la requête n'a donc pas pu aboutir.", - "action": "Mettez la machine en pause, reprenez-la, puis réessayez. Dans l'app Mac, choisissez Mettre la machine en pause, puis Reprendre la machine. Dans un terminal, exécutez cmux vm pause '', puis cmux vm resume ''." + "action": "Contactez le support avec l’ID de trace avant de créer une autre machine." }, "inProgress": { "title": "Toujours en cours", diff --git a/web/messages/it.json b/web/messages/it.json index cc8102d231bc..7045f182a9cd 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "La macchina non risponde", "message": "Questa macchina non ha risposto in tempo, quindi la richiesta non è stata completata.", - "action": "Metti in pausa la macchina, poi riprendila e riprova. Nell'app per Mac, scegli Metti in pausa la macchina, poi Riprendi la macchina. In un terminale, esegui cmux vm pause '', poi cmux vm resume ''." + "action": "Contatta l'assistenza con l'ID di traccia prima di creare un'altra macchina." }, "inProgress": { "title": "Ancora in corso", diff --git a/web/messages/ja.json b/web/messages/ja.json index e1f6889fa3f9..4d61a202659b 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -192,7 +192,7 @@ "guestUnresponsive": { "title": "マシンが応答しません", "message": "このマシンが時間内に応答しなかったため、リクエストを完了できませんでした。", - "action": "マシンを一時停止してから再開し、もう一度お試しください。Mac アプリでは「マシンを一時停止」を選んでから「マシンを再開」を選びます。ターミナルでは cmux vm pause '' を実行してから cmux vm resume '' を実行します。" + "action": "新しいマシンを作成する前に、トレース ID を添えてサポートに連絡してください。" }, "inProgress": { "title": "まだ処理中です", diff --git a/web/messages/km.json b/web/messages/km.json index 1b4203cb21c9..f3197f78c9b8 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "ម៉ាស៊ីនមិនឆ្លើយតប", "message": "ម៉ាស៊ីននេះមិនបានឆ្លើយតបទាន់ពេល ដូច្នេះសំណើមិនអាចបញ្ចប់បានទេ។", - "action": "ផ្អាកម៉ាស៊ីន បន្ទាប់មកបន្តវា ហើយព្យាយាមម្ដងទៀត។ នៅក្នុងកម្មវិធី Mac សូមជ្រើស ផ្អាកម៉ាស៊ីន បន្ទាប់មក បន្តម៉ាស៊ីន។ នៅក្នុងទែមីណាល់ សូមដំណើរការ cmux vm pause '' បន្ទាប់មក cmux vm resume ''។" + "action": "ទាក់ទងផ្នែកជំនួយដោយផ្តល់ Trace ID មុនពេលបង្កើតម៉ាស៊ីនថ្មី។" }, "inProgress": { "title": "នៅតែកំពុងដំណើរការ", diff --git a/web/messages/ko.json b/web/messages/ko.json index bb7eca752439..8152f1b756a1 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "머신이 응답하지 않음", "message": "이 머신이 제시간에 응답하지 않아 요청을 완료할 수 없습니다.", - "action": "머신을 일시 정지한 다음 재개하고 다시 시도하세요. Mac 앱에서는 “머신 일시 정지”를 선택한 다음 “머신 재개”를 선택하세요. 터미널에서는 cmux vm pause ''를 실행한 다음 cmux vm resume ''을 실행하세요." + "action": "새 머신을 만들기 전에 추적 ID를 첨부해 지원팀에 문의하세요." }, "inProgress": { "title": "아직 진행 중", diff --git a/web/messages/no.json b/web/messages/no.json index ae705b922717..e787a89240bf 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "Maskinen svarer ikke", "message": "Denne maskinen svarte ikke i tide, så forespørselen kunne ikke fullføres.", - "action": "Sett maskinen på pause, gjenoppta den, og prøv igjen. I Mac-appen velger du Sett maskin på pause og deretter Gjenoppta maskin. I en terminal kjører du cmux vm pause '' og deretter cmux vm resume ''." + "action": "Kontakt kundestøtte med sporings-ID-en før du oppretter en ny maskin." }, "inProgress": { "title": "Fortsatt i gang", diff --git a/web/messages/pl.json b/web/messages/pl.json index dd69b931f7ae..f696a7606100 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "Maszyna nie odpowiada", "message": "Ta maszyna nie odpowiedziała na czas, więc nie udało się ukończyć żądania.", - "action": "Wstrzymaj maszynę, potem ją wznów i spróbuj ponownie. W aplikacji na Maca wybierz Wstrzymaj maszynę, a potem Wznów maszynę. W terminalu uruchom cmux vm pause '', a potem cmux vm resume ''." + "action": "Skontaktuj się z pomocą techniczną z identyfikatorem śledzenia przed utworzeniem nowej maszyny." }, "inProgress": { "title": "Nadal w toku", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 161f43bb8ca2..ac60b37a60f8 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "A máquina não está respondendo", "message": "Esta máquina não respondeu a tempo, então a solicitação não pôde ser concluída.", - "action": "Pause a máquina, depois retome-a e tente novamente. No app para Mac, escolha Pausar máquina e depois Retomar máquina. Em um terminal, execute cmux vm pause '' e depois cmux vm resume ''." + "action": "Entre em contato com o suporte informando o ID de rastreamento antes de criar outra máquina." }, "inProgress": { "title": "Ainda em andamento", diff --git a/web/messages/ru.json b/web/messages/ru.json index 3b42548f4179..0ca32784f077 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "Машина не отвечает", "message": "Эта машина не ответила вовремя, поэтому запрос не удалось выполнить.", - "action": "Приостановите машину, затем возобновите её и повторите попытку. В приложении для Mac выберите «Приостановить машину», затем «Возобновить машину». В терминале выполните cmux vm pause '', затем cmux vm resume ''." + "action": "Обратитесь в поддержку, указав идентификатор трассировки, прежде чем создавать новую машину." }, "inProgress": { "title": "Ещё выполняется", diff --git a/web/messages/th.json b/web/messages/th.json index bc9bf0ff69db..01c9e1c05270 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "เครื่องไม่ตอบสนอง", "message": "เครื่องนี้ไม่ตอบสนองภายในเวลาที่กำหนด จึงดำเนินการคำขอให้เสร็จไม่ได้", - "action": "หยุดเครื่องชั่วคราว แล้วทำงานต่อ จากนั้นลองอีกครั้ง ในแอป Mac ให้เลือก หยุดเครื่องชั่วคราว แล้วเลือก ทำงานเครื่องต่อ ในเทอร์มินัล ให้เรียกใช้ cmux vm pause '' แล้วตามด้วย cmux vm resume ''" + "action": "ติดต่อฝ่ายสนับสนุนพร้อมรหัสการติดตามก่อนสร้างเครื่องใหม่" }, "inProgress": { "title": "ยังดำเนินการอยู่", diff --git a/web/messages/tr.json b/web/messages/tr.json index 686eda82967c..506a4814fa8d 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "Makine yanıt vermiyor", "message": "Bu makine zamanında yanıt vermediği için istek tamamlanamadı.", - "action": "Makineyi duraklatın, ardından sürdürün ve tekrar deneyin. Mac uygulamasında Makineyi duraklat'ı, ardından Makineyi sürdür'ü seçin. Terminalde cmux vm pause '', ardından cmux vm resume '' çalıştırın." + "action": "Yeni bir makine oluşturmadan önce izleme kimliğiyle desteğe başvurun." }, "inProgress": { "title": "Hâlâ devam ediyor", diff --git a/web/messages/uk.json b/web/messages/uk.json index 6b436f4b70c4..11966d475d85 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "Машина не відповідає", "message": "Ця машина не відповіла вчасно, тому запит не вдалося виконати.", - "action": "Призупиніть машину, потім відновіть її та спробуйте ще раз. У застосунку для Mac виберіть «Призупинити машину», потім «Відновити машину». У терміналі виконайте cmux vm pause '', потім cmux vm resume ''." + "action": "Зверніться до служби підтримки, вказавши ідентифікатор трасування, перш ніж створювати нову машину." }, "inProgress": { "title": "Ще виконується", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 4a32f4e17538..ae01f60efff2 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "机器无响应", "message": "这台机器未能及时响应,因此请求无法完成。", - "action": "先暂停机器,再继续运行,然后重试。在 Mac 应用中,先选择“暂停机器”,再选择“继续运行机器”。在终端中,先运行 cmux vm pause '',再运行 cmux vm resume ''。" + "action": "创建新机器前,请携带跟踪 ID 联系支持团队。" }, "inProgress": { "title": "仍在进行中", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index b6e7f2815e33..582afa96123b 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -33,7 +33,7 @@ "guestUnresponsive": { "title": "機器沒有回應", "message": "這台機器未能及時回應,因此無法完成請求。", - "action": "先暫停機器,再繼續執行,然後重試。在 Mac App 中,先選擇「暫停機器」,再選擇「繼續執行機器」。在終端機中,先執行 cmux vm pause '',再執行 cmux vm resume ''。" + "action": "建立新機器前,請附上追蹤 ID 聯絡支援團隊。" }, "inProgress": { "title": "仍在進行中", diff --git a/web/services/observability/cloudTelemetryContract.ts b/web/services/observability/cloudTelemetryContract.ts index 076138a4b124..b0d98b5661b3 100644 --- a/web/services/observability/cloudTelemetryContract.ts +++ b/web/services/observability/cloudTelemetryContract.ts @@ -4,7 +4,7 @@ export const CLOUD_TELEMETRY_MAX_SPANS = 100; export const CLOUD_TELEMETRY_MAX_AGE_MS = 24 * 60 * 60 * 1000; export const cloudOperations = [ - "create", "open", "list", "status", "stats", "rename", "delete", "pause", "resume", + "create", "open", "list", "status", "stats", "rename", "delete", "pause", "resume", "restart", "snapshot", "fork", "restore", "resize", "exec", "port", "publication", "domain", "base", "session", "workspace", "terminal", "file", "environment", "tunnel", "connect", "refresh", "notification", "agent", "network", "agent_updates", "unknown", diff --git a/web/services/observability/vmAlerts.ts b/web/services/observability/vmAlerts.ts index 03bf3a5e53d9..40915adb2252 100644 --- a/web/services/observability/vmAlerts.ts +++ b/web/services/observability/vmAlerts.ts @@ -13,6 +13,7 @@ const DROPPED_ALERT_REPORT_TIMEOUT_MS = 2_000; const VM_ALERT_REMINDER_WINDOW_MS = 24 * 60 * 60 * 1_000; const VM_ALERT_DELIVERY_LEASE_MS = 5 * 60 * 1_000; const VM_ALERT_SAMPLE_LIMIT = 25; +const VM_HEALTH_STALE_MS = 5 * 60 * 1_000; // The cron runs every five minutes. A daily bucket gives operators a reminder // without creating a new PostHog event for every tick of a persistent outage. const DROPPED_ALERT_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1_000; @@ -26,6 +27,8 @@ export type VmAlertSummary = { readonly createFailures: VmAlertCheckSummary; readonly stuckProvisioning: VmAlertCheckSummary; readonly expiredUnrevokedLeases: VmAlertCheckSummary; + /** Running VMs whose guest resource heartbeat has gone stale. */ + readonly health: VmAlertCheckSummary; /** Account-wide provider TLS rule usage; null when no provider could be read. */ readonly tlsRuleCapacity: (VmAlertCheckSummary & { readonly limit: number }) | null; /** @@ -74,12 +77,14 @@ export async function runVmAlertChecks(options: { const createFailureThreshold = positiveIntegerEnv(env.CMUX_VM_ALERT_CREATE_FAILURES_15M, 3); const expiredLeaseThreshold = positiveIntegerEnv(env.CMUX_VM_ALERT_EXPIRED_LEASES, 50); + const healthStaleMs = positiveIntegerEnv(env.CMUX_VM_ALERT_HEALTH_STALE_MS, VM_HEALTH_STALE_MS); const createFailureSince = new Date(now.getTime() - 15 * 60 * 1000); const stuckProvisioningBefore = new Date(now.getTime() - 20 * 60 * 1000); const createFailures = await countCreateFailures(db, createFailureSince); const stuckProvisioning = await listStuckProvisioningVms(db, stuckProvisioningBefore); const expiredLeases = await listExpiredUnrevokedLeases(db, now); + const unhealthy = await listUnhealthyVms(db, new Date(now.getTime() - healthStaleMs)); const tlsRuleCapacity = await checkTlsRuleCapacity(options.readTlsRuleUsage, env, alertStateStore, now); const triggeredAlerts: AlertInput[] = []; @@ -123,6 +128,8 @@ export async function runVmAlertChecks(options: { }); } + triggeredAlerts.push(...healthAlerts(unhealthy, healthStaleMs)); + triggeredAlerts.push(...tlsRuleCapacity.alerts); const triggeredKeys = new Set(triggeredAlerts.map((alert) => alert.key)); @@ -130,6 +137,7 @@ export async function runVmAlertChecks(options: { "vm-create-failure-spike", "vm-stuck-provisioning", "vm-expired-unrevoked-leases", + "vm-health-stale", ]) { if (!triggeredKeys.has(key)) await clearAlertState(alertStateStore, key, now); } @@ -162,6 +170,10 @@ export async function runVmAlertChecks(options: { triggered: expiredLeases.count > expiredLeaseThreshold, count: expiredLeases.count, }, + health: { + triggered: unhealthy.count > 0, + count: unhealthy.count, + }, tlsRuleCapacity: tlsRuleCapacity.summary, alertSink: { configured: alertsConfigured, @@ -394,6 +406,57 @@ async function listExpiredUnrevokedLeases( return { count: Number(countRow?.total ?? 0), samples }; } +async function listUnhealthyVms( + db: ReturnType, + before: Date, +): Promise<{ + readonly count: number; + readonly samples: Array<{ readonly id: string; readonly updatedAt: Date }>; +}> { + const stale = and( + eq(cloudVms.status, "running"), + // Only classify rows that have reported at least once. A legacy image + // without the reporter is unknown rather than silently treated as dead; + // new images and the stats route establish this heartbeat on first use. + sql`(${cloudVms.providerMetadata}->'cmuxResourceUsage'->>'providerVmId') = ${cloudVms.providerVmId}`, + sql`CASE WHEN ((${cloudVms.providerMetadata}->'cmuxResourceUsage'->>'receivedAt') ~ '^[0-9]+$') + THEN ((${cloudVms.providerMetadata}->'cmuxResourceUsage'->>'receivedAt')::double precision) + ELSE NULL END < ${before.getTime()}`, + ); + // Count and sample in one statement so an alert never reports a count from + // one database snapshot alongside IDs from another. + const rows = await db + .select({ + id: cloudVms.id, + updatedAt: cloudVms.updatedAt, + total: sql`count(*) over ()`, + }) + .from(cloudVms) + .where(stale) + .limit(VM_ALERT_SAMPLE_LIMIT); + return { + count: Number(rows[0]?.total ?? 0), + samples: rows.map(({ id, updatedAt }) => ({ id, updatedAt })), + }; +} + +function healthAlerts( + unhealthy: Awaited>, + staleMs: number, +): AlertInput[] { + if (unhealthy.count === 0) return []; + return [{ + key: "vm-health-stale", + title: "Cloud VM health heartbeat is stale", + body: [ + `${unhealthy.count} running Cloud VM(s) have not reported guest health for ${Math.round(staleMs / 60_000)} minutes.`, + `VM ids: ${unhealthy.samples.length ? unhealthy.samples.map((sample) => sample.id).join(", ") : "unknown"}.`, + "Check guest connectivity and reporter health before classifying the machine as frozen. Provider-running alone does not establish guest health.", + ].join(" "), + severity: "critical", + }]; +} + function formatAge(start: Date, now: Date): string { const minutes = Math.max(0, Math.floor((now.getTime() - start.getTime()) / 60_000)); if (minutes >= 24 * 60) return `${Math.floor(minutes / (24 * 60))}d old`; diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index 727d3b3a7924..f97a585bb65a 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -1120,9 +1120,13 @@ export class FreestyleProvider implements VMProvider { // Do not let an account/provider idle default turn a persistent // machine into a one-shot box. Explicit pause/stop still works. idleTimeoutSeconds: FREESTYLE_PERSISTENT_IDLE_TIMEOUT_SECONDS, - ...(options.runtimeBudgetSeconds !== undefined ? { - maxRunTotalSeconds: Math.max(0, Math.floor(options.runtimeBudgetSeconds)), automaticRestart: false, - } : {}), + // Be explicit even for normal machines: images created under an + // account default with automatic restart disabled must not remain + // unrecoverable after a provider/VMM failure. + automaticRestart: options.runtimeBudgetSeconds === undefined, + ...(options.runtimeBudgetSeconds !== undefined + ? { maxRunTotalSeconds: Math.max(0, Math.floor(options.runtimeBudgetSeconds)) } + : {}), metadata: { cmux: "cloud" }, firewall: { rules: freestyleFirewallRules({ publicDaemonIngress: !networkId, memberIngressNetworkId: options.network?.memberIngress ? networkId : undefined, networkRules: options.networkRules }) }, ...(networkId ? { vpcs: [{ vpcId: networkId, ipv4: true, ipv6: true }] } : {}), @@ -1303,6 +1307,11 @@ export class FreestyleProvider implements VMProvider { ); } + // Hard restart is deliberately unavailable. The documented provider API + // can pause/start, which resumes the same memory and cannot recover a frozen + // guest. Do not advertise restart or call an undocumented endpoint until a + // memory-discarding operation with completion/retry semantics is available. + async setRuntimeBudget(vmId: string, remainingSeconds: number | null): Promise { const vm = this.deps.client(CREATE_TIMEOUT_MS).vms.ref(vmId); if (remainingSeconds === null) { @@ -1601,6 +1610,7 @@ export class FreestyleProvider implements VMProvider { const { vm, vmId, data } = await fs.vms.create({ snapshotId, displayName: "cmux Cloud VM", + automaticRestart: true, idleTimeoutSeconds: FREESTYLE_PERSISTENT_IDLE_TIMEOUT_SECONDS, metadata: { cmux: "cloud" }, firewall: { rules: freestyleFirewallRules({ publicDaemonIngress: !networkId, memberIngressNetworkId: options?.network?.memberIngress ? networkId : undefined, networkRules: options?.networkRules }) }, diff --git a/web/services/vms/drivers/index.ts b/web/services/vms/drivers/index.ts index 5f2404b98423..df088afcfc82 100644 --- a/web/services/vms/drivers/index.ts +++ b/web/services/vms/drivers/index.ts @@ -73,6 +73,7 @@ export function vmCapabilitiesOf(provider: VMProvider): VmCapabilities { fork: declared.fork ?? typeof provider.fork === "function", exec: declared.exec ?? true, stats: declared.stats ?? typeof provider.getStats === "function", + restart: declared.restart ?? typeof provider.restart === "function", ports: declared.ports ?? typeof provider.openPort === "function", desktop: declared.desktop ?? false, sizing: declared.sizing ?? false, diff --git a/web/services/vms/drivers/types.ts b/web/services/vms/drivers/types.ts index 2939f5f7b3b8..501435862736 100644 --- a/web/services/vms/drivers/types.ts +++ b/web/services/vms/drivers/types.ts @@ -33,6 +33,8 @@ export type VmPauseState = "paused" | "in_progress" | "not_paused" | "stopped" | /// reading: they report `asleep` with only their provisioned memory. export type VMStats = { readonly state: "awake" | "asleep" | "unknown"; + /** Health of the guest reporter, independent of provider power state. */ + readonly health?: "healthy" | "unknown" | "unresponsive"; readonly sampledAt: number; /** Timestamp of the latest guest reporter sample, even when it is stale. */ readonly resourceSampledAt?: number; @@ -424,6 +426,8 @@ export interface VmCapabilities { readonly exec: boolean; /** Live CPU/memory/disk readings (`GET /api/vm/:id/stats`). */ readonly stats: boolean; + /** Provider-level recovery for a frozen machine (`POST /api/vm/:id/restart`). */ + readonly restart: boolean; /** Token-gated HTTPS preview URLs for arbitrary VM ports (`POST /api/vm/:id/open-port`). */ readonly ports: boolean; /** A desktop (VNC) image exists for this provider. */ @@ -595,6 +599,8 @@ export interface VMProvider { getStats?(vmId: string, options?: ProviderReadOptions): Promise; /** Optional direct guest probe; must not install tooling, mutate, or wake. */ getResourceStats?(vmId: string): Promise; + /** Force a provider-level reboot without using the guest exec channel. */ + restart?(vmId: string): Promise; /** Grow one or more VM resources. Freestyle currently uses storage only. */ resize?(vmId: string, options: VMResizeOptions): Promise; /** Converge a live machine's outbound rules on `plan`. Must not restart or wake it. */ diff --git a/web/services/vms/providerGateway.ts b/web/services/vms/providerGateway.ts index 63c60f1b9197..9998c72fc3e3 100644 --- a/web/services/vms/providerGateway.ts +++ b/web/services/vms/providerGateway.ts @@ -115,6 +115,8 @@ export type VmProviderGatewayShape = { provider: ProviderId, vmId: string, ) => Effect.Effect; + /** Force a provider-level reboot; unlike resume this does not probe the guest first. */ + readonly restart?: (provider: ProviderId, vmId: string) => Effect.Effect; readonly resize?: ( provider: ProviderId, vmId: string, @@ -405,6 +407,13 @@ export const VmProviderGatewayLive = Layer.succeed(VmProviderGateway, { } return await impl.getResourceStats(vmId); }), + restart: (provider, vmId) => { + const impl = getProvider(provider); + if (!vmCapabilitiesFor(provider).restart || !impl.restart) { + return Effect.fail(new VmOperationUnsupportedError({ provider, operation: "restart" })); + } + return providerEffect(provider, "restart", () => impl.restart!(vmId)); + }, resize: (provider, vmId, options) => { const impl = getProvider(provider); if (!impl.resize) return Effect.fail(new VmOperationUnsupportedError({ provider, operation: "resize" })); diff --git a/web/services/vms/routeDeadline.ts b/web/services/vms/routeDeadline.ts index b3c204182967..d64b3b2d5f05 100644 --- a/web/services/vms/routeDeadline.ts +++ b/web/services/vms/routeDeadline.ts @@ -29,9 +29,10 @@ import { currentVmRequestContext } from "./requestContext"; * through `after()` until the route's maxDuration, then it is interrupted. * A mutation answers `vm_operation_in_progress` so the caller checks status * before retrying: pause reconciles on the provider's raw pause state, resume - * waits out a start already under way, destroy tolerates a machine already - * gone, snapshot and resize refuse a concurrent duplicate, and create-likes - * reuse the Idempotency-Key. + * waits out a start already under way, destroy tolerates a machine already gone, + * snapshot and resize refuse a concurrent duplicate, and create-likes reuse + * the Idempotency-Key. Restart remains unsupported until the provider has a + * hard-reboot contract with completion and duplicate-request semantics. */ /** Time left for the response to reach the client after the route answers. */ @@ -92,6 +93,8 @@ export const VM_ROUTE_DEADLINES = { restore: { clientTimeoutMs: 960_000, maxDurationMs: 600_000, mutating: true }, /** VMClient.updateNetworkPolicy 60 s. */ networkUpdate: { clientTimeoutMs: 60_000, maxDurationMs: 300_000, mutating: true }, + /** Provider-level recovery for a frozen machine; it never waits on guest exec. */ + restart: { clientTimeoutMs: 60_000, maxDurationMs: 300_000, mutating: true }, /** No native caller; the 60 s URLRequest default. */ filesWrite: { clientTimeoutMs: 60_000, maxDurationMs: 300_000, mutating: true }, /** Mac VMClient.resize and CLI 120 s. */ @@ -402,8 +405,9 @@ export function isVmProviderHang(error: VmWorkflowError): boolean { /** * A hung mutation that may still complete at the provider. A stuck guest - * (#19307's vm_guest_unresponsive) is excluded: that answer names the - * machine and its restart action on every route. + * (#19307's vm_guest_unresponsive) is excluded: that answer preserves the + * machine and gives support guidance instead of claiming an unverified + * recovery operation. */ function isUnfinishedMutation(error: VmWorkflowError): boolean { return isVmProviderHang(error) && !(error._tag === "VmProviderOperationError" && hasProviderGuestUnresponsiveCause(error.cause)); diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index 7b7d71c53f7d..d47635712847 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -1437,8 +1437,9 @@ const VM_PROVIDER_CAPACITY_RETRY_AFTER_SECONDS = 30; /** * The guest did not run or finish a command the operation needed, so the * provider's exec channel to that machine is stuck. A retry usually meets the - * same stuck guest, so the answer is non-retryable with a pause and resume - * action that restarts it. Provider diagnostics stay in server traces. + * same stuck guest, so the answer is non-retryable and preserves the machine. + * Provider diagnostics stay in server traces; recovery is only advertised when + * a provider declares a verified hard-restart capability. */ async function vmGuestUnresponsiveResponse(error: VmProviderOperationError, locale: Locale): Promise { const copy = await vmGuestUnresponsiveCopy(locale); @@ -1711,6 +1712,7 @@ function vmPhaseForOperation(operation: string): VmLifecyclePhase { if (operation.includes("restore")) return "restore"; if (operation.includes("fork")) return "fork"; if (operation.includes("snapshot")) return "snapshot"; + if (operation.includes("restart")) return "resume"; if (operation.includes("resume")) return "resume"; if (operation.includes("resize")) return "resize"; if (operation.includes("exec")) return "exec"; diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index c70de0cbe829..db59ba094227 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -2338,6 +2338,64 @@ export function resumeVm(input: { }); } +export type VmRestartResult = { + readonly id: string; + readonly status: "running"; +}; + +/** + * Reboot a VM through the provider control plane. There is deliberately no + * status or guest probe before this call: those are the channels a frozen VM + * has stopped answering. A successful provider restart is recorded as running + * and the normal attach retry observes when the daemon is ready again. + */ +export function restartVm(input: { + readonly userId: string; + readonly billingTeamId?: string | null; + readonly teamIds?: readonly string[]; + readonly providerVmId: string; +}): Effect.Effect { + return Effect.gen(function* () { + const repo = yield* VmRepository; + const providers = yield* VmProviderGateway; + // Use the ownership/access read directly. The accessible variant can + // refresh legacy resource reservations with getStats; that provider read + // is exactly what a frozen VM cannot answer. + const vm = yield* requireUserVm(input); + const providerVmId = vm.providerVmId ?? input.providerVmId; + if (vm.status === "destroyed") { + return yield* Effect.fail(new VmNotFoundError({ vmId: input.providerVmId })); + } + if (!providers.restart || providers.capabilities?.(vm.provider).restart === false) { + return yield* Effect.fail(new VmOperationUnsupportedError({ provider: vm.provider, operation: "restart" })); + } + yield* providers.restart(vm.provider, providerVmId).pipe( + Effect.catchAll((error): Effect.Effect => isProviderNotFoundError(error) + ? Effect.fail(new VmNotFoundError({ vmId: input.providerVmId })) + : Effect.fail(error)), + ); + const recorded = yield* repo.markProviderObservedStatus({ + id: vm.id, + providerVmId, + status: "running", + }); + if (!recorded) { + return yield* Effect.fail(new VmNotFoundError({ vmId: input.providerVmId })); + } + yield* repo.recordUsageEvent({ + userId: vm.userId, + billingTeamId: vm.billingTeamId, + billingPlanId: vm.billingPlanId, + vmId: vm.id, + eventType: "vm.restarted", + provider: vm.provider, + imageId: vm.imageId, + metadata: { source: "user", hard: true }, + }).pipe(Effect.catchAll(() => Effect.void)); + return { id: providerVmId, status: "running" } satisfies VmRestartResult; + }); +} + type SnapshotProviderResources = { readonly vcpus: number | null; readonly memoryMb: number | null; @@ -4463,7 +4521,9 @@ export function getVmStats(input: { // Without a recent guest sample we cannot claim that the guest is // awake; reporting `unknown` prevents the UI and CLI from showing a // healthy machine with empty utilization after the guest wedges. - return guestSampleFresh ? value : { ...value, state: "unknown" }; + return guestSampleFresh + ? { ...value, health: "healthy" } + : { ...value, state: "unknown", health: value.health ?? "unknown" }; }; // The private development backend cannot receive production-edge reports. // Production keeps the push path. Never probe non-awake machines, and @@ -4478,8 +4538,10 @@ export function getVmStats(input: { [VM_RESOURCE_USAGE_KEY]: { ...sample, providerVmId: input.providerVmId, receivedAt: sample.resourceSampledAt, }, - }, input.providerVmId, Date.now()) : reported), - Effect.catchAll((error) => isProviderNotFoundError(error) ? Effect.fail(error) : Effect.succeed(reported)), + }, input.providerVmId, Date.now()) : { ...reported, health: "unresponsive" as const }), + Effect.catchAll((error) => isProviderNotFoundError(error) + ? Effect.fail(error) + : Effect.succeed({ ...reported, health: "unresponsive" as const })), ); return resourceRead.pipe(Effect.map(withGuestHealth)); }), diff --git a/web/tests/vm-alerts.test.ts b/web/tests/vm-alerts.test.ts index a7d2b5c2d944..fb2bcfb32a0e 100644 --- a/web/tests/vm-alerts.test.ts +++ b/web/tests/vm-alerts.test.ts @@ -140,6 +140,7 @@ describe("VM alert checks", () => { createFailures: { triggered: true, count: 3 }, stuckProvisioning: { triggered: true, count: 1 }, expiredUnrevokedLeases: { triggered: true, count: 51 }, + health: { triggered: false, count: 0 }, tlsRuleCapacity: null, alertSink: { configured: false, droppedAlerts: 0 }, }); @@ -199,6 +200,51 @@ describe("VM alert checks", () => { expect(attempts).toBe(2); }); + dbTest("alerts when a running VM's guest health heartbeat is stale", async () => { + if (!sql) throw new Error("test database not initialized"); + await sql`truncate cloud_vm_alert_states, cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; + const now = new Date("2026-07-04T12:00:00.000Z"); + await sql` + insert into cloud_vms ( + user_id, billing_team_id, billing_plan_id, provider, provider_vm_id, image_id, status, provider_metadata, updated_at + ) + values ( + 'user-health', 'team-health', 'free', 'freestyle', 'provider-health', 'snapshot-health', 'running', + jsonb_build_object('cmuxResourceUsage', jsonb_build_object( + 'providerVmId', 'provider-health', 'receivedAt', ${now.getTime() - 6 * 60 * 1000}::bigint + )), ${new Date(now.getTime() - 6 * 60 * 1000)} + ) + `; + // A stopped allocation, missing heartbeat, foreign sample, malformed + // timestamp, and current heartbeat do not establish a frozen running guest. + for (const [suffix, status, sample] of [ + ["paused", "paused", { providerVmId: "excluded-paused", receivedAt: 1 }], + ["missing", "running", {}], + ["foreign", "running", { providerVmId: "another-vm", receivedAt: 1 }], + ["invalid", "running", { providerVmId: "excluded-invalid", receivedAt: "unknown" }], + ["fresh", "running", { providerVmId: "excluded-fresh", receivedAt: now.getTime() }], + ] as const) { + await sql` + insert into cloud_vms (user_id, provider, provider_vm_id, image_id, status, provider_metadata) + values ('user-health', 'freestyle', ${`excluded-${suffix}`}, 'snapshot-health', + ${status}, ${sql.json({ cmuxResourceUsage: sample })}) + `; + } + const alerts: AlertInput[] = []; + const summary = await runVmAlertChecks({ + db: cloudDb(), + now, + env: { CMUX_VM_ALERT_HEALTH_STALE_MS: String(5 * 60 * 1000) }, + sendAlert: async (input): Promise => { + alerts.push(input); + return { sent: true, configured: true, status: 200 }; + }, + }); + expect(summary.health).toEqual({ triggered: true, count: 1 }); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ key: "vm-health-stale", severity: "critical" }); + }); + dbTest("does not page the create-failure spike for reconciliation housekeeping", async () => { if (!sql) throw new Error("test database not initialized"); await sql`truncate cloud_vm_alert_states, cloud_vm_billing_grants, cloud_vm_usage_events, cloud_vm_leases, cloud_vms restart identity cascade`; diff --git a/web/tests/vm-capabilities.test.ts b/web/tests/vm-capabilities.test.ts index 34c91d933c8d..b39945419380 100644 --- a/web/tests/vm-capabilities.test.ts +++ b/web/tests/vm-capabilities.test.ts @@ -11,12 +11,14 @@ import { MockVMProvider } from "../services/vms/drivers/mock"; describe("vm capability derivation", () => { test("freestyle: derived from the real driver, not hardcoded", () => { const caps = vmCapabilitiesFor("freestyle"); + expect(caps.restart).toBe(false); expect(caps).toEqual({ snapshot: true, restore: true, fork: false, exec: true, stats: true, + restart: false, // Port previews ride the platform's TLS edge (style.dev capability URLs). ports: true, desktop: true, @@ -38,6 +40,7 @@ describe("vm capability derivation", () => { const caps = vmCapabilitiesOf(new MockVMProvider({ features: { cmuxRemote: false } })); expect(caps.fork).toBe(false); expect(caps.stats).toBe(false); + expect(caps.restart).toBe(false); expect(caps.ports).toBe(false); expect(caps.desktop).toBe(false); expect(caps.sizing).toBe(false); diff --git a/web/tests/vm-freestyle-provider.test.ts b/web/tests/vm-freestyle-provider.test.ts index b4c64c191872..b0b9ac3d8c8c 100644 --- a/web/tests/vm-freestyle-provider.test.ts +++ b/web/tests/vm-freestyle-provider.test.ts @@ -707,9 +707,15 @@ describe("FreestyleProvider create with edge rules", () => { // explicitly pauses or destroys it. The explicit -1 also overrides a // provider/account default that would otherwise idle-pause the VM. idleTimeoutSeconds: -1, + automaticRestart: true, }); }); + test("does not expose hard restart without a supported provider contract", () => { + const provider: VMProvider = providerWith(fakeFreestyle({ probeExit: 0 })); + expect(provider.restart).toBeUndefined(); + }); + test("passes the rule inline and returns a snapshot-v2 machine without guest setup", async () => { const fake = fakeFreestyle({ probeExit: 0 }); const handle = await providerWith(fake).create({ @@ -770,6 +776,7 @@ describe("FreestyleProvider create with edge rules", () => { expect(restored.image).toBe("snap-1"); expect(ok.creates[0]).toMatchObject({ snapshotId: "snap-1", + automaticRestart: true, idleTimeoutSeconds: FREESTYLE_PERSISTENT_IDLE_TIMEOUT_SECONDS, tls: { rules: freestyleEdgeRules([EDGE_RULE]) }, }); diff --git a/web/tests/vm-freestyle-stats.test.ts b/web/tests/vm-freestyle-stats.test.ts index 02ccfa2e7e09..2aecc8a00465 100644 --- a/web/tests/vm-freestyle-stats.test.ts +++ b/web/tests/vm-freestyle-stats.test.ts @@ -109,7 +109,7 @@ describe("Freestyle live machine stats", () => { const receivedAt = Date.now(); const { result, resourceCalls } = await readStats("running", { ...gauges, receivedAt, providerVmId: "vm-stats", diskTotalMb: 1 }); expect(resourceCalls).toHaveLength(0); - expect(result).toEqual({ state: "awake", sampledAt: receivedAt, resourceSampledAt: receivedAt, cpus: 2, memoryTotalMb: 4096, diskTotalMb: 16384, ...gauges }); + expect(result).toEqual({ state: "awake", health: "healthy", sampledAt: receivedAt, resourceSampledAt: receivedAt, cpus: 2, memoryTotalMb: 4096, diskTotalMb: 16384, ...gauges }); }); test("direct dev backends sample an awake guest when callback telemetry is unavailable", async () => { @@ -138,6 +138,7 @@ describe("Freestyle live machine stats", () => { expect(resourceCalls).toEqual([]); expect(result.state).toBe("unknown"); expect(result.cpuPercent).toBeUndefined(); + expect(result.health).toBe("unknown"); }); test("the explicit operator override still enables direct sampling", async () => { @@ -164,12 +165,12 @@ describe("Freestyle live machine stats", () => { { label: "a failed probe", resourceExitCode: 1 }, { label: "malformed output", resourceOutput: "not json" }, { label: "invalid gauges", resourceOutput: { cpuPercent: 101 } }, - ])("$label keeps provisioned capacity without inventing usage", async ({ resourceExitCode, resourceOutput }) => { + ])("$label reports an unknown/unresponsive guest while keeping provisioned capacity", async ({ resourceExitCode, resourceOutput }) => { const { result, resourceCalls } = await readStats("running", undefined, { directBackend: true, resourceExitCode, resourceOutput, }); expect(resourceCalls).toHaveLength(1); - expect(result).toMatchObject({ state: "unknown", cpus: 2, memoryTotalMb: 4096, diskTotalMb: 16384 }); + expect(result).toMatchObject({ state: "unknown", health: "unresponsive", cpus: 2, memoryTotalMb: 4096, diskTotalMb: 16384 }); expect(result.cpuPercent).toBeUndefined(); expect(result.memoryUsedMb).toBeUndefined(); expect(result.diskUsedMb).toBeUndefined(); diff --git a/web/tests/vm-pause-resume-route.test.ts b/web/tests/vm-pause-resume-route.test.ts index 2184cfd4607d..b95e6b866eb1 100644 --- a/web/tests/vm-pause-resume-route.test.ts +++ b/web/tests/vm-pause-resume-route.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; import type { AuthedUser } from "../services/vms/auth"; -// POST /api/vm/[id]/pause and /resume: the route's contract around the +// POST /api/vm/[id]/pause, /resume, and /restart: the route's contract around the // workflows (auth gate, the program's inputs, the 200 body, and error // pass-through). The workflows themselves are covered by // vm-pause-resume-workflow.test.ts. bun's mock.module is process-global, so @@ -10,7 +10,7 @@ import type { AuthedUser } from "../services/vms/auth"; let active = false; let authedUser: AuthedUser | null = null; -const programs: Array<{ kind: "pause" | "resume"; input: Record }> = []; +const programs: Array<{ kind: "pause" | "resume" | "restart"; input: Record }> = []; let routeResult: | { ok: true; value: { id: string; status: "paused" | "running" } } | { ok: false; response: Response } = { ok: true, value: { id: "fs-1", status: "paused" } }; @@ -20,6 +20,7 @@ const realVerifyRequest = realAuth.verifyRequest; const realWorkflows = await import("../services/vms/workflows"); const realPauseVm = realWorkflows.pauseVm; const realResumeVm = realWorkflows.resumeVm; +const realRestartVm = realWorkflows.restartVm; const realRouteWorkflow = await import("../services/vms/routeWorkflow"); const realRunVmRoute = realRouteWorkflow.runVmRoute; @@ -41,19 +42,25 @@ mock.module("../services/vms/workflows", () => ({ programs.push({ kind: "resume", input: input as Record }); return { sentinel: "resume" } as unknown as ReturnType; }) as typeof realResumeVm, + restartVm: ((input: Parameters[0]) => { + if (!active) return realRestartVm(input); + programs.push({ kind: "restart", input: input as Record }); + return { sentinel: "restart" } as unknown as ReturnType; + }) as typeof realRestartVm, })); mock.module("../services/vms/routeWorkflow", () => ({ ...realRouteWorkflow, runVmRoute: (async (program: unknown, options?: unknown) => { if (!active) return realRunVmRoute(program as never, options as never); - expect((program as { sentinel?: string }).sentinel).toMatch(/^(pause|resume)$/); + expect((program as { sentinel?: string }).sentinel).toMatch(/^(pause|resume|restart)$/); return routeResult; }) as typeof realRunVmRoute, })); const pauseRoute = await import("../app/api/vm/[id]/pause/route"); const resumeRoute = await import("../app/api/vm/[id]/resume/route"); +const restartRoute = await import("../app/api/vm/[id]/restart/route"); const user: AuthedUser = { id: "user-1", @@ -79,7 +86,7 @@ function post(path: string): Request { const params = { params: Promise.resolve({ id: "fs-1" }) }; -describe("POST /api/vm/[id]/pause and /resume", () => { +describe("POST /api/vm/[id]/pause, /resume, and /restart", () => { beforeAll(() => { active = true; }); @@ -120,6 +127,16 @@ describe("POST /api/vm/[id]/pause and /resume", () => { expect("maxActiveVms" in input).toBe(true); }); + test("restart passes the owner's scope without a status or guest preflight", async () => { + routeResult = { ok: true, value: { id: "fs-1", status: "running" } }; + const response = await restartRoute.POST(post("/api/vm/fs-1/restart"), params); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id: "fs-1", status: "running" }); + expect(programs).toHaveLength(1); + expect(programs[0]?.kind).toBe("restart"); + expect(programs[0]?.input).toMatchObject({ userId: "user-1", billingTeamId: "team-1", teamIds: ["team-1"], providerVmId: "fs-1" }); + }); + test("a workflow refusal is returned as the workflow's own response (501 for a provider without pause)", async () => { routeResult = { ok: false, diff --git a/web/tests/vm-provider-conflicts.test.ts b/web/tests/vm-provider-conflicts.test.ts index 2b21ffa67f90..66e63872fa20 100644 --- a/web/tests/vm-provider-conflicts.test.ts +++ b/web/tests/vm-provider-conflicts.test.ts @@ -80,12 +80,15 @@ describe("a machine-state conflict", () => { expect(payload).toMatchObject({ error: "vm_state_conflict" }); }); - test("a machine that does not respond answers the restart action, not an outage", async () => { + test("a machine that does not respond preserves its state and offers support, not an unsupported recovery", async () => { const provider = providerWith({ exec: async () => { throw nonResponsive(); } }); const cause = await failure(() => provider.exec("vm-1", "true", { timeoutMs: 1_000 })); const { response, payload } = await answer("exec", cause); expect(response.status).toBe(503); - expect(payload).toMatchObject({ error: "vm_guest_unresponsive" }); + expect(payload).toMatchObject({ error: "vm_guest_unresponsive", retryable: false }); + expect(String(payload.action)).toMatch(/contact support/i); + expect(String(payload.action)).toMatch(/trace ID/i); + expect(String(payload.action)).not.toMatch(/cmux vm (?:pause|resume|restart|rm)/); }); }); diff --git a/web/tests/vm-read-deadlines.test.ts b/web/tests/vm-read-deadlines.test.ts index 2e2d0ed8122d..6c7d5628e6da 100644 --- a/web/tests/vm-read-deadlines.test.ts +++ b/web/tests/vm-read-deadlines.test.ts @@ -85,9 +85,7 @@ describe("VM read deadlines through the real route path", () => { const run = await statsRoute("vm-ok", 5_000); expect(run.ok).toBe(true); if (!run.ok) return; - // Provider running state is not enough to claim the guest is healthy; - // this fixture has no resource reporter sample, so stats are unknown. - expect(run.value).toMatchObject({ state: "unknown", cpus: 2 }); + expect(run.value).toMatchObject({ state: "unknown", health: "unknown", cpus: 2 }); expect(fetches.find((entry) => entry.path === "/v5/vms/vm-ok")?.signal?.aborted).toBe(false); }); diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 43032b12feda..cbaa67d3c1f2 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -515,6 +515,7 @@ describe("VM REST auth", () => { snapshot: true, restore: true, fork: false, + restart: false, exec: true, stats: true, ports: true, diff --git a/web/tests/vm-route-deadlines.test.ts b/web/tests/vm-route-deadlines.test.ts index 59cab97734a5..cb9b120d5d51 100644 --- a/web/tests/vm-route-deadlines.test.ts +++ b/web/tests/vm-route-deadlines.test.ts @@ -96,6 +96,7 @@ describe("VM route answer deadlines", () => { describe("remaining VM routes", () => { const expected: Array<[VmRouteDeadlineOperation, number, boolean]> = [ ["rename", 55_000, true], + ["restart", 55_000, true], ["tunnelEnroll", 55_000, true], ["tunnelRevoke", 15_000, true], ["attach", 955_000, true], diff --git a/web/tests/vm-scp.test.ts b/web/tests/vm-scp.test.ts index b0dcaee4cc56..c7866b47ebbf 100644 --- a/web/tests/vm-scp.test.ts +++ b/web/tests/vm-scp.test.ts @@ -93,8 +93,9 @@ describe("SCP setup on a machine that does not answer", () => { expect(response?.status).toBe(503); const payload = await response!.json() as Record; expect(payload).toMatchObject({ error: "vm_guest_unresponsive", retryable: false }); - expect(String(payload.action)).toContain("cmux vm pause , then cmux vm resume "); - expect(String(payload.action)).toContain("Pause machine, then Resume machine"); + expect(String(payload.action)).toMatch(/contact support/i); + expect(String(payload.action)).toMatch(/trace ID/i); + expect(String(payload.action)).not.toMatch(/cmux vm (?:pause|resume|restart|rm)/); expect(JSON.stringify(payload)).not.toMatch(/temporarily unavailable/i); }); diff --git a/web/tests/vm-workflows.test.ts b/web/tests/vm-workflows.test.ts index bea9055ff4e6..2e6c730dfbb2 100644 --- a/web/tests/vm-workflows.test.ts +++ b/web/tests/vm-workflows.test.ts @@ -82,11 +82,82 @@ import { renameVm, restoreVm, reconcileVmProviderStatuses, + restartVm, resizeVm, snapshotVm, type VmModelPlaneProvisioner, } from "../services/vms/workflows"; +describe("VM hard restart", () => { + test("restarts a frozen machine without probing its status or guest", async () => { + const vm = testCloudVmRow({ providerVmId: "vm-frozen", status: "running" }); + const usageEvents: RecordedUsageEvent[] = []; + const observedStatuses: ObservedStatusUpdate[] = []; + let restartCalls = 0; + const repo = testWorkflowRepo({ vm, usageEvents, observedStatuses }); + const result = await Effect.runPromise(restartVm({ + userId: vm.userId, + providerVmId: vm.providerVmId!, + }).pipe(Effect.provide(Layer.merge( + Layer.succeed(VmRepository, repo), + Layer.succeed(VmProviderGateway, { + ...unusedProviderGateway(), + restart: (_provider, providerVmId) => Effect.sync(() => { + expect(providerVmId).toBe("vm-frozen"); + restartCalls += 1; + }), + // A frozen provider would never answer this probe. The workflow must + // invoke restart directly instead of requiring a healthy read first. + getStatus: () => Effect.die(new Error("status probe must not run")), + }), + )))); + + expect(result).toEqual({ id: "vm-frozen", status: "running" }); + expect(restartCalls).toBe(1); + expect(observedStatuses).toEqual([{ id: vm.id, providerVmId: "vm-frozen", status: "running" }]); + expect(usageEvents).toContainEqual(expect.objectContaining({ eventType: "vm.restarted", vmId: vm.id })); + }); + + test("declared unsupported restart never mutates lifecycle or records success", async () => { + const vm = testCloudVmRow({ providerVmId: "vm-frozen", status: "running" }); + const usageEvents: RecordedUsageEvent[] = []; + const observedStatuses: ObservedStatusUpdate[] = []; + let restartCalls = 0; + const result = await Effect.runPromise(Effect.either(restartVm({ + userId: vm.userId, providerVmId: vm.providerVmId!, + }).pipe(Effect.provide(Layer.merge( + Layer.succeed(VmRepository, testWorkflowRepo({ vm, usageEvents, observedStatuses })), + Layer.succeed(VmProviderGateway, { + ...unusedProviderGateway(), + capabilities: () => ({ ...vmCapabilitiesFor("freestyle"), restart: false }), + restart: () => Effect.sync(() => { restartCalls += 1; }), + }), + ))))); + expect(result._tag).toBe("Left"); + if (result._tag === "Left") expect(result.left).toBeInstanceOf(VmOperationUnsupportedError); + expect(restartCalls).toBe(0); + expect(observedStatuses).toEqual([]); + expect(usageEvents).toEqual([]); + }); + + test("returns a typed unsupported error when the provider has no hard restart", async () => { + const vm = testCloudVmRow({ providerVmId: "vm-no-restart", status: "running" }); + const result = await Effect.runPromise(Effect.either(restartVm({ + userId: vm.userId, + providerVmId: vm.providerVmId!, + }).pipe(Effect.provide(Layer.merge( + Layer.succeed(VmRepository, testWorkflowRepo({ vm })), + Layer.succeed(VmProviderGateway, unusedProviderGateway()), + ))))); + + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect(result.left).toBeInstanceOf(VmOperationUnsupportedError); + expect(result.left).toMatchObject({ operation: "restart" }); + } + }); +}); + const runDbTests = process.env.CMUX_DB_TEST === "1"; // These cases share one Postgres database and several exercise paths read or // temporarily override process-wide VM plan limits. Keep the DB-backed group From c13b01558fc67a50c8b1706ef1ed09bd16bc30e2 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:06:49 -0700 Subject: [PATCH 019/183] Explain a full Cloud resource pool instead of a missing plan (#20066) * test(cloud): a full shared pool is not reported as a missing plan Co-Authored-By: Claude Opus 5.5 * fix(cloud): explain a full shared pool instead of a missing plan A 402 vm_resource_pool_exceeded on a Pro team read as "Your plan does not allow this Cloud operation." because the client classified every 402 by status alone. Read the server's error code: a full pool names the vCPUs or memory in use and the request (reusing the sheet's pool shortfall copy), active-limit, create-credit and size-gated refusals get their own sentence, an unrecognised code keeps the server's message, and only a code-less 402 keeps the generic plan sentence. The pending create row offers Retry first and an upgrade second for a full pool. Co-Authored-By: Claude Opus 5.5 * fix(cloud): report a full pool as resource_limit and redact server copy Address review: the server's telemetry contract rejects an unknown failure class and drops the whole batch, so a full pool or active-limit refusal is classified as the existing resource_limit instead of a new capacity class. The open-workspace path now applies the same redaction fallback as create, and a full pool offers an upgrade only once the plan is known to have a higher tier. Co-Authored-By: Claude Opus 5.5 * fix(cloud): key create failure actions on the parsed refusal code Address review: the pending create row matched refusal codes as substrings of the display text, so a longer code such as vm_resource_pool_exceeded_v2 counted as a full pool. Read the code once from the exact (HTTP 402: ) reference the CLI and the in-process create both print, and compare it whole. Also translate the four new failure strings into every locale the catalog carries. Co-Authored-By: Claude Opus 5.5 * fix(cloud): carry the refusal code apart from the failure text Address review: reading the code back out of the failure text let a server message that named another code stand in for the real one. The in-process launcher now reads the code from the thrown error and the CLI path reads only the reference ending the CLI's own Error: header; both pass it through the create completion onto the operation, and the failed row's actions key on that carried code alone. A retry clears it. Co-Authored-By: Claude Opus 5.5 * test(cloud): cover the refusal code handoff to the failed row Address review: assert the app coordinator carries a launcher's refusal code onto the pending row and clears it on retry, that finish overwrites a code, and that an open failure on an existing machine keeps none. Co-Authored-By: Claude Opus 5.5 * test(cloud): cover pool upgrade and credit billing scope regressions * fix(cloud): preserve pool upgrades and billing scope * test(localization): cover Unicode escapes in Swift defaults * fix(localization): decode Swift Unicode scalars in defaults * fix(cloud): localize personal create credit refusals * test: correlate v2 mock responses with request ids * Keep localization additions in main catalog order * Start CodeRouter children from a clean signal mask and the caller's stdio The two posix_spawn sites #18953 added in the CLI handed the child the calling Swift concurrency thread's signal mask, so a CodeRouter child could not receive SIGTERM or SIGWINCH (CLIAuthAliasTests: termination signal) and the exec/spawn signal-state guard failed. Set an empty mask and default dispositions on both, as the other CLI spawn sites do. The alias exec also decided which standard descriptors to pass on at spawn time, so a file the CLI had opened since launch could reuse a closed stdin and reach the child as an open one. Record the caller's standard descriptors at the top of main and pass on only those. Co-Authored-By: Claude Opus 5.5 * Assert that `coderouter add` stays off the socket handoff #18953's classification test expected `add codex` to arm the handoff, contradicting its own name ("only exact top-level agent names use socket handoff") and the launch policy. CodeRouter's handoff parser accepts only a provider name after `--`, and `add` does its own login without a handoff lease, so the policy is right and the expectation was wrong. Co-Authored-By: Claude Opus 5.5 * Probe a closed stdin without being fooled by the script itself With descriptor 0 closed, /bin/sh opens the script it runs on that descriptor, so the fake CodeRouter's `(: <&0)` probe always reported an open stdin on macOS and the test could never pass. Treat stdin as open only when descriptor 0 exists and is not the script. Co-Authored-By: Claude Opus 5.5 * test: echo request ids in capabilities fixture --------- Co-authored-by: Claude Opus 5.5 --- .../Machines/MachinePlanSnapshot.swift | 7 + .../Operations/CloudDiagnosticFailure.swift | 20 +- .../Operations/CloudPlanRefusal.swift | 197 ++++++ ...oudDiagnosticFailurePlanRefusalTests.swift | 160 +++++ .../CloudMachineCreateCompletion.swift | 9 +- .../CloudMachineCreateCoordinator.swift | 6 + .../CloudMachineCreateOperation.swift | 4 + .../CloudMachineCreateCoordinatorTests.swift | 35 ++ Resources/Localizable.xcstrings | 559 ++++++++++++++++++ Sources/Cloud/CloudTreeNodeActions.swift | 2 +- .../InProcessMachineCreateLauncher.swift | 14 +- Sources/Cloud/MachineCreateCoordinator.swift | 6 +- Sources/Cloud/MachineCreateOperation.swift | 13 +- Sources/CloudVMActionLauncher.swift | 16 +- .../Panels/MachineCreateLoadingContent.swift | 35 +- cmuxTests/CloudSidebarPolishTests.swift | 32 +- .../InProcessMachineCreateLauncherTests.swift | 21 + cmuxTests/MachineCreateCoordinatorTests.swift | 27 +- scripts/localize_changes.py | 13 + tests/test_localize_changes.py | 8 + 20 files changed, 1158 insertions(+), 26 deletions(-) create mode 100644 Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudPlanRefusal.swift create mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudDiagnosticFailurePlanRefusalTests.swift diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift index 6940c2c4329a..8d2af4905261 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift @@ -55,6 +55,13 @@ public struct MachinePlanSnapshot: Equatable, Sendable { } } + /// Whether Max is a larger plan for shared vCPU and memory capacity. + /// Team and Founders have the same active-machine tier as Max but can still + /// upgrade their shared pool to Max capacity. + public var hasHigherResourcePoolPlan: Bool { + planId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "max" + } + public static func isPaidPlanID(_ planId: String) -> Bool { switch planId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "go", "pro", "max", "team", "founders": diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudDiagnosticFailure.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudDiagnosticFailure.swift index 790d5911332a..16cf122c9265 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudDiagnosticFailure.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudDiagnosticFailure.swift @@ -58,7 +58,7 @@ public enum CloudDiagnosticFailure: String, Codable, Sendable, Error { case .malformedResponse: return .response case .disabledByManagedPolicy, .cloudMachinesDisabled: return .permission case .lifecycleUnsupported: return .unsupported - case .httpStatus(let status, _): return classify(status: status) + case .httpStatus(let status, let body): return classify(status: status, body: body) } } if let error = error as? MachineUsageClientError { @@ -67,7 +67,7 @@ public enum CloudDiagnosticFailure: String, Codable, Sendable, Error { case .sessionRefreshFailed: return .sessionRefresh case .backendUnreachable: return .network case .malformedResponse: return .response - case .httpStatus(let status, _): return classify(status: status) + case .httpStatus(let status, let body): return classify(status: status, body: body) } } if let error = error as? CloudMachineLink.LinkError { @@ -102,6 +102,22 @@ public enum CloudDiagnosticFailure: String, Codable, Sendable, Error { } } + /// Classifies an HTTP answer by its `error` code when the server named + /// one, so a full pool is not reported as a missing plan. + /// - Parameters: + /// - status: The HTTP status. + /// - body: The response body. + public static func classify(status: Int, body: String) -> Self { + CloudPlanRefusal(status: status, body: body)?.diagnosticFailure ?? classify(status: status) + } + + /// The sentence a person sees for a failure: the server's explained + /// refusal when it named one, otherwise the class label. + /// - Parameter error: The thrown error. + public static func message(for error: Error) -> String { + CloudPlanRefusal(error)?.message ?? classify(error).label + } + public static func classify(status: Int) -> Self { switch status { case 401: return .authentication diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudPlanRefusal.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudPlanRefusal.swift new file mode 100644 index 000000000000..64906cbb6b47 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudPlanRefusal.swift @@ -0,0 +1,197 @@ +import CmuxCloudResizeCore +import Foundation + +/// A `402` the control plane explained with a stable `error` code, read so a +/// person sees what actually stopped the request instead of a status-only +/// "your plan does not allow this" sentence. The server enforces every limit; +/// this type only names the one it reported. +public struct CloudPlanRefusal: Equatable, Sendable { + /// What the server refused, from its `error` code. + public enum Kind: Equatable, Sendable { + /// `vm_resource_pool_exceeded`: running machines already hold the + /// team's shared vCPUs or memory. Carries the overflow when the answer + /// named the pool, what is in use, and the request. + case resourcePool(CloudVMResourcePool.Shortfall?) + /// `vm_active_limit_exceeded` with a nonzero limit: every active slot is taken. + case activeLimit + /// `vm_create_credits_insufficient`. + case createCredits + /// `vm_memory_requires_plan`: the size needs a larger plan. + case memoryRequiresPlan + /// Any other code; the server's own message explains it. + case other + } + + /// The server's `error` code. + public let code: String + public let kind: Kind + /// The server's one-line explanation (`ui.message`, else `message`). + public let serverMessage: String? + /// The plan the answer offers as an upgrade (`upgradePlanId`), if any. + public let upgradePlanId: String? + + /// Reads a `402` answer; nil for other statuses or a body without a code. + /// - Parameters: + /// - status: The HTTP status. + /// - body: The response body. + public init?(status: Int, body: String) { + guard status == 402, + let data = body.data(using: .utf8), + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let code = Self.string(object["error"]) else { return nil } + let ui = object["ui"] as? [String: Any] + self.code = code + self.serverMessage = Self.string(ui?["message"]) ?? Self.string(object["message"]) + self.upgradePlanId = Self.string(object["upgradePlanId"]) + switch code { + case Self.resourcePoolExceededCode: + kind = .resourcePool(Self.shortfall(object)) + case Self.activeLimitExceededCode: + // A zero limit is the subscribe gate, not a full set of slots. + let limit = (object["limit"] as? NSNumber)?.intValue + kind = limit == 0 ? .other : .activeLimit + case "vm_create_credits_insufficient": + kind = .createCredits + case "vm_memory_requires_plan": + kind = .memoryRequiresPlan + default: + kind = .other + } + } + + /// Reads the refusal from a Cloud client error; nil for anything else. + /// - Parameter error: The thrown error. + public init?(_ error: Error) { + if case let .httpStatus(status, body)? = error as? VMClientError { + self.init(status: status, body: body) + } else if case let .httpStatus(status, body)? = error as? MachineUsageClientError { + self.init(status: status, body: body) + } else { + return nil + } + } + + /// The diagnostic class: a full pool or full set of active slots is a + /// limit the team's own machines hold; the rest is what the plan includes. + /// Only classes the telemetry contract accepts may be returned here. + public var diagnosticFailure: CloudDiagnosticFailure { + switch kind { + case .resourcePool, .activeLimit: return .resourceLimit + case .createCredits, .memoryRequiresPlan, .other: return .plan + } + } + + /// Whether the answer names a larger plan that would let the request through. + public var offersUpgrade: Bool { upgradePlanId != nil } + + /// Whether a failed-create row can offer a larger plan for this refusal. + /// + /// - Parameters: + /// - code: The refusal code carried by the failed create. + /// - plan: The currently loaded plan, or nil while it is unavailable. + /// - Returns: Whether the row should show its upgrade action. + public static func offersUpgrade(for code: String?, plan: MachinePlanSnapshot?) -> Bool { + if code == activeLimitExceededCode { return plan?.hasHigherPlan ?? true } + return code == resourcePoolExceededCode && plan?.hasHigherResourcePoolPlan == true + } + + /// The localized sentence for a person: what stopped the request and what + /// to do, with an upgrade only as the secondary option. + public var message: String { + switch kind { + case .resourcePool(let shortfall?): + return CloudVMResourcePool.shortfallText(shortfall, offersUpgrade: offersUpgrade) + case .resourcePool(nil): + return Self.capacityMessage + case .activeLimit: + return String( + localized: "cloud.operation.failure.activeLimit", + defaultValue: "Every active machine your plan allows is in use. Pause or delete a running machine, then try again." + ) + case .createCredits: + if serverMessage?.hasPrefix("Your account has no Cloud VM create credits left.") == true { + return String( + localized: "cloud.operation.failure.createCredits.account", + defaultValue: "Your account has no machine create credits left. Add Cloud VM create credits or upgrade your plan." + ) + } + return String( + localized: "cloud.operation.failure.createCredits", + defaultValue: "Your team has no machine create credits left. Ask a team admin to add credits or upgrade the plan." + ) + case .memoryRequiresPlan: + return String( + localized: "cloud.operation.failure.memoryRequiresPlan", + defaultValue: "This size is larger than your plan includes. Choose a smaller size, or upgrade the plan." + ) + case .other: + return serverMessage ?? CloudDiagnosticFailure.plan.label + } + } + + /// `vm_resource_pool_exceeded`: running machines hold the shared pool. + public static let resourcePoolExceededCode = "vm_resource_pool_exceeded" + /// `vm_active_limit_exceeded`: every active-machine slot is taken. + public static let activeLimitExceededCode = "vm_active_limit_exceeded" + + /// The stable reference a failure transcript carries for a refusal, as + /// both the CLI and the in-process create print it. + /// - Parameter code: The server's `error` code. + public static func reference(code: String) -> String { "(HTTP 402: \(code))" } + + /// The refusal code the `cmux` CLI named in its own error header, the + /// `Error: (HTTP 402: <code>)` line it writes when a request + /// fails; nil when the transcript has no such header. Only the first + /// `Error:` line is read and only the reference that ends it, so the + /// server's message on the lines after it can never stand in for the code. + /// - Parameter output: The CLI's failure transcript. + public static func code(inCLIFailureOutput output: String) -> String? { + guard let header = output.split(whereSeparator: \.isNewline) + .first(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("Error:") }) else { return nil } + let line = header.trimmingCharacters(in: .whitespaces) + guard let match = line.range(of: #"\(HTTP 402: [A-Za-z0-9_.-]+\)$"#, options: .regularExpression) else { + return nil + } + return String(line[match].dropFirst("(HTTP 402: ".count).dropLast()) + } + + /// A full pool whose answer did not name the numbers. + public static var capacityMessage: String { + String( + localized: "cloud.operation.failure.capacity", + defaultValue: "Your team's running machines are using all of the plan's Cloud capacity. Pause or delete a running machine, then try again." + ) + } + + /// The overflow named by a pool answer's `resource`, `pool`, `used`, and + /// `requested`; nil when any part is missing. + private static func shortfall(_ object: [String: Any]) -> CloudVMResourcePool.Shortfall? { + guard let pool = object["pool"] as? [String: Any], + let used = object["used"] as? [String: Any], + let requested = object["requested"] as? [String: Any] else { return nil } + switch string(object["resource"]) { + case "memoryMb": + guard let poolMb = int(pool["memoryMb"]), let usedMb = int(used["memoryMb"]), + let requestedMb = int(requested["memoryMb"]) else { return nil } + return .memory(requestedMb: requestedMb, freeMb: max(0, poolMb - usedMb), poolMb: poolMb) + case "vcpus": + guard let poolVcpus = int(pool["vcpus"]), let usedVcpus = int(used["vcpus"]), + let requestedVcpus = int(requested["vcpus"]) else { return nil } + return .vcpus(requested: requestedVcpus, free: max(0, poolVcpus - usedVcpus), pool: poolVcpus) + default: + return nil + } + } + + private static func string(_ raw: Any?) -> String? { + guard let value = (raw as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } + + private static func int(_ raw: Any?) -> Int? { + guard let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite, number.doubleValue >= 0 else { return nil } + return Int(exactly: number.doubleValue) + } +} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudDiagnosticFailurePlanRefusalTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudDiagnosticFailurePlanRefusalTests.swift new file mode 100644 index 000000000000..46d50ff9cd6b --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudDiagnosticFailurePlanRefusalTests.swift @@ -0,0 +1,160 @@ +@testable import CmuxCloud +import CmuxCloudResizeCore +import Foundation +import Testing + +/// A `402` names what stopped the request in its `error` code. A full shared +/// pool on a paid team is not a missing plan, and must not read as one. +@Suite("Cloud diagnostic failure for plan refusals") +struct CloudDiagnosticFailurePlanRefusalTests { + static let poolExceededBody = """ + {"error":"vm_resource_pool_exceeded","message":"Your VMs already use 20 of 20 vCPUs. This VM needs 4 vCPUs.", + "resource":"vcpus","pool":{"vcpus":20,"memoryMb":40960},"used":{"vcpus":20,"memoryMb":40960}, + "requested":{"vcpus":4,"memoryMb":8192},"upgradePlanId":"max"} + """ + + @Test("A full shared pool is classified as a resource limit, not as the plan") + func poolExceededIsNotPlan() { + let error = VMClientError.httpStatus(402, Self.poolExceededBody) + let failure = CloudDiagnosticFailure.classify(error) + #expect(failure != .plan) + #expect(failure.label != CloudDiagnosticFailure.plan.label) + } +} + +extension CloudDiagnosticFailurePlanRefusalTests { + private static func body(_ object: [String: Any]) -> String { + String(decoding: try! JSONSerialization.data(withJSONObject: object), as: UTF8.self) + } + + @Test("A full vCPU pool names what is free and the request, with Max as the secondary way through") + func poolExceededMessageNamesTheVcpuShortfall() throws { + let refusal = try #require(CloudPlanRefusal(VMClientError.httpStatus(402, Self.poolExceededBody))) + #expect(refusal.code == "vm_resource_pool_exceeded") + #expect(refusal.kind == .resourcePool(.vcpus(requested: 4, free: 0, pool: 20))) + #expect(refusal.offersUpgrade) + #expect(refusal.message == CloudVMResourcePool.shortfallText(.vcpus(requested: 4, free: 0, pool: 20), offersUpgrade: true)) + #expect(CloudDiagnosticFailure.message(for: VMClientError.httpStatus(402, Self.poolExceededBody)) == refusal.message) + } + + @Test("A full memory pool on Max names the memory and offers no upgrade") + func poolExceededMemoryOnMax() throws { + let body = Self.body([ + "error": "vm_resource_pool_exceeded", "resource": "memoryMb", + "pool": ["vcpus": 80, "memoryMb": 163_840], "used": ["vcpus": 40, "memoryMb": 147_456], + "requested": ["vcpus": 8, "memoryMb": 32_768], "upgradePlanId": NSNull(), + ]) + let refusal = try #require(CloudPlanRefusal(status: 402, body: body)) + #expect(refusal.kind == .resourcePool(.memory(requestedMb: 32_768, freeMb: 16_384, poolMb: 163_840))) + #expect(!refusal.offersUpgrade) + #expect(refusal.diagnosticFailure == .resourceLimit) + } + + @Test("A pool refusal without numbers still names the pool") + func poolExceededWithoutNumbers() throws { + let refusal = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_resource_pool_exceeded"}"#)) + #expect(refusal.kind == .resourcePool(nil)) + #expect(refusal.message == CloudPlanRefusal.capacityMessage) + } + + @Test("Each recognised 402 code maps to its own class and sentence") + func recognisedCodes() throws { + let active = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_active_limit_exceeded","limit":5}"#)) + #expect(active.kind == .activeLimit) + #expect(active.diagnosticFailure == .resourceLimit) + let credits = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_create_credits_insufficient","amount":1}"#)) + #expect(credits.kind == .createCredits) + #expect(credits.diagnosticFailure == .plan) + #expect(credits.message != CloudDiagnosticFailure.plan.label) + let memory = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_memory_requires_plan","upgradePlanId":"max"}"#)) + #expect(memory.kind == .memoryRequiresPlan) + #expect(memory.diagnosticFailure == .plan) + #expect(memory.message != CloudDiagnosticFailure.plan.label) + } + + @Test("A zero active limit is the subscribe gate and keeps the server's sentence") + func zeroActiveLimitIsTheSubscribeGate() throws { + let refusal = try #require(CloudPlanRefusal(status: 402, body: Self.body([ + "error": "vm_active_limit_exceeded", "limit": 0, + "message": "Cloud VMs require a cmux Pro subscription.", + ]))) + #expect(refusal.kind == .other) + #expect(refusal.message == "Cloud VMs require a cmux Pro subscription.") + } + + @Test("Pool upgrades follow the capacity ladder, including Founders and Team") + func resourcePoolUpgradeActions() { + let poolCode = CloudPlanRefusal.resourcePoolExceededCode + for planID in ["pro", "founders", " Team "] { + let plan = MachinePlanSnapshot(activeCount: 1, planId: planID) + #expect(CloudPlanRefusal.offersUpgrade(for: poolCode, plan: plan), "\(planID)") + } + let maxPlan = MachinePlanSnapshot(activeCount: 1, planId: " MAX ") + #expect(!CloudPlanRefusal.offersUpgrade(for: poolCode, plan: maxPlan)) + #expect(!CloudPlanRefusal.offersUpgrade(for: poolCode, plan: nil)) + #expect(!CloudPlanRefusal.offersUpgrade(for: "vm_other", plan: maxPlan)) + #expect(CloudPlanRefusal.offersUpgrade(for: CloudPlanRefusal.activeLimitExceededCode, plan: nil)) + for planID in ["max", "founders", "team"] { + let plan = MachinePlanSnapshot(activeCount: 5, planId: planID) + #expect(!CloudPlanRefusal.offersUpgrade(for: CloudPlanRefusal.activeLimitExceededCode, plan: plan)) + } + } + + @Test("Create-credit refusals keep the server's account or team billing scope") + func createCreditsPreserveBillingScope() throws { + let account = try #require(CloudPlanRefusal(status: 402, body: Self.body([ + "error": "vm_create_credits_insufficient", + "message": "Your account has no Cloud VM create credits left.", + ]))) + #expect(account.message == String( + localized: "cloud.operation.failure.createCredits.account", + defaultValue: "Your account has no machine create credits left. Add Cloud VM create credits or upgrade your plan." + )) + let team = try #require(CloudPlanRefusal(status: 402, body: Self.body([ + "error": "vm_create_credits_insufficient", + "message": "This team has no Cloud VM create credits left.", + ]))) + #expect(team.message == String( + localized: "cloud.operation.failure.createCredits", + defaultValue: "Your team has no machine create credits left. Ask a team admin to add credits or upgrade the plan." + )) + } + + @Test("An unrecognised 402 prefers the server's sentence, else the plan sentence") + func unrecognisedCodes() throws { + let explained = try #require(CloudPlanRefusal(status: 402, body: Self.body([ + "error": "vm_hours_limit_reached", "message": "fallback", "ui": ["message": "You used this month's hours."], + ]))) + #expect(explained.message == "You used this month's hours.") + let bare = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_new_gate"}"#)) + #expect(bare.message == CloudDiagnosticFailure.plan.label) + } + + @Test("A 402 without a code keeps the status classification") + func statusOnlyFallback() { + #expect(CloudPlanRefusal(status: 402, body: "not json") == nil) + #expect(CloudPlanRefusal(status: 402, body: #"{"message":"no code"}"#) == nil) + #expect(CloudPlanRefusal(status: 409, body: #"{"error":"vm_resource_pool_exceeded"}"#) == nil) + #expect(CloudDiagnosticFailure.classify(VMClientError.httpStatus(402, "")) == .plan) + #expect(CloudDiagnosticFailure.message(for: VMClientError.httpStatus(402, "")) == CloudDiagnosticFailure.plan.label) + #expect(CloudDiagnosticFailure.classify(MachineUsageClientError.httpStatus(402, Self.poolExceededBody)) == .resourceLimit) + #expect(CloudDiagnosticFailure.classify(VMClientError.httpStatus(503, Self.poolExceededBody)) == .server) + } +} + +extension CloudDiagnosticFailurePlanRefusalTests { + @Test("The CLI's refusal code is read only from its own error header") + func cliFailureCodeComesFromTheHeader() { + #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: Cloud VM limit reached (HTTP 402: vm_active_limit_exceeded)\nThis plan allows 5.") == "vm_active_limit_exceeded") + #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: Refused (HTTP 402: vm_resource_pool_exceeded_v2)") == "vm_resource_pool_exceeded_v2") + // The server's message follows the header; a reference inside it never counts. + let spoofed = "Error: Refused (HTTP 402: vm_new_gate)\nSee (HTTP 402: vm_resource_pool_exceeded)" + #expect(CloudPlanRefusal.code(inCLIFailureOutput: spoofed) == "vm_new_gate") + // A reference inside the title is not the one that ends the header. + #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: (HTTP 402: vm_resource_pool_exceeded) x (HTTP 402: vm_new_gate)") == "vm_new_gate") + #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Full.\n(HTTP 402: vm_resource_pool_exceeded)") == nil) + #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: Busy (HTTP 409: vm_resource_pool_exceeded)") == nil) + #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: Refused (HTTP 402: vm_resource_pool_exceeded) later") == nil) + #expect(CloudPlanRefusal.reference(code: "vm_x") == "(HTTP 402: vm_x)") + } +} diff --git a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCompletion.swift b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCompletion.swift index 3a4f7ab9d331..ac5806bfe466 100644 --- a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCompletion.swift +++ b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCompletion.swift @@ -8,6 +8,7 @@ public struct CloudMachineCreateCompletion: Sendable { let failureOutput: String let machineID: String? let workspaceID: UUID? + let refusalCode: String? /// Captures the authoritative receipt and safe presentation of a process result. /// @@ -18,12 +19,18 @@ public struct CloudMachineCreateCompletion: Sendable { /// - failureOutput: Already redacted and localized error text for presentation. /// - machineID: Authoritative machine receipt, if present. /// - workspaceID: Local workspace receipt, if opening succeeded. - public init(succeeded: Bool, wasCancelled: Bool, output: String, failureOutput: String, machineID: String?, workspaceID: UUID?) { + /// - refusalCode: The server's refusal code, read by the launcher from + /// the error itself, never from display text; nil when there is none. + public init( + succeeded: Bool, wasCancelled: Bool, output: String, failureOutput: String, + machineID: String?, workspaceID: UUID?, refusalCode: String? = nil + ) { self.succeeded = succeeded self.wasCancelled = wasCancelled self.output = output self.failureOutput = failureOutput self.machineID = machineID self.workspaceID = workspaceID + self.refusalCode = refusalCode } } diff --git a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCoordinator.swift b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCoordinator.swift index 9dc3e973d4d9..0aea04e502ea 100644 --- a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCoordinator.swift +++ b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCoordinator.swift @@ -66,6 +66,7 @@ public final class CloudMachineCreateCoordinator { case .failed = projection.operations[index].phase else { return nil } let attempt = CloudMachineCreateAttempt(operationID: id, canAllocateMachine: !projection.operations[index].request.isBaseSetup && projection.operations[index].createdMachineID == nil) projection.operations[index].phase = .running + projection.operations[index].refusalCode = nil attempts[id] = attempt carries[id] = nil return attempt @@ -84,6 +85,7 @@ public final class CloudMachineCreateCoordinator { let refusedOperation = projection.operations[index] if let retryFailure { projection.operations[index].phase = .failed(output: retryFailure) + projection.operations[index].refusalCode = nil } else { projection.operations.remove(at: index) } @@ -164,12 +166,16 @@ public final class CloudMachineCreateCoordinator { if operation.request.retainsPendingProjection { // Keep the inline failure. The retained receipt makes retry an open, never a create. projection.operations[index].phase = .failed(output: completion.failureOutput) + // The machine exists and a retry only opens it, so a create + // refusal's actions (pause, upgrade) do not apply to this row. + projection.operations[index].refusalCode = nil } else { projection.operations.remove(at: index) } } else { outcome = .failed(output: completion.failureOutput) projection.operations[index].phase = .failed(output: completion.failureOutput) + projection.operations[index].refusalCode = completion.refusalCode } transition.finished = .init(operation: operation, outcome: outcome) return transition diff --git a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateOperation.swift b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateOperation.swift index 5373f604fc28..45538f3854b0 100644 --- a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateOperation.swift +++ b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateOperation.swift @@ -22,4 +22,8 @@ public struct CloudMachineCreateOperation: Identifiable, Equatable, Sendable { public internal(set) var createdMachineID: String? /// The current lifecycle phase. public internal(set) var phase: Phase = .running + /// The server's refusal code for the failure in ``phase``, carried apart + /// from its display text so no message can change which actions a failed + /// row offers. Nil while running, after success, or for other failures. + public internal(set) var refusalCode: String? } diff --git a/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineCreateCoordinatorTests.swift b/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineCreateCoordinatorTests.swift index e0b0c0205210..32b11e66278e 100644 --- a/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineCreateCoordinatorTests.swift +++ b/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineCreateCoordinatorTests.swift @@ -26,6 +26,41 @@ struct CloudMachineCreateCoordinatorTests { ) } + @Test("A failed create keeps the launcher's refusal code, and a retry clears it") + func refusalCodeTravelsWithTheFailure() throws { + let owner = makeOwner() + let attempt = owner.reserve(request()) + let id = attempt.operationID + _ = owner.finish(CloudMachineCreateCompletion( + succeeded: false, wasCancelled: false, output: "", + failureOutput: "Shown text (HTTP 402: vm_active_limit_exceeded)", + machineID: nil, workspaceID: nil, refusalCode: "vm_resource_pool_exceeded" + ), from: attempt) + #expect(owner.projection.operations.first?.refusalCode == "vm_resource_pool_exceeded") + let retry = try #require(owner.retry(id)) + #expect(owner.projection.operations.first?.refusalCode == nil) + _ = owner.finish(CloudMachineCreateCompletion( + succeeded: false, wasCancelled: false, output: "", failureOutput: "Limit", + machineID: nil, workspaceID: nil, refusalCode: "vm_active_limit_exceeded" + ), from: retry) + #expect(owner.projection.operations.first?.refusalCode == "vm_active_limit_exceeded") + let last = try #require(owner.retry(id)) + _ = owner.finish(completion(success: false), from: last) + #expect(owner.projection.operations.first?.refusalCode == nil) + } + + @Test("A machine that exists but failed to open keeps no create refusal code") + func openFailureCarriesNoRefusalCode() { + let owner = makeOwner() + let attempt = owner.reserve(request()) + _ = owner.finish(CloudMachineCreateCompletion( + succeeded: false, wasCancelled: false, output: "", failureOutput: "Open failed", + machineID: "vm-1", workspaceID: nil, refusalCode: "vm_resource_pool_exceeded" + ), from: attempt) + #expect(owner.projection.operations.first?.createdMachineID == "vm-1") + #expect(owner.projection.operations.first?.refusalCode == nil) + } + @Test func pendingExistsBeforeDelayedCreateResponse() { let owner = makeOwner() let request = request() diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7cf523f3adcc..41c3f423c5ca 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -651822,6 +651822,565 @@ "ja": { "stringUnit": { "state": "translated", "value": "cmux サービスが CodeRouter の引き継ぎを拒否しました。もう一度お試しください。" } } } }, + "cloud.operation.failure.capacity": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokrenute mašine vašeg tima koriste sav Cloud kapacitet plana. Pauzirajte ili izbrišite pokrenutu mašinu, pa pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dit teams kørende maskiner bruger hele planens Cloud-kapacitet. Sæt en kørende maskine på pause eller slet den, og prøv igen." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le macchine in esecuzione del tuo team usano tutta la capacità Cloud del piano. Metti in pausa o elimina una macchina in esecuzione, poi riprova." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ម៉ាស៊ីនដែលកំពុងដំណើរការរបស់ក្រុមអ្នកកំពុងប្រើសមត្ថភាព Cloud ទាំងអស់របស់គម្រោង។ ផ្អាក ឬលុបម៉ាស៊ីនដែលកំពុងដំណើរការមួយ រួចព្យាយាមម្តងទៀត។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Teamets kjørende maskiner bruker hele Cloud-kapasiteten i planen. Sett en kjørende maskin på pause eller slett den, og prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchomione maszyny Twojego zespołu wykorzystują całą pojemność Cloud w planie. Wstrzymaj lub usuń uruchomioną maszynę, a następnie spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As máquinas em execução da sua equipe estão usando toda a capacidade de Cloud do plano. Pause ou exclua uma máquina em execução e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запущенные машины вашей команды используют всю мощность Cloud по тарифу. Приостановите или удалите запущенную машину и повторите попытку." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เครื่องที่กำลังทำงานของทีมคุณใช้ความจุ Cloud ของแผนจนหมดแล้ว หยุดเครื่องที่กำลังทำงานชั่วคราวหรือลบออก แล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekibinizin çalışan makineleri planın tüm Cloud kapasitesini kullanıyor. Çalışan bir makineyi duraklatın veya silin, ardından tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запущені машини вашої команди використовують усю потужність Cloud за планом. Призупиніть або видаліть запущену машину й спробуйте ще раз." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Your team's running machines are using all of the plan's Cloud capacity. Pause or delete a running machine, then try again." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تستخدم الأجهزة قيد التشغيل لدى فريقك كامل سعة Cloud في الخطة. أوقف جهازًا قيد التشغيل مؤقتًا أو احذفه، ثم حاول مرة أخرى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die laufenden Maschinen deines Teams nutzen die gesamte Cloud-Kapazität des Plans. Pausiere oder lösche eine laufende Maschine und versuche es dann erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las máquinas en ejecución de tu equipo están usando toda la capacidad de Cloud del plan. Pausa o elimina una máquina en ejecución y vuelve a intentarlo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les machines en cours d'exécution de votre équipe utilisent toute la capacité Cloud du forfait. Mettez en pause ou supprimez une machine en cours d'exécution, puis réessayez." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームの実行中のマシンがプランの Cloud 容量をすべて使用しています。実行中のマシンを一時停止または削除してから、もう一度お試しください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀의 실행 중인 머신이 플랜의 Cloud 용량을 모두 사용하고 있습니다. 실행 중인 머신을 일시 정지하거나 삭제한 후 다시 시도하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您团队中正在运行的机器已用尽套餐的全部 Cloud 容量。请暂停或删除一台正在运行的机器,然后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "您團隊中執行中的機器已用盡方案的全部 Cloud 容量。請暫停或刪除一台執行中的機器,然後再試一次。" + } + } + } + }, + "cloud.operation.failure.activeLimit": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sve aktivne mašine koje vaš plan dozvoljava su u upotrebi. Pauzirajte ili izbrišite pokrenutu mašinu, pa pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Alle aktive maskiner, som din plan tillader, er i brug. Sæt en kørende maskine på pause eller slet den, og prøv igen." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tutte le macchine attive consentite dal tuo piano sono in uso. Metti in pausa o elimina una macchina in esecuzione, poi riprova." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ម៉ាស៊ីនសកម្មទាំងអស់ដែលគម្រោងរបស់អ្នកអនុញ្ញាតកំពុងប្រើប្រាស់។ ផ្អាក ឬលុបម៉ាស៊ីនដែលកំពុងដំណើរការមួយ រួចព្យាយាមម្តងទៀត។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Alle aktive maskiner planen din tillater, er i bruk. Sett en kjørende maskin på pause eller slett den, og prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wszystkie aktywne maszyny dozwolone w Twoim planie są w użyciu. Wstrzymaj lub usuń uruchomioną maszynę, a następnie spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Todas as máquinas ativas que seu plano permite estão em uso. Pause ou exclua uma máquina em execução e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все активные машины, разрешённые вашим тарифом, уже используются. Приостановите или удалите запущенную машину и повторите попытку." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เครื่องที่ใช้งานอยู่ทั้งหมดที่แผนของคุณอนุญาตกำลังถูกใช้งาน หยุดเครื่องที่กำลังทำงานชั่วคราวหรือลบออก แล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Planınızın izin verdiği tüm etkin makineler kullanımda. Çalışan bir makineyi duraklatın veya silin, ardından tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Усі активні машини, дозволені вашим планом, уже використовуються. Призупиніть або видаліть запущену машину й спробуйте ще раз." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Every active machine your plan allows is in use. Pause or delete a running machine, then try again." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جميع الأجهزة النشطة التي تسمح بها خطتك قيد الاستخدام. أوقف جهازًا قيد التشغيل مؤقتًا أو احذفه، ثم حاول مرة أخرى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle aktiven Maschinen, die dein Plan erlaubt, sind in Gebrauch. Pausiere oder lösche eine laufende Maschine und versuche es dann erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todas las máquinas activas que permite tu plan están en uso. Pausa o elimina una máquina en ejecución y vuelve a intentarlo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toutes les machines actives autorisées par votre forfait sont utilisées. Mettez en pause ou supprimez une machine en cours d'exécution, puis réessayez." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プランで利用できるアクティブなマシンはすべて使用中です。実行中のマシンを一時停止または削除してから、もう一度お試しください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "플랜에서 허용하는 활성 머신을 모두 사용 중입니다. 실행 중인 머신을 일시 정지하거나 삭제한 후 다시 시도하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您的套餐允许的所有活跃机器都已在使用。请暂停或删除一台正在运行的机器,然后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "您的方案允許的所有使用中機器都已在使用。請暫停或刪除一台執行中的機器,然後再試一次。" + } + } + } + }, + "cloud.operation.failure.createCredits": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vaš tim više nema kredita za kreiranje mašina. Zamolite administratora tima da doda kredite ili nadogradite plan." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dit team har ikke flere kreditter til at oprette maskiner. Bed en teamadministrator om at tilføje kreditter, eller opgrader planen." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il tuo team non ha più crediti per creare macchine. Chiedi a un amministratore del team di aggiungere crediti o passa a un piano superiore." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ក្រុមរបស់អ្នកលែងមានក្រេឌីតសម្រាប់បង្កើតម៉ាស៊ីនទៀតហើយ។ សូមស្នើអ្នកគ្រប់គ្រងក្រុមឱ្យបន្ថែមក្រេឌីត ឬដំឡើងគម្រោង។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Teamet ditt har ikke flere kreditter for å opprette maskiner. Be en teamadministrator om å legge til kreditter, eller oppgrader planen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Twój zespół nie ma już kredytów na tworzenie maszyn. Poproś administratora zespołu o dodanie kredytów lub zmień plan na wyższy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sua equipe não tem mais créditos para criar máquinas. Peça a um administrador da equipe para adicionar créditos ou faça upgrade do plano." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "У вашей команды закончились кредиты на создание машин. Попросите администратора команды добавить кредиты или повысьте тариф." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทีมของคุณไม่มีเครดิตสำหรับสร้างเครื่องเหลือแล้ว ขอให้ผู้ดูแลทีมเพิ่มเครดิต หรืออัปเกรดแผน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekibinizin makine oluşturma kredisi kalmadı. Bir ekip yöneticisinden kredi eklemesini isteyin veya planı yükseltin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У вашої команди закінчилися кредити на створення машин. Попросіть адміністратора команди додати кредити або перейдіть на вищий план." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Your team has no machine create credits left. Ask a team admin to add credits or upgrade the plan." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يعد لدى فريقك أي أرصدة لإنشاء الأجهزة. اطلب من مسؤول الفريق إضافة أرصدة أو قم بترقية الخطة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Team hat keine Credits zum Erstellen von Maschinen mehr. Bitte einen Team-Admin, Credits hinzuzufügen, oder führe ein Upgrade des Plans durch." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tu equipo no tiene créditos para crear máquinas. Pide a un administrador del equipo que añada créditos o mejora el plan." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre équipe n'a plus de crédits de création de machines. Demandez à un administrateur de l'équipe d'ajouter des crédits ou passez à un forfait supérieur." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームにマシン作成クレジットが残っていません。チーム管理者にクレジットの追加を依頼するか、プランをアップグレードしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀에 남은 머신 생성 크레딧이 없습니다. 팀 관리자에게 크레딧 추가를 요청하거나 플랜을 업그레이드하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您的团队已没有创建机器的额度。请让团队管理员添加额度,或升级套餐。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "您的團隊已沒有建立機器的點數。請團隊管理員加入點數,或升級方案。" + } + } + } + }, + "cloud.operation.failure.memoryRequiresPlan": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ova veličina je veća od onoga što vaš plan uključuje. Odaberite manju veličinu ili nadogradite plan." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelse er større, end din plan omfatter. Vælg en mindre størrelse, eller opgrader planen." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa dimensione supera quanto incluso nel tuo piano. Scegli una dimensione più piccola o passa a un piano superiore." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំហំនេះធំជាងអ្វីដែលគម្រោងរបស់អ្នករួមបញ្ចូល។ ជ្រើសរើសទំហំតូចជាងនេះ ឬដំឡើងគម្រោង។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelsen er større enn det planen din inkluderer. Velg en mindre størrelse, eller oppgrader planen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ten rozmiar przekracza to, co obejmuje Twój plan. Wybierz mniejszy rozmiar lub zmień plan na wyższy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Este tamanho é maior do que seu plano inclui. Escolha um tamanho menor ou faça upgrade do plano." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Этот размер больше, чем включено в ваш тариф. Выберите размер поменьше или повысьте тариф." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดนี้ใหญ่กว่าที่แผนของคุณรวมไว้ เลือกขนาดที่เล็กกว่า หรืออัปเกรดแผน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu boyut planınızın kapsadığından daha büyük. Daha küçük bir boyut seçin veya planı yükseltin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Цей розмір більший, ніж включено у ваш план. Виберіть менший розмір або перейдіть на вищий план." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This size is larger than your plan includes. Choose a smaller size, or upgrade the plan." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذا الحجم أكبر مما تتضمنه خطتك. اختر حجمًا أصغر، أو قم بترقية الخطة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Größe übersteigt, was dein Plan enthält. Wähle eine kleinere Größe oder führe ein Upgrade des Plans durch." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este tamaño supera lo que incluye tu plan. Elige un tamaño más pequeño o mejora el plan." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette taille dépasse ce que comprend votre forfait. Choisissez une taille plus petite ou passez à un forfait supérieur." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このサイズはプランに含まれる範囲を超えています。小さいサイズを選ぶか、プランをアップグレードしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 크기는 플랜에 포함된 범위보다 큽니다. 더 작은 크기를 선택하거나 플랜을 업그레이드하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此规格超出您的套餐所含范围。请选择较小的规格,或升级套餐。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此大小超出您的方案所包含的範圍。請選擇較小的大小,或升級方案。" + } + } + } + }, + "cloud.operation.failure.createCredits.account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account has no machine create credits left. Add Cloud VM create credits or upgrade your plan." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يعد لدى حسابك أي أرصدة لإنشاء الأجهزة. أضف أرصدة Cloud VM أو قم بترقية خطتك." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Konto hat keine Credits zum Erstellen von Maschinen mehr. Füge Cloud-VM-Credits hinzu oder führe ein Upgrade deines Plans durch." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tu cuenta no tiene créditos para crear máquinas. Añade créditos de Cloud VM o mejora tu plan." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre compte n'a plus de crédits de création de machines. Ajoutez des crédits de machines Cloud ou passez à un forfait supérieur." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントにマシン作成クレジットが残っていません。Cloud VM クレジットを追加するか、プランをアップグレードしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정에 머신 생성 크레딧이 남아 있지 않습니다. Cloud VM 크레딧을 추가하거나 플랜을 업그레이드하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您的帐户已没有创建机器的额度。请添加 Cloud VM 额度或升级套餐。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "您的帳戶已沒有建立機器的點數。請新增 Cloud VM 點數或升級方案。" + } + } + } + }, "account.signIn.copyErrorDetails": { "extractionState": "manual", "localizations": { diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index 2be092590545..9c3bf5a6a910 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -642,7 +642,7 @@ struct CloudTreeNodeActions { } ) }, failureDescription: { error in - CloudDiagnosticFailure.classify(error).label + CloudVMActionLauncher.displayableFailureMessage(for: error) }) } actions.organize = { action, id, _ in catalog().organizeSidebar(action, nodeID: id) } diff --git a/Sources/Cloud/InProcessMachineCreateLauncher.swift b/Sources/Cloud/InProcessMachineCreateLauncher.swift index 3be998d1248f..6b0845f7d598 100644 --- a/Sources/Cloud/InProcessMachineCreateLauncher.swift +++ b/Sources/Cloud/InProcessMachineCreateLauncher.swift @@ -288,13 +288,23 @@ enum InProcessMachineCreateLauncher { } catch { return CloudVMActionLauncher.Completion( terminationStatus: 1, - output: output + CloudDiagnosticFailure.classify(error).label + "\n", + output: output + failureText(for: error), workspaceId: nil, - machineId: machineID + machineId: machineID, + refusalCode: CloudPlanRefusal(error)?.code ) } } + /// The failure lines for a person: what the server refused and what to do, + /// then the stable `(HTTP 402: code)` reference the CLI prints, for + /// support. Actions key on the completion's `refusalCode`, not this text. + static func failureText(for error: Error) -> String { + let message = CloudVMActionLauncher.displayableFailureMessage(for: error) + guard let refusal = CloudPlanRefusal(error) else { return message + "\n" } + return "\(message)\n\(CloudPlanRefusal.reference(code: refusal.code))\n" + } + @discardableResult static func start( arguments: [String], diff --git a/Sources/Cloud/MachineCreateCoordinator.swift b/Sources/Cloud/MachineCreateCoordinator.swift index 05b11668cdd3..3b8ddabf2c36 100644 --- a/Sources/Cloud/MachineCreateCoordinator.swift +++ b/Sources/Cloud/MachineCreateCoordinator.swift @@ -215,7 +215,8 @@ final class MachineCreateCoordinator { let completion = CloudMachineCreateCompletion( succeeded: result.succeeded, wasCancelled: result.wasCancelled, output: result.output, failureOutput: Self.displayableFailureOutput(result.output), - machineID: result.machineId, workspaceID: result.workspaceId + machineID: result.machineId, workspaceID: result.workspaceId, + refusalCode: result.refusalCode ) self.apply(self.lifecycle.finish(completion, from: attempt)) }) @@ -302,7 +303,8 @@ final class MachineCreateCoordinator { guard let request = requests[operation.id] else { return nil } return MachineCreateOperation( id: operation.id, request: request, startedAt: operation.startedAt, - createdMachineID: operation.createdMachineID, phase: operation.phase + createdMachineID: operation.createdMachineID, phase: operation.phase, + refusalCode: operation.refusalCode ) } diff --git a/Sources/Cloud/MachineCreateOperation.swift b/Sources/Cloud/MachineCreateOperation.swift index 57aab62f380c..0392e553ddb8 100644 --- a/Sources/Cloud/MachineCreateOperation.swift +++ b/Sources/Cloud/MachineCreateOperation.swift @@ -19,6 +19,9 @@ struct MachineCreateOperation: Identifiable, Equatable { /// the only safe correlation key while the CLI is still opening the machine. var createdMachineID: String? = nil var phase: Phase = .running + /// The server's refusal code for a failed create, carried from the + /// launcher apart from the display text. Failure actions key on it alone. + var refusalCode: String? = nil var isRunning: Bool { if case .running = phase { return true } @@ -47,10 +50,18 @@ struct MachineCreateOperation: Identifiable, Equatable { return nil } + /// The create failed because the plan's active-machine limit is reached /// (the service's `vm_active_limit_exceeded`), so upgrading would let it through. var hitMachineLimit: Bool { - failureOutput?.contains("vm_active_limit_exceeded") == true + refusalCode == CloudPlanRefusal.activeLimitExceededCode + } + + /// The create found the team's shared vCPUs or memory already in use + /// (`vm_resource_pool_exceeded`). Pausing or deleting a running machine + /// frees it; a larger plan is only the secondary way through. + var hitResourcePool: Bool { + refusalCode == CloudPlanRefusal.resourcePoolExceededCode } /// The one-line status beside the name: the sheet's progress wording diff --git a/Sources/CloudVMActionLauncher.swift b/Sources/CloudVMActionLauncher.swift index 56af31d4aa18..968299938a47 100644 --- a/Sources/CloudVMActionLauncher.swift +++ b/Sources/CloudVMActionLauncher.swift @@ -36,6 +36,9 @@ final class CloudVMActionLauncher { /// cancelled create is not a failed create and must not present an /// error sheet or notification. var wasCancelled: Bool = false + /// The server's refusal code, read from the error the request threw + /// (in process) or the CLI's own error header; never from a message. + var refusalCode: String? = nil var succeeded: Bool { terminationStatus == 0 @@ -352,7 +355,8 @@ final class CloudVMActionLauncher { output: output, workspaceId: Self.createdWorkspaceId(from: output), machineId: Self.createdMachineId(from: output), - wasCancelled: wasCancelled + wasCancelled: wasCancelled, + refusalCode: terminationStatus == 0 ? nil : CloudPlanRefusal.code(inCLIFailureOutput: output) ) if let operationContext { let diagnosticError: Error? = wasCancelled ? CancellationError() @@ -523,6 +527,16 @@ final class CloudVMActionLauncher { ) } + /// The sentence a person sees for a Cloud failure: the server's explained + /// refusal when it named one, else the class label. A server sentence the + /// output redaction would hide falls back to the class label too. + nonisolated static func displayableFailureMessage(for error: Error) -> String { + let message = CloudDiagnosticFailure.message(for: error) + let safe = sanitizedCloudVMStartOutput(message) + guard safe.isEmpty || safe == hiddenOutputPlaceholder else { return message } + return CloudDiagnosticFailure.classify(error).label + } + /// The first line of CLI output that passes the same redaction as the full /// transcript, with an "Error:" prefix dropped. Nil when no line is safe. nonisolated static func firstSafeLine(of output: String) -> String? { diff --git a/Sources/Panels/MachineCreateLoadingContent.swift b/Sources/Panels/MachineCreateLoadingContent.swift index a424aef54a78..8946bccb3521 100644 --- a/Sources/Panels/MachineCreateLoadingContent.swift +++ b/Sources/Panels/MachineCreateLoadingContent.swift @@ -34,21 +34,17 @@ struct MachineCreateLoadingContent: View { .foregroundStyle(.secondary) .textSelection(.enabled) HStack(spacing: 8) { - if offersUpgrade { - Button(String(localized: "machines.pending.upgrade", defaultValue: "Upgrade Plan")) { - ProUpgradePresenter.present(source: .createFailedAtLimit) - } - .buttonStyle(.borderedProminent) - .accessibilityIdentifier("MachineCreateUpgradeButton") - } - // Upgrading is the way forward at the limit, so it takes - // the prominent style and Retry steps back. - if offersUpgrade { + // Upgrading is the way forward at the machine limit, so it + // takes the prominent style and Retry steps back. A full + // pool frees up when a machine pauses, so Retry leads there. + if upgradeIsPrimary { + upgradeButton.buttonStyle(.borderedProminent) Button(String(localized: "machines.pending.retry", defaultValue: "Retry")) { actions.retry(operation.id) } .buttonStyle(.bordered) } else { Button(String(localized: "machines.pending.retry", defaultValue: "Retry")) { actions.retry(operation.id) } .buttonStyle(.borderedProminent) + if offersUpgrade { upgradeButton.buttonStyle(.bordered) } } Button(String(localized: "machines.pending.dismiss", defaultValue: "Dismiss")) { actions.dismiss(operation.id) } .buttonStyle(.bordered) @@ -66,9 +62,22 @@ struct MachineCreateLoadingContent: View { .background(Color(nsColor: GhosttyApp.shared.defaultBackgroundColor)) } - /// The create hit the plan's machine limit and a plan with more machines - /// exists. An unloaded plan still offers it; the plans page shows the rest. + private var upgradeButton: some View { + Button(String(localized: "machines.pending.upgrade", defaultValue: "Upgrade Plan")) { + ProUpgradePresenter.present(source: .createFailedAtLimit) + } + .accessibilityIdentifier("MachineCreateUpgradeButton") + } + + /// The create hit the plan's machine limit or its shared vCPU and memory + /// pool, and a larger plan exists. At the machine limit an unloaded plan + /// still offers it; a full pool waits for the plan, so Max never sees it. private var offersUpgrade: Bool { - operation.hitMachineLimit && (NewMachineSheetDataCache.shared?.currentData?.plan?.hasHigherPlan ?? true) + CloudPlanRefusal.offersUpgrade( + for: operation.refusalCode, + plan: NewMachineSheetDataCache.shared?.currentData?.plan + ) } + + private var upgradeIsPrimary: Bool { offersUpgrade && operation.hitMachineLimit } } diff --git a/cmuxTests/CloudSidebarPolishTests.swift b/cmuxTests/CloudSidebarPolishTests.swift index b92e6ceab982..6148b92e5caa 100644 --- a/cmuxTests/CloudSidebarPolishTests.swift +++ b/cmuxTests/CloudSidebarPolishTests.swift @@ -1,4 +1,5 @@ import CmuxCloud +import CmuxCloudResizeCore import CmuxSettings import Foundation import Testing @@ -19,8 +20,10 @@ struct CloudSidebarPolishTests { ) #expect(!operation.hitMachineLimit) operation.phase = .failed(output: "Error: Cloud VM limit reached (HTTP 402: vm_active_limit_exceeded)") + operation.refusalCode = "vm_active_limit_exceeded" #expect(operation.hitMachineLimit) - operation.phase = .failed(output: "Error: The Cloud VM service is temporarily unavailable.") + // Actions follow the carried code, never the display text. + operation.refusalCode = nil #expect(!operation.hitMachineLimit) for plan in ["free", "go", "pro", "unknown"] { #expect(MachinePlanSnapshot(activeCount: 5, planId: plan).hasHigherPlan, "\(plan)") @@ -30,6 +33,33 @@ struct CloudSidebarPolishTests { } } + @Test("A create refused by a full shared pool says what is in use, not that the plan is missing") + func resourcePoolFailureExplainsThePool() { + let body = """ + {"error":"vm_resource_pool_exceeded","resource":"vcpus","pool":{"vcpus":20,"memoryMb":40960}, + "used":{"vcpus":20,"memoryMb":40960},"requested":{"vcpus":4,"memoryMb":8192},"upgradePlanId":"max"} + """ + let text = InProcessMachineCreateLauncher.failureText(for: VMClientError.httpStatus(402, body)) + #expect(!text.contains(CloudDiagnosticFailure.plan.label)) + #expect(text.hasPrefix(CloudVMResourcePool.shortfallText(.vcpus(requested: 4, free: 0, pool: 20), offersUpgrade: true))) + let shown = MachineCreateCoordinator.displayableFailureOutput(text) + #expect(shown == text.trimmingCharacters(in: .whitespacesAndNewlines)) + var operation = MachineCreateOperation( + id: UUID(), request: MachineCreateCoordinatorTests.newMachineRequest(), startedAt: Date() + ) + operation.phase = .failed(output: shown) + operation.refusalCode = "vm_resource_pool_exceeded" + #expect(operation.hitResourcePool) + #expect(!operation.hitMachineLimit) + #expect(InProcessMachineCreateLauncher.failureText(for: VMClientError.httpStatus(402, "")) == CloudDiagnosticFailure.plan.label + "\n") + // A server message naming another code changes nothing: only the carried code counts. + operation.phase = .failed(output: "See (HTTP 402: vm_resource_pool_exceeded)\n(HTTP 402: vm_new_gate)") + operation.refusalCode = "vm_new_gate" + #expect(!operation.hitResourcePool) + operation.refusalCode = "vm_resource_pool_exceeded_v2" + #expect(!operation.hitResourcePool) + } + @Test("The mode bar's minimum fits the widest tab's name with every other tab as its icon") func oneLabelWidth() { // Full and icon-only widths for three tabs; Cloud's label is the widest. diff --git a/cmuxTests/InProcessMachineCreateLauncherTests.swift b/cmuxTests/InProcessMachineCreateLauncherTests.swift index 6b5902520d9d..a789e9f8fbbf 100644 --- a/cmuxTests/InProcessMachineCreateLauncherTests.swift +++ b/cmuxTests/InProcessMachineCreateLauncherTests.swift @@ -85,6 +85,27 @@ struct InProcessMachineCreateLauncherTests { #expect(!completion.wasCancelled) } + @Test("A refused create carries the server's code apart from its message") + func refusedCreateCarriesTheRefusalCode() async throws { + let invocation = try #require(InProcessMachineCreateLauncher.parse(arguments: [ + "vm", "new", "--workspace", UUID().uuidString + ])) + // The message names another code; the completion must carry the real one. + let body = #"{"error":"vm_new_gate","message":"Refused (HTTP 402: vm_resource_pool_exceeded)"}"# + let dependencies = InProcessMachineCreateLauncher.Dependencies( + create: { _, _ in throw VMClientError.httpStatus(402, body) }, + record: { _, _ in Issue.record("Unexpected record"); return nil }, + provider: { _ in Issue.record("Unexpected lookup"); return nil }, + refresh: { _ in Issue.record("Unexpected refresh") }, + open: { _, _ in Issue.record("Unexpected open") } + ) + let completion = await InProcessMachineCreateLauncher.run( + invocation, operationID: UUID(), dependencies: dependencies, onOutput: { _ in } + ) + #expect(!completion.succeeded) + #expect(completion.refusalCode == "vm_new_gate") + } + @Test("A missing receipt provider forces a fresh fleet discovery") func missingReceiptProviderForcesFreshRefresh() async throws { let workspace = UUID() diff --git a/cmuxTests/MachineCreateCoordinatorTests.swift b/cmuxTests/MachineCreateCoordinatorTests.swift index fe0f402555e2..565535a18d18 100644 --- a/cmuxTests/MachineCreateCoordinatorTests.swift +++ b/cmuxTests/MachineCreateCoordinatorTests.swift @@ -44,13 +44,17 @@ struct MachineCreateCoordinatorTests { } } - func complete(status: Int32, output: String, workspaceID: UUID? = nil, machineID: String? = nil) { + func complete( + status: Int32, output: String, workspaceID: UUID? = nil, machineID: String? = nil, + refusalCode: String? = nil + ) { let completion = completions.removeFirst() completion(CloudVMActionLauncher.Completion( terminationStatus: status, output: output, workspaceId: workspaceID, - machineId: machineID + machineId: machineID, + refusalCode: refusalCode )) } } @@ -367,6 +371,25 @@ struct MachineCreateCoordinatorTests { #expect(notices.notices.count == 2, "retry success remains visible when no workspace was selected") } + @Test("A refused create's code reaches the row's actions, and a retry clears it") + func refusalCodeReachesTheFailedRow() { + let (coordinator, launches, _, _, _) = makeCoordinator() + coordinator.start(Self.newMachineRequest(), launch: launches.launch) + let id = coordinator.operations[0].id + launches.complete( + status: 1, output: "Only 0 of your 20 vCPUs are free.\n(HTTP 402: vm_resource_pool_exceeded)\n", + refusalCode: "vm_resource_pool_exceeded" + ) + #expect(coordinator.operation(id: id)?.refusalCode == "vm_resource_pool_exceeded") + #expect(coordinator.operation(id: id)?.hitResourcePool == true) + #expect(coordinator.retry(id)) + #expect(coordinator.operation(id: id)?.refusalCode == nil) + // The text names a code, but only the completion's own code counts. + launches.complete(status: 1, output: "Refused.\n(HTTP 402: vm_resource_pool_exceeded)\n") + #expect(coordinator.operation(id: id)?.refusalCode == nil) + #expect(coordinator.operation(id: id)?.hitResourcePool == false) + } + @Test func emptyFailureOutputGetsAGenericMessage() { let (coordinator, launches, _, _, _) = makeCoordinator() coordinator.start(Self.newMachineRequest(), launch: launches.launch) diff --git a/scripts/localize_changes.py b/scripts/localize_changes.py index 68c550d4c188..2c9ecf2ef330 100644 --- a/scripts/localize_changes.py +++ b/scripts/localize_changes.py @@ -159,6 +159,19 @@ def decode_swift_escapes(value: str) -> str: index += 1 continue index += 1 + if index < len(value) and value[index] == "u" and index + 1 < len(value) and value[index + 1] == "{": + end = value.find("}", index + 2) + if end < 0: + raise ValueError("unsupported Swift Unicode escape in defaultValue") + digits = value[index + 2:end] + if not re.fullmatch(r"[0-9A-Fa-f]{1,8}", digits): + raise ValueError("unsupported Swift Unicode escape in defaultValue") + scalar = int(digits, 16) + if scalar > 0x10FFFF or 0xD800 <= scalar <= 0xDFFF: + raise ValueError("invalid Swift Unicode scalar in defaultValue") + result.append(chr(scalar)) + index = end + 1 + continue if index >= len(value) or value[index] not in escapes: raise ValueError("unsupported Swift escape in defaultValue") result.append(escapes[value[index]]) diff --git a/tests/test_localize_changes.py b/tests/test_localize_changes.py index da5661a338d8..5d94ce748cd2 100644 --- a/tests/test_localize_changes.py +++ b/tests/test_localize_changes.py @@ -51,6 +51,14 @@ def write_catalog(root: Path, strings: dict) -> Path: class LocalizeChangesTests(unittest.TestCase): + def test_decode_swift_unicode_escapes(self): + self.assertEqual( + MODULE.decode_swift_escapes(r"Rename \u{201C}%@\u{201D}\u{2026}"), + "Rename “%@”…", + ) + with self.assertRaisesRegex(ValueError, "invalid Swift Unicode scalar"): + MODULE.decode_swift_escapes(r"bad \u{D800}") + def test_parse_swift_messages_can_report_the_keys_it_dropped_for_conflicts(self): text = ( 'String(localized: "same", defaultValue: "Open")\n' From f3d212ee14653a7d348748048baf83c06d9ca60c Mon Sep 17 00:00:00 2001 From: Mirksen <52635146+Mirksen@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:07:33 +0200 Subject: [PATCH 020/183] fix: route remote tmux split shortcuts to projected panes (#18333) * test: cover split actions targeting projected remote tmux panes * fix: route split actions through projected remote tmux pane owners Cmd+D and menu splits pass the focused terminal surface ID. A remote tmux pane is projected from its mirror and is absent from workspace.panels, so the shared split action rejected it before sending a remote command. Resolve projected panes through the existing control topology and dispatch to their mutation owner. Preserve the exact target, split orientation, before placement and focus intent; rejected requests never fall back to a local split. Verification: changed Swift syntax and git diff --check passed. A standalone probe executing the actual split action with synthetic collaborators ran 27 routing assertions: 16 failures on regression commit 4162b2d, zero with the fix. The probe does not compile or exercise the native app. App-host regressions were added but not executed; full Xcode is unavailable locally. * test: cover remote tmux keyboard split routing Co-authored-by: Mirksen <52635146+Mirksen@users.noreply.github.com> --------- Co-authored-by: Austin Wang <38676809+austinywang@users.noreply.github.com> --- Sources/TabManager+TerminalSplitAction.swift | 21 +- .../RemoteTmuxMirrorSplitRoutingTests.swift | 204 ++++++++++++++++++ 2 files changed, 223 insertions(+), 2 deletions(-) diff --git a/Sources/TabManager+TerminalSplitAction.swift b/Sources/TabManager+TerminalSplitAction.swift index e691ed7e811c..c230163f25ad 100644 --- a/Sources/TabManager+TerminalSplitAction.swift +++ b/Sources/TabManager+TerminalSplitAction.swift @@ -19,8 +19,25 @@ extension TabManager { direction: SplitDirection, focus: Bool = true ) -> TerminalPanelCreationOutcome { - guard let workspace = tabs.first(where: { $0.id == tabId }), - workspace.panels[surfaceId] != nil else { return .failed } + guard let workspace = tabs.first(where: { $0.id == tabId }) else { return .failed } + // Keyboard and menu actions identify the focused terminal surface. + // A projected tmux pane is owned by its mirror, outside workspace.panels. + // Route to that exact pane instead of rejecting it or splitting its + // container's currently active pane. + if let remotePane = workspace.remoteTmuxControlPane(surfaceID: surfaceId) { + workspace.clearSplitZoom() + sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) + let accepted = remotePane.owner.requestSplit( + fromPane: remotePane.pane.tmuxPaneID, + vertical: direction.orientation == .vertical, + focusIntent: focus ? .focusCreatedPane : .preserveActivePane, + insertBefore: direction.insertFirst, + shellCommand: nil, + workingDirectory: nil + ) + return accepted ? .routedToRemote : .failed + } + guard workspace.panels[surfaceId] != nil else { return .failed } workspace.clearSplitZoom() sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) let outcome = workspace.newTerminalSplitOutcome( diff --git a/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift b/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift index ab0f1fc9bbc0..f62a6ad5c027 100644 --- a/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift +++ b/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift @@ -1,4 +1,5 @@ import CmuxRemoteSession +import CmuxPanes import AppKit import Bonsplit import CmuxControlSocket @@ -20,6 +21,209 @@ import Testing /// remote panes. @MainActor @Suite(.serialized) struct RemoteTmuxMirrorSplitRoutingTests { + @Test(arguments: [false, true], [false, true]) + func sessionMirrorSplitShortcutTargetsTheFocusedTerminal( + splitDown: Bool, + remapped: Bool + ) throws { + let harness = try RemoteTmuxSessionMirrorLayoutHarness() + defer { harness.tearDown() } + let appDelegate = try #require(AppDelegate.shared) + let windowID = appDelegate.registerMainWindowContextForTesting(tabManager: harness.manager) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) } + let panel = try #require(harness.singlePanePanel(tmuxPaneID: 11)) + let portal = try RemoteTmuxPanePortalTestHarness( + panels: [panel], appDelegate: appDelegate, windowID: windowID + ) + defer { portal.tearDown() } + harness.manager.window = portal.window + appDelegate.mainWindowContexts.values.first { $0.windowId == windowID }?.window = portal.window + panel.hostedView.setVisibleInUI(true) + panel.hostedView.setActive(true) + try #require(portal.window.makeFirstResponder(panel.hostedView.surfaceView)) + appDelegate.debugSetShortcutRoutingFocusedWindowForTesting(portal.window) + defer { appDelegate.debugResetShortcutRoutingStateForTesting() } + + let action: KeyboardShortcutSettings.Action = splitDown ? .splitDown : .splitRight + let originalStore = KeyboardShortcutSettings.installIsolatedTestFileStore( + prefix: "cmux-remote-tmux-split-shortcut" + ) + let originalDefaults = UserDefaults.standard.object(forKey: action.defaultsKey) + defer { + if let originalDefaults { + UserDefaults.standard.set(originalDefaults, forKey: action.defaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: action.defaultsKey) + } + KeyboardShortcutSettings.settingsFileStore = originalStore + } + let shortcut = remapped + ? StoredShortcut(key: "d", command: true, shift: splitDown, option: true, control: false) + : action.defaultShortcut + KeyboardShortcutSettings.setShortcut(shortcut, for: action) + + // This is the first remote split, while the workspace still has one + // outer window tab and the terminal is an independently owned surface. + let context = try #require(appDelegate.focusedTerminalShortcutContext(preferredWindow: portal.window)) + #expect(context.panelId == panel.id) + #expect(harness.workspace.panels[panel.id] == nil) + #expect(harness.workspace.remoteTmuxControlPane(surfaceID: panel.id)?.owner === harness.sessionMirror) + #expect(!appDelegate.shouldSuppressSplitShortcutForTransientTerminalFocusState( + direction: splitDown ? .down : .right, tabManager: harness.manager + )) + let panelsBefore = Set(harness.workspace.panels.keys) + let focusedPanelBefore = harness.workspace.focusedPanelId + let modifiers: NSEvent.ModifierFlags = splitDown ? [.command, .shift] : [.command] + let event = try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, + modifierFlags: remapped ? modifiers.union(.option) : modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: portal.window.windowNumber, context: nil, + characters: "d", charactersIgnoringModifiers: "d", isARepeat: false, keyCode: 2 + )) + if remapped { + let oldEvent = try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: portal.window.windowNumber, context: nil, + characters: "d", charactersIgnoringModifiers: "d", isARepeat: false, keyCode: 2 + )) + #expect(!appDelegate.debugHandleCustomShortcut(event: oldEvent)) + } + #expect(appDelegate.debugHandleCustomShortcut(event: event)) + + // Accepted remote mutations wait for tmux's layout publication. They + // neither insert a local pane nor change focus before that response. + #expect(Set(harness.workspace.panels.keys) == panelsBefore) + #expect(harness.workspace.focusedPanelId == focusedPanelBefore) + #expect(harness.windowMirror?.activePaneId == 11) + harness.writer.close() + let commands = try #require(String( + bytes: try harness.pipe.fileHandleForReading.readToEnd() ?? Data(), encoding: .utf8 + )) + let splits = commands.split(separator: "\n").filter { $0.hasPrefix("split-window ") } + #expect(splits == [Substring("split-window -P -F '#{pane_id}' \(splitDown ? "-v" : "-h") -t @1.%11")]) + } + + @Test func sessionMirrorSplitRejectsWhileReconnecting() throws { + let harness = try RemoteTmuxSessionMirrorLayoutHarness() + defer { harness.tearDown() } + let panel = try #require(harness.singlePanePanel(tmuxPaneID: 11)) + let panelsBefore = Set(harness.workspace.panels.keys) + harness.connection.beginReconnecting() + + let outcome = harness.manager.createSplitOutcome( + tabId: harness.workspace.id, surfaceId: panel.id, direction: .right + ) + + #expect(!outcome.isAccepted) + #expect(Set(harness.workspace.panels.keys) == panelsBefore) + } + + @Test(arguments: [ + (SplitDirection.right, "-h", false), + (SplitDirection.down, "-v", false), + (SplitDirection.left, "-h", true), + (SplitDirection.up, "-v", true), + ], [true, false]) + func splitActionRoutesTheRequestedProjectedPane( + configuration: (SplitDirection, String, Bool), + focus: Bool + ) throws { + let harness = try RemoteTmuxMirrorCLIObservabilityTests.Harness( + connectedTransport: true + ) + defer { harness.tearDown() } + let manager = try #require(harness.appDelegate.tabManagerFor(windowId: harness.windowID)) + let surfaceID = try #require(harness.mirror.panel(forPane: 11)?.id) + let activePaneBefore = harness.mirror.activePaneId + let panelsBefore = Set(harness.workspace.panels.keys) + #expect(activePaneBefore == 22) + #expect(harness.workspace.panels[surfaceID] == nil) + + let outcome = manager.createSplitOutcome( + tabId: harness.workspace.id, + surfaceId: surfaceID, + direction: configuration.0, + focus: focus + ) + + guard case .routedToRemote = outcome else { + Issue.record("Expected projected-pane split to route to remote tmux") + return + } + #expect(Set(harness.workspace.panels.keys) == panelsBefore) + #expect(harness.mirror.activePaneId == activePaneBefore) + let writer = try #require(harness.controlWriter) + let pipe = try #require(harness.controlPipe) + writer.close() + let commands = try #require(String( + bytes: try pipe.fileHandleForReading.readToEnd() ?? Data(), + encoding: .utf8 + )) + let splits = commands.split(separator: "\n").filter { $0.hasPrefix("split-window ") } + #expect(splits.count == 1) + let command = try #require(splits.first) + let tokens = command.split(separator: " ") + #expect(command.hasSuffix("-t @3.%11")) + #expect(tokens.contains(Substring(configuration.1))) + #expect(tokens.contains("-b") == configuration.2) + #expect(tokens.contains("-d") == !focus) + #expect(tokens.contains("-P") == focus) + } + + @Test func splitActionRejectsDisconnectedProjectedPane() throws { + let harness = try RemoteTmuxMirrorCLIObservabilityTests.Harness() + defer { harness.tearDown() } + let manager = try #require(harness.appDelegate.tabManagerFor(windowId: harness.windowID)) + let surfaceID = try #require(harness.mirror.panel(forPane: 11)?.id) + let panelsBefore = Set(harness.workspace.panels.keys) + + let outcome = manager.createSplitOutcome( + tabId: harness.workspace.id, + surfaceId: surfaceID, + direction: .right + ) + + #expect(!outcome.isAccepted) + #expect(Set(harness.workspace.panels.keys) == panelsBefore) + } + + @Test func splitActionRejectsUnknownSurface() throws { + let harness = try RemoteTmuxMirrorCLIObservabilityTests.Harness( + connectedTransport: true + ) + defer { harness.tearDown() } + let manager = try #require(harness.appDelegate.tabManagerFor(windowId: harness.windowID)) + let panelsBefore = Set(harness.workspace.panels.keys) + + let outcome = manager.createSplitOutcome( + tabId: harness.workspace.id, + surfaceId: UUID(), + direction: .right + ) + + #expect(!outcome.isAccepted) + #expect(Set(harness.workspace.panels.keys) == panelsBefore) + } + + @Test func localSplitActionStillCreatesLocalPanel() throws { + let harness = try Harness() + defer { harness.tearDown() } + let manager = try #require(harness.appDelegate.tabManagerFor(windowId: harness.windowId)) + let panelsBefore = harness.workspace.panels.count + + let outcome = manager.createSplitOutcome( + tabId: harness.workspace.id, + surfaceId: harness.sourcePanelId, + direction: .right, + focus: false + ) + + #expect(outcome.panel != nil) + #expect(harness.workspace.panels.count == panelsBefore + 1) + } + @Test func mirrorWorkspaceSplitNeverCreatesLocalPanel() throws { let harness = try Harness() defer { harness.tearDown() } From 28e864017f1f38bea1f7bf757be0ab1ea57bcd3d Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Wed, 14 Oct 2026 01:09:22 -0700 Subject: [PATCH 021/183] fix(cmux-tui): drop an unneeded BTreeSet qualification (#20501) BTreeSet is already imported, and clippy's -D unused-qualifications fails the cmux-tui lint job on main. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../cmux-tui-core/src/workspace_registry/session_journal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs index 253062d0e53e..7c004a926740 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs @@ -1591,7 +1591,7 @@ fn backfill_legacy_journal_segment_kinds(connection: &Connection) -> anyhow::Res .records .into_iter() .map(|record| record.kind) - .collect::<std::collections::BTreeSet<_>>(); + .collect::<BTreeSet<_>>(); for kind in kinds { connection.execute( "INSERT OR IGNORE INTO journal_segment_kinds(segment_id, kind) VALUES(?1, ?2)", From 4d9d0f7c3ec27cae47732695d2c1e4bc108d125b Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Wed, 14 Oct 2026 01:16:13 -0700 Subject: [PATCH 022/183] Expect the request deadline error in the Cloud command timeout test (#20502) #20376 gave a persistent channel's per-request deadline its own error, LinkError.requestTimedOut, separate from the connect timeout. The app already classifies it as a timeout and as retryable, but this test still caught only .timedOut, so the deadline it waits for escaped as an uncaught error. Catch the error the channel now throws, and check that same error is retryable. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- cmuxTests/CloudCommandOperationTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CloudCommandOperationTests.swift b/cmuxTests/CloudCommandOperationTests.swift index bd6ea4e7179d..c37f9e6dcc2e 100644 --- a/cmuxTests/CloudCommandOperationTests.swift +++ b/cmuxTests/CloudCommandOperationTests.swift @@ -30,7 +30,7 @@ import Testing do { _ = try await command.value Issue.record("a command deadline must fail the operation") - } catch CloudMachineLink.LinkError.timedOut {} + } catch CloudMachineLink.LinkError.requestTimedOut {} } let failed = try #require(recorder.operations.first) #expect(!failed.isRunning) @@ -38,7 +38,7 @@ import Testing #expect(failed.failure == .timeout) #expect(failed.steps.first?.failure == .timeout) #expect(failed.isVisibleInMachinesPanel) - #expect(CloudTuiDaemonAnswer(error: CloudMachineLink.LinkError.timedOut).isRetryable) + #expect(CloudTuiDaemonAnswer(error: CloudMachineLink.LinkError.requestTimedOut).isRetryable) let success = recorder.begin(.workspace) await recorder.finish(success) From b6981ca935215b20013dbc1837ebd6d445c7f53a Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Wed, 14 Oct 2026 01:18:29 -0700 Subject: [PATCH 023/183] Retry Codex lock contention instead of refusing restores on relaunch (#20499) * Add regression for Codex restore failing on coordination-lock contention Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Retry Codex lock-namespace contention before refusing a restore During a relaunch dozens of Codex threads resume at once, each briefly holding thread-writer-locks/.coordination.lock. The CLI's final exec guard treated that `.changing` probe like an unreadable lock and refused an unowned conversation. It now re-probes `.changing` for a bounded ~3.5s, woken early by lock-namespace file events, while `.active` and `.unavailable` still refuse at once with case-specific messages. The app admission path already kept `.changing` recoverable, but waited up to 8s per round for a file event a flock release never raises; it now re-probes after 250ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Re-probe lock-namespace changes inside one admission request Review found that a 250ms evidence bound turns a coordination lock that never settles into ~4 admission RPCs per second per pane. Keep the 8s request bound and instead re-probe `.changing` on the settle bounds within the same request, with an app-host regression for the waiting request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- CLI/CMUXCLI+CodexWriterFinalGuard.swift | 47 +- .../CodexWriterLockSettle.swift | 62 ++ .../CodexWriterLockTests.swift | 64 ++ Resources/Localizable.xcstrings | 813 ++++++++++-------- Sources/AgentRestoreEvidenceObservation.swift | 11 +- ...inalController+AgentRestoreAdmission.swift | 20 +- .../CLICodexWriterLockRestoreTests.swift | 90 ++ .../CMUXCLIErrorOutputRegressionTests.swift | 10 +- .../CodexWriterAdmissionRecoveryTests.swift | 100 ++- 9 files changed, 788 insertions(+), 429 deletions(-) create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockSettle.swift diff --git a/CLI/CMUXCLI+CodexWriterFinalGuard.swift b/CLI/CMUXCLI+CodexWriterFinalGuard.swift index 0ba4ee7c938c..9814a2c74fb6 100644 --- a/CLI/CMUXCLI+CodexWriterFinalGuard.swift +++ b/CLI/CMUXCLI+CodexWriterFinalGuard.swift @@ -6,9 +6,10 @@ extension CMUXCLI { /// /// The app admission RPC is unavailable when a newer CLI talks to an older /// cmux. This final guard keeps that compatibility path from silently - /// entering Codex's read-only mode. It never waits, removes a lock, or - /// signals the process that owns it; Codex remains the atomic authority - /// after this advisory observation. + /// entering Codex's read-only mode. It waits only a bounded time while + /// Codex changes its lock namespace, and never removes a lock or signals + /// the process that owns it; Codex remains the atomic authority after + /// this advisory observation. func requireCodexWriterAvailable( invocation: AgentRestoreInvocation, workingDirectory: String @@ -22,26 +23,42 @@ extension CMUXCLI { workingDirectory: workingDirectory, fallbackHome: NSHomeDirectory() ) - var inspection = CodexWriterLockInspector().inspect( - sessionID: sessionID, - codexHome: home - ) + // Another Codex starting during a relaunch can hold the namespace + // briefly; retry that alone so it is not mistaken for an owner. + let probe = { CodexWriterLockInspector().inspect(sessionID: sessionID, codexHome: home) } + var inspection = CodexWriterLockSettle().inspection(probe: probe) var candidates: [CodexWriterProcessInspector.Candidate] = [] if inspection.state == .active { let observed = inspection candidates = CodexWriterProcessInspector().candidates(for: observed) - inspection = CodexWriterLockInspector().inspect(sessionID: sessionID, codexHome: home) + inspection = CodexWriterLockSettle().inspection(probe: probe) if !inspection.deviceAndInodeMatch(observed) { candidates = [] } } - guard inspection.state == .available else { + switch inspection.state { + case .available: + return + case .active: + throw loggedRestoreError( + stage: "session.writer-lock-held", + detail: "session=\(sessionID)", + message: CodexWriterRestoreNotice().message(candidates: candidates) + ) + case .changing: + throw loggedRestoreError( + stage: "session.writer-lock-changing", + detail: "session=\(sessionID)", + message: String( + localized: "agentRestore.writerLock.changing", + defaultValue: "Codex was still updating its session locks, so cmux could not confirm this conversation is free. Retry 'cmux restore --surface'." + ) + ) + case .unavailable: throw loggedRestoreError( - stage: inspection.state == .active - ? "session.writer-lock-held" - : "session.writer-check-unavailable", + stage: "session.writer-check-unavailable", detail: "session=\(sessionID)", - message: inspection.state == .active ? CodexWriterRestoreNotice().message(candidates: candidates) : String( - localized: "agentRestore.admission.unavailable", - defaultValue: "cmux could not verify whether this agent session is already running. Retry 'cmux restore --surface'." + message: String( + localized: "agentRestore.writerLock.unreadable", + defaultValue: "cmux could not read this conversation's Codex session lock, so it could not confirm the conversation is free. Retry 'cmux restore --surface'." ) ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockSettle.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockSettle.swift new file mode 100644 index 000000000000..12a41f6bb767 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockSettle.swift @@ -0,0 +1,62 @@ +import Darwin +import Foundation + +/// Re-probes Codex's writer lock while Codex is changing its lock namespace. +/// +/// `.changing` means another Codex held the shared coordination lock or +/// swapped a lock file during the probe. A relaunch resumes dozens of threads +/// in the same second, each holding coordination briefly, so a single probe +/// often lands in that window even though nobody owns this conversation. +/// Only `.changing` is retried; `.active` and `.unavailable` return at once. +public struct CodexWriterLockSettle: Sendable { + /// Creates a stateless settling policy. + public init() {} + + /// About 3.5 seconds, front-loaded: one Codex holds coordination for + /// milliseconds, while a whole relaunch burst can take a few seconds. + public static let delays: [TimeInterval] = [0.02, 0.05, 0.1, 0.2, 0.4, 0.8, 1, 1] + + /// Probes until the lock is not `.changing`, or the delay budget is spent. + /// + /// - Parameters: + /// - delays: the longest wait before each re-probe; its count bounds the probes. + /// - awaitChange: waits at most the given interval for the namespace to change. + /// - probe: one nonblocking lock inspection. + /// - Returns: The first settled observation, or the last `.changing` one. + public func inspection( + delays: [TimeInterval] = Self.delays, + awaitChange: (CodexWriterLockInspection, TimeInterval) -> Void = Self.awaitNamespaceChange, + probe: () -> CodexWriterLockInspection + ) -> CodexWriterLockInspection { + var inspection = probe() + for delay in delays where inspection.state == .changing { + awaitChange(inspection, delay) + inspection = probe() + } + return inspection + } + + /// Returns when Codex creates, renames, or unlinks a thread lock, or after `timeout`. + /// + /// Releasing a flock produces no file event, so the timeout is the + /// re-probe bound rather than a guess at when the writer finishes. + public static func awaitNamespaceChange(_ inspection: CodexWriterLockInspection, timeout: TimeInterval) { + let directory = URL(fileURLWithPath: inspection.lockPath).deletingLastPathComponent().path + let descriptor = open(directory, O_EVTONLY | O_CLOEXEC) + // `.changing` implies the directory exists; without an event source + // the bound alone paces the next probe. + guard descriptor >= 0 else { + _ = DispatchSemaphore(value: 0).wait(timeout: .now() + timeout) + return + } + let changed = DispatchSemaphore(value: 0) + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: [.write, .delete, .rename], queue: .global(qos: .utility) + ) + source.setEventHandler { changed.signal() } + source.setCancelHandler { close(descriptor) } + source.resume() + defer { source.cancel() } + _ = changed.wait(timeout: .now() + timeout) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockTests.swift index ce17fc40fdb4..420ccb4f2483 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockTests.swift @@ -84,6 +84,70 @@ import Testing #expect(flock(coordination, LOCK_EX | LOCK_NB) == 0) } + @Test func settleRetriesOnlyNamespaceChangesUntilTheyEnd() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let coordination = try fixture.hold(fixture.locks.appendingPathComponent(".coordination.lock").path) + defer { close(coordination) } + var waits: [TimeInterval] = [] + // Another Codex finishes its own lock update while this restore waits. + let settled = CodexWriterLockSettle().inspection( + awaitChange: { observed, delay in + #expect(observed.state == .changing) + waits.append(delay) + if waits.count == 3 { #expect(flock(coordination, LOCK_UN) == 0) } + }, + probe: fixture.inspect + ) + #expect(settled.state == .available) + #expect(waits == Array(CodexWriterLockSettle.delays.prefix(3))) + } + + @Test func settleReportsPersistentChangeAfterItsBudget() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let coordination = try fixture.hold(fixture.locks.appendingPathComponent(".coordination.lock").path) + defer { close(coordination) } + var waits = 0 + let settled = CodexWriterLockSettle().inspection(awaitChange: { _, _ in waits += 1 }, probe: fixture.inspect) + #expect(settled.state == .changing) + #expect(waits == CodexWriterLockSettle.delays.count) + } + + @Test func settleNeverRetriesAHeldOrUnreadableLock() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let writer = try fixture.hold() + defer { close(writer) } + let held = CodexWriterLockSettle().inspection( + awaitChange: { _, _ in Issue.record("A held writer is an owner, not a transient change") }, + probe: fixture.inspect + ) + #expect(held.state == .active) + let unreadable = CodexWriterLockSettle().inspection( + awaitChange: { _, _ in Issue.record("Unreadable state must fail closed at once") }, + probe: { CodexWriterLockInspector().inspect(sessionID: "../escape", codexHome: fixture.home.path) } + ) + #expect(unreadable.state == .unavailable) + } + + @Test func namespaceWaitReturnsOnALockFileChange() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let coordination = try fixture.hold(fixture.locks.appendingPathComponent(".coordination.lock").path) + defer { close(coordination) } + let observed = fixture.inspect() + try #require(observed.state == .changing) + let path = fixture.path + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + FileManager.default.createFile(atPath: path, contents: nil) + } + let start = ContinuousClock.now + // The created lock file, not the generous bound, ends this wait. + CodexWriterLockSettle.awaitNamespaceChange(observed, timeout: 30) + #expect(start.duration(to: .now) < .seconds(10)) + } + @Test func malformedAndNonregularPathsFailClosed() throws { let fixture = try Fixture() defer { fixture.remove() } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 41c3f423c5ca..5a875be8eb6a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -487178,65 +487178,6 @@ } } }, - "agentRestore.admission.unavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux could not verify whether this agent session is already running. Retry 'cmux restore --surface'." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このエージェントセッションがすでに実行中かどうかを cmux で確認できませんでした。「cmux restore --surface」を再試行してください。" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "cmux konnte nicht überprüfen, ob diese Agenten-Sitzung bereits läuft. Versuche 'cmux restore --surface' erneut." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "cmux n’a pas pu vérifier si cette session d’agent est déjà en cours d’exécution. Réessayez 'cmux restore --surface'." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذر على cmux التحقق مما إذا كانت جلسة الوكيل هذه قيد التشغيل بالفعل. أعد محاولة 'cmux restore --surface'." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "cmux no pudo verificar si esta sesión del agente ya se está ejecutando. Reintenta 'cmux restore --surface'." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "cmux 無法驗證此代理程式工作階段是否已在執行。請重試 'cmux restore --surface'。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "cmux 无法验证此智能体会话是否已在运行。请重试 'cmux restore --surface'。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux에서 이 에이전트 세션이 이미 실행 중인지 확인할 수 없습니다. 'cmux restore --surface'를 다시 시도하십시오." - } - } - } - }, "agentRestore.liveOwner.notice": { "extractionState": "manual", "localizations": { @@ -651822,1117 +651763,1235 @@ "ja": { "stringUnit": { "state": "translated", "value": "cmux サービスが CodeRouter の引き継ぎを拒否しました。もう一度お試しください。" } } } }, - "cloud.operation.failure.capacity": { + "account.signIn.copyErrorDetails": { "extractionState": "manual", "localizations": { - "bs": { - "stringUnit": { - "state": "translated", - "value": "Pokrenute mašine vašeg tima koriste sav Cloud kapacitet plana. Pauzirajte ili izbrišite pokrenutu mašinu, pa pokušajte ponovo." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Dit teams kørende maskiner bruger hele planens Cloud-kapacitet. Sæt en kørende maskine på pause eller slet den, og prøv igen." - } - }, - "it": { + "en": { "stringUnit": { "state": "translated", - "value": "Le macchine in esecuzione del tuo team usano tutta la capacità Cloud del piano. Metti in pausa o elimina una macchina in esecuzione, poi riprova." + "value": "Copy Error Details" } }, - "km": { + "ar": { "stringUnit": { "state": "translated", - "value": "ម៉ាស៊ីនដែលកំពុងដំណើរការរបស់ក្រុមអ្នកកំពុងប្រើសមត្ថភាព Cloud ទាំងអស់របស់គម្រោង។ ផ្អាក ឬលុបម៉ាស៊ីនដែលកំពុងដំណើរការមួយ រួចព្យាយាមម្តងទៀត។" + "value": "نسخ تفاصيل الخطأ" } }, - "nb": { + "de": { "stringUnit": { "state": "translated", - "value": "Teamets kjørende maskiner bruker hele Cloud-kapasiteten i planen. Sett en kjørende maskin på pause eller slett den, og prøv igjen." + "value": "Fehlerdetails kopieren" } }, - "pl": { + "es": { "stringUnit": { "state": "translated", - "value": "Uruchomione maszyny Twojego zespołu wykorzystują całą pojemność Cloud w planie. Wstrzymaj lub usuń uruchomioną maszynę, a następnie spróbuj ponownie." + "value": "Copiar detalles del error" } }, - "pt-BR": { + "fr": { "stringUnit": { "state": "translated", - "value": "As máquinas em execução da sua equipe estão usando toda a capacidade de Cloud do plano. Pause ou exclua uma máquina em execução e tente novamente." + "value": "Copier les détails de l’erreur" } }, - "ru": { + "ja": { "stringUnit": { "state": "translated", - "value": "Запущенные машины вашей команды используют всю мощность Cloud по тарифу. Приостановите или удалите запущенную машину и повторите попытку." + "value": "エラーの詳細をコピー" } }, - "th": { + "ko": { "stringUnit": { "state": "translated", - "value": "เครื่องที่กำลังทำงานของทีมคุณใช้ความจุ Cloud ของแผนจนหมดแล้ว หยุดเครื่องที่กำลังทำงานชั่วคราวหรือลบออก แล้วลองอีกครั้ง" + "value": "오류 세부 정보 복사" } }, - "tr": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Ekibinizin çalışan makineleri planın tüm Cloud kapasitesini kullanıyor. Çalışan bir makineyi duraklatın veya silin, ardından tekrar deneyin." + "value": "复制错误详细信息" } }, - "uk": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Запущені машини вашої команди використовують усю потужність Cloud за планом. Призупиніть або видаліть запущену машину й спробуйте ще раз." + "value": "複製錯誤詳細資訊" } - }, + } + } + }, + "account.signIn.errorDetailsCopied": { + "extractionState": "manual", + "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your team's running machines are using all of the plan's Cloud capacity. Pause or delete a running machine, then try again." + "value": "Error Details Copied" } }, "ar": { "stringUnit": { "state": "translated", - "value": "تستخدم الأجهزة قيد التشغيل لدى فريقك كامل سعة Cloud في الخطة. أوقف جهازًا قيد التشغيل مؤقتًا أو احذفه، ثم حاول مرة أخرى." + "value": "تم نسخ تفاصيل الخطأ" } }, "de": { "stringUnit": { "state": "translated", - "value": "Die laufenden Maschinen deines Teams nutzen die gesamte Cloud-Kapazität des Plans. Pausiere oder lösche eine laufende Maschine und versuche es dann erneut." + "value": "Fehlerdetails kopiert" } }, "es": { "stringUnit": { "state": "translated", - "value": "Las máquinas en ejecución de tu equipo están usando toda la capacidad de Cloud del plan. Pausa o elimina una máquina en ejecución y vuelve a intentarlo." + "value": "Detalles del error copiados" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Les machines en cours d'exécution de votre équipe utilisent toute la capacité Cloud du forfait. Mettez en pause ou supprimez une machine en cours d'exécution, puis réessayez." + "value": "Détails de l’erreur copiés" } }, "ja": { "stringUnit": { "state": "translated", - "value": "チームの実行中のマシンがプランの Cloud 容量をすべて使用しています。実行中のマシンを一時停止または削除してから、もう一度お試しください。" + "value": "エラーの詳細をコピーしました" } }, "ko": { "stringUnit": { "state": "translated", - "value": "팀의 실행 중인 머신이 플랜의 Cloud 용량을 모두 사용하고 있습니다. 실행 중인 머신을 일시 정지하거나 삭제한 후 다시 시도하세요." + "value": "오류 세부 정보 복사됨" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "您团队中正在运行的机器已用尽套餐的全部 Cloud 容量。请暂停或删除一台正在运行的机器,然后重试。" + "value": "已复制错误详细信息" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "您團隊中執行中的機器已用盡方案的全部 Cloud 容量。請暫停或刪除一台執行中的機器,然後再試一次。" + "value": "已複製錯誤詳細資訊" } } } }, - "cloud.operation.failure.activeLimit": { + "auth.error.rate_limited": { "extractionState": "manual", "localizations": { - "bs": { - "stringUnit": { - "state": "translated", - "value": "Sve aktivne mašine koje vaš plan dozvoljava su u upotrebi. Pauzirajte ili izbrišite pokrenutu mašinu, pa pokušajte ponovo." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Alle aktive maskiner, som din plan tillader, er i brug. Sæt en kørende maskine på pause eller slet den, og prøv igen." - } - }, - "it": { + "en": { "stringUnit": { "state": "translated", - "value": "Tutte le macchine attive consentite dal tuo piano sono in uso. Metti in pausa o elimina una macchina in esecuzione, poi riprova." + "value": "Too many sign-in requests from this network. cmux retries automatically." } }, - "km": { + "ar": { "stringUnit": { "state": "translated", - "value": "ម៉ាស៊ីនសកម្មទាំងអស់ដែលគម្រោងរបស់អ្នកអនុញ្ញាតកំពុងប្រើប្រាស់។ ផ្អាក ឬលុបម៉ាស៊ីនដែលកំពុងដំណើរការមួយ រួចព្យាយាមម្តងទៀត។" + "value": "طلبات تسجيل دخول كثيرة جدًا من هذه الشبكة. يعيد cmux المحاولة تلقائيًا." } }, - "nb": { + "de": { "stringUnit": { "state": "translated", - "value": "Alle aktive maskiner planen din tillater, er i bruk. Sett en kjørende maskin på pause eller slett den, og prøv igjen." + "value": "Zu viele Anmeldeanfragen aus diesem Netzwerk. cmux versucht es automatisch erneut." } }, - "pl": { + "es": { "stringUnit": { "state": "translated", - "value": "Wszystkie aktywne maszyny dozwolone w Twoim planie są w użyciu. Wstrzymaj lub usuń uruchomioną maszynę, a następnie spróbuj ponownie." + "value": "Demasiadas solicitudes de inicio de sesión desde esta red. cmux lo reintenta automáticamente." } }, - "pt-BR": { + "fr": { "stringUnit": { "state": "translated", - "value": "Todas as máquinas ativas que seu plano permite estão em uso. Pause ou exclua uma máquina em execução e tente novamente." + "value": "Trop de demandes de connexion depuis ce réseau. cmux réessaie automatiquement." } }, - "ru": { + "ja": { "stringUnit": { "state": "translated", - "value": "Все активные машины, разрешённые вашим тарифом, уже используются. Приостановите или удалите запущенную машину и повторите попытку." + "value": "このネットワークからのサインイン要求が多すぎます。cmux が自動的に再試行します。" } }, - "th": { + "ko": { "stringUnit": { "state": "translated", - "value": "เครื่องที่ใช้งานอยู่ทั้งหมดที่แผนของคุณอนุญาตกำลังถูกใช้งาน หยุดเครื่องที่กำลังทำงานชั่วคราวหรือลบออก แล้วลองอีกครั้ง" + "value": "이 네트워크에서 로그인 요청이 너무 많습니다. cmux가 자동으로 다시 시도합니다." } }, - "tr": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Planınızın izin verdiği tüm etkin makineler kullanımda. Çalışan bir makineyi duraklatın veya silin, ardından tekrar deneyin." + "value": "来自此网络的登录请求过多。cmux 会自动重试。" } }, - "uk": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Усі активні машини, дозволені вашим планом, уже використовуються. Призупиніть або видаліть запущену машину й спробуйте ще раз." + "value": "來自此網路的登入要求過多。cmux 會自動重試。" } - }, + } + } + }, + "account.signIn.error.rateLimited.recovery": { + "extractionState": "manual", + "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Every active machine your plan allows is in use. Pause or delete a running machine, then try again." + "value": "cmux retried automatically. Wait a minute, then try again. Your account and existing cmux workspaces are unchanged." } }, "ar": { "stringUnit": { "state": "translated", - "value": "جميع الأجهزة النشطة التي تسمح بها خطتك قيد الاستخدام. أوقف جهازًا قيد التشغيل مؤقتًا أو احذفه، ثم حاول مرة أخرى." + "value": "أعاد cmux المحاولة تلقائيًا. انتظر دقيقة ثم حاول مرة أخرى. لم يتغير حسابك ولا مساحات عمل cmux الحالية." } }, "de": { "stringUnit": { "state": "translated", - "value": "Alle aktiven Maschinen, die dein Plan erlaubt, sind in Gebrauch. Pausiere oder lösche eine laufende Maschine und versuche es dann erneut." + "value": "cmux hat es automatisch erneut versucht. Warte eine Minute und versuche es dann erneut. Dein Konto und deine bestehenden cmux-Workspaces bleiben unverändert." } }, "es": { "stringUnit": { "state": "translated", - "value": "Todas las máquinas activas que permite tu plan están en uso. Pausa o elimina una máquina en ejecución y vuelve a intentarlo." + "value": "cmux lo reintentó automáticamente. Espera un minuto y vuelve a intentarlo. Tu cuenta y tus espacios de trabajo de cmux existentes no han cambiado." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Toutes les machines actives autorisées par votre forfait sont utilisées. Mettez en pause ou supprimez une machine en cours d'exécution, puis réessayez." + "value": "cmux a réessayé automatiquement. Patientez une minute, puis réessayez. Votre compte et vos espaces de travail cmux existants sont inchangés." } }, "ja": { "stringUnit": { "state": "translated", - "value": "プランで利用できるアクティブなマシンはすべて使用中です。実行中のマシンを一時停止または削除してから、もう一度お試しください。" + "value": "cmux が自動的に再試行しました。1 分ほど待ってから、もう一度お試しください。アカウントと既存の cmux ワークスペースは変更されていません。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "플랜에서 허용하는 활성 머신을 모두 사용 중입니다. 실행 중인 머신을 일시 정지하거나 삭제한 후 다시 시도하세요." + "value": "cmux가 자동으로 다시 시도했습니다. 1분 정도 기다린 후 다시 시도하세요. 계정과 기존 cmux 워크스페이스는 변경되지 않았습니다." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "您的套餐允许的所有活跃机器都已在使用。请暂停或删除一台正在运行的机器,然后重试。" + "value": "cmux 已自动重试。请等待一分钟后再试。你的账户和现有的 cmux 工作区均未更改。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "您的方案允許的所有使用中機器都已在使用。請暫停或刪除一台執行中的機器,然後再試一次。" + "value": "cmux 已自動重試。請稍候一分鐘後再試一次。你的帳號和現有的 cmux 工作區都沒有變更。" } } } }, - "cloud.operation.failure.createCredits": { + "account.signIn.error.rateLimited.title": { "extractionState": "manual", "localizations": { - "bs": { - "stringUnit": { - "state": "translated", - "value": "Vaš tim više nema kredita za kreiranje mašina. Zamolite administratora tima da doda kredite ili nadogradite plan." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Dit team har ikke flere kreditter til at oprette maskiner. Bed en teamadministrator om at tilføje kreditter, eller opgrader planen." - } - }, - "it": { + "en": { "stringUnit": { "state": "translated", - "value": "Il tuo team non ha più crediti per creare macchine. Chiedi a un amministratore del team di aggiungere crediti o passa a un piano superiore." + "value": "Too many sign-in requests from this network" } }, - "km": { + "ar": { "stringUnit": { "state": "translated", - "value": "ក្រុមរបស់អ្នកលែងមានក្រេឌីតសម្រាប់បង្កើតម៉ាស៊ីនទៀតហើយ។ សូមស្នើអ្នកគ្រប់គ្រងក្រុមឱ្យបន្ថែមក្រេឌីត ឬដំឡើងគម្រោង។" + "value": "طلبات تسجيل دخول كثيرة جدًا من هذه الشبكة" } }, - "nb": { + "de": { "stringUnit": { "state": "translated", - "value": "Teamet ditt har ikke flere kreditter for å opprette maskiner. Be en teamadministrator om å legge til kreditter, eller oppgrader planen." + "value": "Zu viele Anmeldeanfragen aus diesem Netzwerk" } }, - "pl": { + "es": { "stringUnit": { "state": "translated", - "value": "Twój zespół nie ma już kredytów na tworzenie maszyn. Poproś administratora zespołu o dodanie kredytów lub zmień plan na wyższy." + "value": "Demasiadas solicitudes de inicio de sesión desde esta red" } }, - "pt-BR": { + "fr": { "stringUnit": { "state": "translated", - "value": "Sua equipe não tem mais créditos para criar máquinas. Peça a um administrador da equipe para adicionar créditos ou faça upgrade do plano." + "value": "Trop de demandes de connexion depuis ce réseau" } }, - "ru": { + "ja": { "stringUnit": { "state": "translated", - "value": "У вашей команды закончились кредиты на создание машин. Попросите администратора команды добавить кредиты или повысьте тариф." + "value": "このネットワークからのサインイン要求が多すぎます" } }, - "th": { + "ko": { "stringUnit": { "state": "translated", - "value": "ทีมของคุณไม่มีเครดิตสำหรับสร้างเครื่องเหลือแล้ว ขอให้ผู้ดูแลทีมเพิ่มเครดิต หรืออัปเกรดแผน" + "value": "이 네트워크에서 로그인 요청이 너무 많습니다" } }, - "tr": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Ekibinizin makine oluşturma kredisi kalmadı. Bir ekip yöneticisinden kredi eklemesini isteyin veya planı yükseltin." + "value": "来自此网络的登录请求过多" } }, - "uk": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "У вашої команди закінчилися кредити на створення машин. Попросіть адміністратора команди додати кредити або перейдіть на вищий план." + "value": "來自此網路的登入要求過多" } - }, + } + } + }, + "account.signIn.loading.rateLimited": { + "extractionState": "manual", + "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your team has no machine create credits left. Ask a team admin to add credits or upgrade the plan." + "value": "Too many sign-in requests from this network" } }, "ar": { "stringUnit": { "state": "translated", - "value": "لم يعد لدى فريقك أي أرصدة لإنشاء الأجهزة. اطلب من مسؤول الفريق إضافة أرصدة أو قم بترقية الخطة." + "value": "طلبات تسجيل دخول كثيرة جدًا من هذه الشبكة" } }, "de": { "stringUnit": { "state": "translated", - "value": "Dein Team hat keine Credits zum Erstellen von Maschinen mehr. Bitte einen Team-Admin, Credits hinzuzufügen, oder führe ein Upgrade des Plans durch." + "value": "Zu viele Anmeldeanfragen aus diesem Netzwerk" } }, "es": { "stringUnit": { "state": "translated", - "value": "Tu equipo no tiene créditos para crear máquinas. Pide a un administrador del equipo que añada créditos o mejora el plan." + "value": "Demasiadas solicitudes de inicio de sesión desde esta red" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Votre équipe n'a plus de crédits de création de machines. Demandez à un administrateur de l'équipe d'ajouter des crédits ou passez à un forfait supérieur." + "value": "Trop de demandes de connexion depuis ce réseau" } }, "ja": { "stringUnit": { "state": "translated", - "value": "チームにマシン作成クレジットが残っていません。チーム管理者にクレジットの追加を依頼するか、プランをアップグレードしてください。" + "value": "このネットワークからのサインイン要求が多すぎます" } }, "ko": { "stringUnit": { "state": "translated", - "value": "팀에 남은 머신 생성 크레딧이 없습니다. 팀 관리자에게 크레딧 추가를 요청하거나 플랜을 업그레이드하세요." + "value": "이 네트워크에서 로그인 요청이 너무 많습니다" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "您的团队已没有创建机器的额度。请让团队管理员添加额度,或升级套餐。" + "value": "来自此网络的登录请求过多" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "您的團隊已沒有建立機器的點數。請團隊管理員加入點數,或升級方案。" + "value": "來自此網路的登入要求過多" } } } }, - "cloud.operation.failure.memoryRequiresPlan": { + "account.signIn.loading.rateLimited.instructions": { "extractionState": "manual", "localizations": { - "bs": { - "stringUnit": { - "state": "translated", - "value": "Ova veličina je veća od onoga što vaš plan uključuje. Odaberite manju veličinu ili nadogradite plan." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Denne størrelse er større, end din plan omfatter. Vælg en mindre størrelse, eller opgrader planen." - } - }, - "it": { + "en": { "stringUnit": { "state": "translated", - "value": "Questa dimensione supera quanto incluso nel tuo piano. Scegli una dimensione più piccola o passa a un piano superiore." + "value": "cmux retries automatically. Keep this pane open." } }, - "km": { + "ar": { "stringUnit": { "state": "translated", - "value": "ទំហំនេះធំជាងអ្វីដែលគម្រោងរបស់អ្នករួមបញ្ចូល។ ជ្រើសរើសទំហំតូចជាងនេះ ឬដំឡើងគម្រោង។" + "value": "يعيد cmux المحاولة تلقائيًا. أبقِ هذه اللوحة مفتوحة." } }, - "nb": { + "de": { "stringUnit": { "state": "translated", - "value": "Denne størrelsen er større enn det planen din inkluderer. Velg en mindre størrelse, eller oppgrader planen." + "value": "cmux versucht es automatisch erneut. Lass diesen Bereich geöffnet." } }, - "pl": { + "es": { "stringUnit": { "state": "translated", - "value": "Ten rozmiar przekracza to, co obejmuje Twój plan. Wybierz mniejszy rozmiar lub zmień plan na wyższy." + "value": "cmux lo reintenta automáticamente. Mantén este panel abierto." } }, - "pt-BR": { + "fr": { "stringUnit": { "state": "translated", - "value": "Este tamanho é maior do que seu plano inclui. Escolha um tamanho menor ou faça upgrade do plano." + "value": "cmux réessaie automatiquement. Gardez ce volet ouvert." } }, - "ru": { + "ja": { "stringUnit": { "state": "translated", - "value": "Этот размер больше, чем включено в ваш тариф. Выберите размер поменьше или повысьте тариф." + "value": "cmux が自動的に再試行します。このペインを開いたままにしてください。" } }, - "th": { + "ko": { "stringUnit": { "state": "translated", - "value": "ขนาดนี้ใหญ่กว่าที่แผนของคุณรวมไว้ เลือกขนาดที่เล็กกว่า หรืออัปเกรดแผน" + "value": "cmux가 자동으로 다시 시도합니다. 이 패널을 열어 두세요." } }, - "tr": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Bu boyut planınızın kapsadığından daha büyük. Daha küçük bir boyut seçin veya planı yükseltin." + "value": "cmux 会自动重试。请保持此面板打开。" } }, - "uk": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Цей розмір більший, ніж включено у ваш план. Виберіть менший розмір або перейдіть на вищий план." + "value": "cmux 會自動重試。請保持此面板開啟。" } - }, + } + } + }, + "cloud.link.requestTimedOut": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Cloud machine did not answer the request before its deadline. The connection will be checked before retrying." } }, + "de": { "stringUnit": { "state": "translated", "value": "Die Cloud-Maschine hat vor Ablauf der Frist nicht geantwortet. Die Verbindung wird vor dem erneuten Versuch geprüft." } }, + "fr": { "stringUnit": { "state": "translated", "value": "La machine Cloud n’a pas répondu avant la date limite. La connexion sera vérifiée avant une nouvelle tentative." } }, + "ar": { "stringUnit": { "state": "translated", "value": "لم تستجب آلة Cloud قبل الموعد النهائي للطلب. سيتم التحقق من الاتصال قبل إعادة المحاولة." } }, + "es": { "stringUnit": { "state": "translated", "value": "La máquina Cloud no respondió antes del plazo. Se comprobará la conexión antes de volver a intentarlo." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Cloud 機器未在期限前回應要求。重試前會檢查連線。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Cloud 机器未在截止时间前响应请求。重试前将检查连接。" } }, + "ko": { "stringUnit": { "state": "translated", "value": "Cloud 머신이 기한 내에 요청에 응답하지 않았습니다. 다시 시도하기 전에 연결을 확인합니다." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Cloud マシンは期限内に応答しませんでした。再試行する前に接続を確認します。" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Cloud mašina nije odgovorila prije isteka roka. Veza će biti provjerena prije ponovnog pokušaja." } }, + "da": { "stringUnit": { "state": "translated", "value": "Cloud-maskinen svarede ikke inden tidsfristen. Forbindelsen kontrolleres, før der prøves igen." } }, + "it": { "stringUnit": { "state": "translated", "value": "La macchina Cloud non ha risposto entro il termine. La connessione verrà controllata prima di riprovare." } }, + "km": { "stringUnit": { "state": "translated", "value": "ម៉ាស៊ីន Cloud មិនបានឆ្លើយតបមុនពេលកំណត់ទេ។ ការតភ្ជាប់នឹងត្រូវបានពិនិត្យមុនពេលព្យាយាមម្តងទៀត។" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Cloud-maskinen svarte ikke innen tidsfristen. Tilkoblingen blir kontrollert før nytt forsøk." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Maszyna Cloud nie odpowiedziała przed upływem limitu czasu. Połączenie zostanie sprawdzone przed ponowną próbą." } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "A máquina Cloud não respondeu antes do prazo. A conexão será verificada antes de tentar novamente." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Облачная машина не ответила до истечения срока. Перед повторной попыткой будет проверено подключение." } }, + "th": { "stringUnit": { "state": "translated", "value": "เครื่อง Cloud ไม่ตอบสนองก่อนถึงกำหนด จะตรวจสอบการเชื่อมต่อก่อนลองอีกครั้ง" } }, + "tr": { "stringUnit": { "state": "translated", "value": "Cloud makinesi süre dolmadan isteğe yanıt vermedi. Yeniden denemeden önce bağlantı kontrol edilecek." } }, + "uk": { "stringUnit": { "state": "translated", "value": "Хмарна машина не відповіла до завершення терміну. Перед повторною спробою з’єднання буде перевірено." } } + } + }, + "cloud.welcome.note.teamAvailable": { + "extractionState": "manual", + "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This size is larger than your plan includes. Choose a smaller size, or upgrade the plan." + "value": "Cloud can be enabled through your team; machine access follows the team’s plan." } }, "ar": { "stringUnit": { "state": "translated", - "value": "هذا الحجم أكبر مما تتضمنه خطتك. اختر حجمًا أصغر، أو قم بترقية الخطة." + "value": "يمكن تفعيل Cloud عبر فريقك؛ ويعتمد الوصول إلى الأجهزة على خطة الفريق." } }, "de": { "stringUnit": { "state": "translated", - "value": "Diese Größe übersteigt, was dein Plan enthält. Wähle eine kleinere Größe oder führe ein Upgrade des Plans durch." + "value": "Cloud kann über Ihr Team aktiviert werden; der Maschinenzugriff richtet sich nach dem Teamplan." } }, "es": { "stringUnit": { "state": "translated", - "value": "Este tamaño supera lo que incluye tu plan. Elige un tamaño más pequeño o mejora el plan." + "value": "Cloud se puede activar a través de tu equipo; el acceso a las máquinas depende del plan del equipo." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Cette taille dépasse ce que comprend votre forfait. Choisissez une taille plus petite ou passez à un forfait supérieur." + "value": "Cloud peut être activé via votre équipe ; l’accès aux machines dépend du forfait de l’équipe." } }, "ja": { "stringUnit": { "state": "translated", - "value": "このサイズはプランに含まれる範囲を超えています。小さいサイズを選ぶか、プランをアップグレードしてください。" + "value": "Cloud はチームを通じて有効にできます。マシンへのアクセスはチームのプランに従います。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 크기는 플랜에 포함된 범위보다 큽니다. 더 작은 크기를 선택하거나 플랜을 업그레이드하세요." + "value": "팀을 통해 Cloud를 활성화할 수 있으며 머신 사용 권한은 팀 요금제에 따라 결정됩니다." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "此规格超出您的套餐所含范围。请选择较小的规格,或升级套餐。" + "value": "可通过您的团队启用 Cloud;机器访问权限取决于团队方案。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "此大小超出您的方案所包含的範圍。請選擇較小的大小,或升級方案。" + "value": "Cloud 可透過您的團隊啟用;機器存取權限取決於團隊方案。" } } } }, - "cloud.operation.failure.createCredits.account": { + "cloud.welcome.note.teamIncluded": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your account has no machine create credits left. Add Cloud VM create credits or upgrade your plan." + "value": "Included through your team’s plan." } }, "ar": { "stringUnit": { "state": "translated", - "value": "لم يعد لدى حسابك أي أرصدة لإنشاء الأجهزة. أضف أرصدة Cloud VM أو قم بترقية خطتك." + "value": "مضمّن في خطة فريقك." } }, "de": { "stringUnit": { "state": "translated", - "value": "Dein Konto hat keine Credits zum Erstellen von Maschinen mehr. Füge Cloud-VM-Credits hinzu oder führe ein Upgrade deines Plans durch." + "value": "Im Teamplan enthalten." } }, "es": { "stringUnit": { "state": "translated", - "value": "Tu cuenta no tiene créditos para crear máquinas. Añade créditos de Cloud VM o mejora tu plan." + "value": "Incluido en el plan de tu equipo." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Votre compte n'a plus de crédits de création de machines. Ajoutez des crédits de machines Cloud ou passez à un forfait supérieur." + "value": "Inclus dans le forfait de votre équipe." } }, "ja": { "stringUnit": { "state": "translated", - "value": "アカウントにマシン作成クレジットが残っていません。Cloud VM クレジットを追加するか、プランをアップグレードしてください。" + "value": "チームのプランに含まれています。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "계정에 머신 생성 크레딧이 남아 있지 않습니다. Cloud VM 크레딧을 추가하거나 플랜을 업그레이드하세요." + "value": "팀 요금제에 포함되어 있습니다." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "您的帐户已没有创建机器的额度。请添加 Cloud VM 额度或升级套餐。" + "value": "已包含在您团队的方案中。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "您的帳戶已沒有建立機器的點數。請新增 Cloud VM 點數或升級方案。" + "value": "已包含在您團隊的方案中。" } } } }, - "account.signIn.copyErrorDetails": { + "agentRestore.writerLock.changing": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy Error Details" + "value": "Codex was still updating its session locks, so cmux could not confirm this conversation is free. Retry 'cmux restore --surface'." } }, "ar": { "stringUnit": { "state": "translated", - "value": "نسخ تفاصيل الخطأ" + "value": "كان Codex لا يزال يحدّث أقفال الجلسات، لذلك لم يتمكن cmux من التأكد من أن هذه المحادثة غير مستخدمة. أعد تشغيل 'cmux restore --surface'." } }, "de": { "stringUnit": { "state": "translated", - "value": "Fehlerdetails kopieren" + "value": "Codex hat seine Sitzungssperren noch aktualisiert, daher konnte cmux nicht bestätigen, dass diese Unterhaltung frei ist. Führe 'cmux restore --surface' erneut aus." } }, "es": { "stringUnit": { "state": "translated", - "value": "Copiar detalles del error" + "value": "Codex todavía estaba actualizando sus bloqueos de sesión, así que cmux no pudo confirmar que esta conversación esté libre. Vuelve a ejecutar 'cmux restore --surface'." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Copier les détails de l’erreur" + "value": "Codex mettait encore à jour ses verrous de session, cmux n'a donc pas pu confirmer que cette conversation est libre. Relancez 'cmux restore --surface'." } }, "ja": { "stringUnit": { "state": "translated", - "value": "エラーの詳細をコピー" + "value": "Codex がまだセッションロックを更新していたため、cmux はこの会話が使用中でないことを確認できませんでした。'cmux restore --surface' を再実行してください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "오류 세부 정보 복사" + "value": "Codex가 아직 세션 잠금을 갱신하고 있어 cmux가 이 대화가 사용 중이 아닌지 확인할 수 없습니다. 'cmux restore --surface'를 다시 실행하세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "复制错误详细信息" + "value": "Codex 仍在更新其会话锁,因此 cmux 无法确认此对话未被占用。请重新运行 'cmux restore --surface'。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "複製錯誤詳細資訊" + "value": "Codex 仍在更新其工作階段鎖定,因此 cmux 無法確認此對話未被使用。請重新執行 'cmux restore --surface'。" } } } }, - "account.signIn.errorDetailsCopied": { + "agentRestore.writerLock.unreadable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Error Details Copied" + "value": "cmux could not read this conversation's Codex session lock, so it could not confirm the conversation is free. Retry 'cmux restore --surface'." } }, "ar": { "stringUnit": { "state": "translated", - "value": "تم نسخ تفاصيل الخطأ" + "value": "تعذّر على cmux قراءة قفل جلسة Codex لهذه المحادثة، لذلك لم يتمكن من التأكد من أنها غير مستخدمة. أعد تشغيل 'cmux restore --surface'." } }, "de": { "stringUnit": { "state": "translated", - "value": "Fehlerdetails kopiert" + "value": "cmux konnte die Codex-Sitzungssperre dieser Unterhaltung nicht lesen und daher nicht bestätigen, dass die Unterhaltung frei ist. Führe 'cmux restore --surface' erneut aus." } }, "es": { "stringUnit": { "state": "translated", - "value": "Detalles del error copiados" + "value": "cmux no pudo leer el bloqueo de sesión de Codex de esta conversación, así que no pudo confirmar que esté libre. Vuelve a ejecutar 'cmux restore --surface'." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Détails de l’erreur copiés" + "value": "cmux n'a pas pu lire le verrou de session Codex de cette conversation et n'a donc pas pu confirmer qu'elle est libre. Relancez 'cmux restore --surface'." } }, "ja": { "stringUnit": { "state": "translated", - "value": "エラーの詳細をコピーしました" + "value": "cmux はこの会話の Codex セッションロックを読み取れなかったため、会話が使用中でないことを確認できませんでした。'cmux restore --surface' を再実行してください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "오류 세부 정보 복사됨" + "value": "cmux가 이 대화의 Codex 세션 잠금을 읽을 수 없어 대화가 사용 중이 아닌지 확인할 수 없습니다. 'cmux restore --surface'를 다시 실행하세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已复制错误详细信息" + "value": "cmux 无法读取此对话的 Codex 会话锁,因此无法确认此对话未被占用。请重新运行 'cmux restore --surface'。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "已複製錯誤詳細資訊" + "value": "cmux 無法讀取此對話的 Codex 工作階段鎖定,因此無法確認此對話未被使用。請重新執行 'cmux restore --surface'。" } } } }, - "auth.error.rate_limited": { + "cloud.operation.failure.capacity": { "extractionState": "manual", "localizations": { - "en": { + "bs": { "stringUnit": { "state": "translated", - "value": "Too many sign-in requests from this network. cmux retries automatically." + "value": "Pokrenute mašine vašeg tima koriste sav Cloud kapacitet plana. Pauzirajte ili izbrišite pokrenutu mašinu, pa pokušajte ponovo." } }, - "ar": { + "da": { "stringUnit": { "state": "translated", - "value": "طلبات تسجيل دخول كثيرة جدًا من هذه الشبكة. يعيد cmux المحاولة تلقائيًا." + "value": "Dit teams kørende maskiner bruger hele planens Cloud-kapacitet. Sæt en kørende maskine på pause eller slet den, og prøv igen." } }, - "de": { + "it": { "stringUnit": { "state": "translated", - "value": "Zu viele Anmeldeanfragen aus diesem Netzwerk. cmux versucht es automatisch erneut." + "value": "Le macchine in esecuzione del tuo team usano tutta la capacità Cloud del piano. Metti in pausa o elimina una macchina in esecuzione, poi riprova." } }, - "es": { + "km": { "stringUnit": { "state": "translated", - "value": "Demasiadas solicitudes de inicio de sesión desde esta red. cmux lo reintenta automáticamente." + "value": "ម៉ាស៊ីនដែលកំពុងដំណើរការរបស់ក្រុមអ្នកកំពុងប្រើសមត្ថភាព Cloud ទាំងអស់របស់គម្រោង។ ផ្អាក ឬលុបម៉ាស៊ីនដែលកំពុងដំណើរការមួយ រួចព្យាយាមម្តងទៀត។" } }, - "fr": { + "nb": { "stringUnit": { "state": "translated", - "value": "Trop de demandes de connexion depuis ce réseau. cmux réessaie automatiquement." + "value": "Teamets kjørende maskiner bruker hele Cloud-kapasiteten i planen. Sett en kjørende maskin på pause eller slett den, og prøv igjen." } }, - "ja": { + "pl": { "stringUnit": { "state": "translated", - "value": "このネットワークからのサインイン要求が多すぎます。cmux が自動的に再試行します。" + "value": "Uruchomione maszyny Twojego zespołu wykorzystują całą pojemność Cloud w planie. Wstrzymaj lub usuń uruchomioną maszynę, a następnie spróbuj ponownie." } }, - "ko": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "이 네트워크에서 로그인 요청이 너무 많습니다. cmux가 자동으로 다시 시도합니다." + "value": "As máquinas em execução da sua equipe estão usando toda a capacidade de Cloud do plano. Pause ou exclua uma máquina em execução e tente novamente." } }, - "zh-Hans": { + "ru": { "stringUnit": { "state": "translated", - "value": "来自此网络的登录请求过多。cmux 会自动重试。" + "value": "Запущенные машины вашей команды используют всю мощность Cloud по тарифу. Приостановите или удалите запущенную машину и повторите попытку." } }, - "zh-Hant": { + "th": { "stringUnit": { "state": "translated", - "value": "來自此網路的登入要求過多。cmux 會自動重試。" + "value": "เครื่องที่กำลังทำงานของทีมคุณใช้ความจุ Cloud ของแผนจนหมดแล้ว หยุดเครื่องที่กำลังทำงานชั่วคราวหรือลบออก แล้วลองอีกครั้ง" } - } - } - }, - "account.signIn.error.rateLimited.recovery": { - "extractionState": "manual", - "localizations": { + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekibinizin çalışan makineleri planın tüm Cloud kapasitesini kullanıyor. Çalışan bir makineyi duraklatın veya silin, ardından tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запущені машини вашої команди використовують усю потужність Cloud за планом. Призупиніть або видаліть запущену машину й спробуйте ще раз." + } + }, "en": { "stringUnit": { "state": "translated", - "value": "cmux retried automatically. Wait a minute, then try again. Your account and existing cmux workspaces are unchanged." + "value": "Your team's running machines are using all of the plan's Cloud capacity. Pause or delete a running machine, then try again." } }, "ar": { "stringUnit": { "state": "translated", - "value": "أعاد cmux المحاولة تلقائيًا. انتظر دقيقة ثم حاول مرة أخرى. لم يتغير حسابك ولا مساحات عمل cmux الحالية." + "value": "تستخدم الأجهزة قيد التشغيل لدى فريقك كامل سعة Cloud في الخطة. أوقف جهازًا قيد التشغيل مؤقتًا أو احذفه، ثم حاول مرة أخرى." } }, "de": { "stringUnit": { "state": "translated", - "value": "cmux hat es automatisch erneut versucht. Warte eine Minute und versuche es dann erneut. Dein Konto und deine bestehenden cmux-Workspaces bleiben unverändert." + "value": "Die laufenden Maschinen deines Teams nutzen die gesamte Cloud-Kapazität des Plans. Pausiere oder lösche eine laufende Maschine und versuche es dann erneut." } }, "es": { "stringUnit": { "state": "translated", - "value": "cmux lo reintentó automáticamente. Espera un minuto y vuelve a intentarlo. Tu cuenta y tus espacios de trabajo de cmux existentes no han cambiado." + "value": "Las máquinas en ejecución de tu equipo están usando toda la capacidad de Cloud del plan. Pausa o elimina una máquina en ejecución y vuelve a intentarlo." } }, "fr": { "stringUnit": { "state": "translated", - "value": "cmux a réessayé automatiquement. Patientez une minute, puis réessayez. Votre compte et vos espaces de travail cmux existants sont inchangés." + "value": "Les machines en cours d'exécution de votre équipe utilisent toute la capacité Cloud du forfait. Mettez en pause ou supprimez une machine en cours d'exécution, puis réessayez." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux が自動的に再試行しました。1 分ほど待ってから、もう一度お試しください。アカウントと既存の cmux ワークスペースは変更されていません。" + "value": "チームの実行中のマシンがプランの Cloud 容量をすべて使用しています。実行中のマシンを一時停止または削除してから、もう一度お試しください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "cmux가 자동으로 다시 시도했습니다. 1분 정도 기다린 후 다시 시도하세요. 계정과 기존 cmux 워크스페이스는 변경되지 않았습니다." + "value": "팀의 실행 중인 머신이 플랜의 Cloud 용량을 모두 사용하고 있습니다. 실행 중인 머신을 일시 정지하거나 삭제한 후 다시 시도하세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "cmux 已自动重试。请等待一分钟后再试。你的账户和现有的 cmux 工作区均未更改。" + "value": "您团队中正在运行的机器已用尽套餐的全部 Cloud 容量。请暂停或删除一台正在运行的机器,然后重试。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "cmux 已自動重試。請稍候一分鐘後再試一次。你的帳號和現有的 cmux 工作區都沒有變更。" + "value": "您團隊中執行中的機器已用盡方案的全部 Cloud 容量。請暫停或刪除一台執行中的機器,然後再試一次。" } } } }, - "account.signIn.error.rateLimited.title": { + "cloud.operation.failure.activeLimit": { "extractionState": "manual", "localizations": { - "en": { + "bs": { "stringUnit": { "state": "translated", - "value": "Too many sign-in requests from this network" + "value": "Sve aktivne mašine koje vaš plan dozvoljava su u upotrebi. Pauzirajte ili izbrišite pokrenutu mašinu, pa pokušajte ponovo." } }, - "ar": { + "da": { "stringUnit": { "state": "translated", - "value": "طلبات تسجيل دخول كثيرة جدًا من هذه الشبكة" + "value": "Alle aktive maskiner, som din plan tillader, er i brug. Sæt en kørende maskine på pause eller slet den, og prøv igen." } }, - "de": { + "it": { "stringUnit": { "state": "translated", - "value": "Zu viele Anmeldeanfragen aus diesem Netzwerk" + "value": "Tutte le macchine attive consentite dal tuo piano sono in uso. Metti in pausa o elimina una macchina in esecuzione, poi riprova." } }, - "es": { + "km": { "stringUnit": { "state": "translated", - "value": "Demasiadas solicitudes de inicio de sesión desde esta red" + "value": "ម៉ាស៊ីនសកម្មទាំងអស់ដែលគម្រោងរបស់អ្នកអនុញ្ញាតកំពុងប្រើប្រាស់។ ផ្អាក ឬលុបម៉ាស៊ីនដែលកំពុងដំណើរការមួយ រួចព្យាយាមម្តងទៀត។" } }, - "fr": { + "nb": { "stringUnit": { "state": "translated", - "value": "Trop de demandes de connexion depuis ce réseau" + "value": "Alle aktive maskiner planen din tillater, er i bruk. Sett en kjørende maskin på pause eller slett den, og prøv igjen." } }, - "ja": { + "pl": { "stringUnit": { "state": "translated", - "value": "このネットワークからのサインイン要求が多すぎます" + "value": "Wszystkie aktywne maszyny dozwolone w Twoim planie są w użyciu. Wstrzymaj lub usuń uruchomioną maszynę, a następnie spróbuj ponownie." } }, - "ko": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "이 네트워크에서 로그인 요청이 너무 많습니다" + "value": "Todas as máquinas ativas que seu plano permite estão em uso. Pause ou exclua uma máquina em execução e tente novamente." } }, - "zh-Hans": { + "ru": { "stringUnit": { "state": "translated", - "value": "来自此网络的登录请求过多" + "value": "Все активные машины, разрешённые вашим тарифом, уже используются. Приостановите или удалите запущенную машину и повторите попытку." } }, - "zh-Hant": { + "th": { "stringUnit": { "state": "translated", - "value": "來自此網路的登入要求過多" + "value": "เครื่องที่ใช้งานอยู่ทั้งหมดที่แผนของคุณอนุญาตกำลังถูกใช้งาน หยุดเครื่องที่กำลังทำงานชั่วคราวหรือลบออก แล้วลองอีกครั้ง" } - } - } - }, - "account.signIn.loading.rateLimited": { - "extractionState": "manual", - "localizations": { + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Planınızın izin verdiği tüm etkin makineler kullanımda. Çalışan bir makineyi duraklatın veya silin, ardından tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Усі активні машини, дозволені вашим планом, уже використовуються. Призупиніть або видаліть запущену машину й спробуйте ще раз." + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Too many sign-in requests from this network" + "value": "Every active machine your plan allows is in use. Pause or delete a running machine, then try again." } }, "ar": { "stringUnit": { "state": "translated", - "value": "طلبات تسجيل دخول كثيرة جدًا من هذه الشبكة" + "value": "جميع الأجهزة النشطة التي تسمح بها خطتك قيد الاستخدام. أوقف جهازًا قيد التشغيل مؤقتًا أو احذفه، ثم حاول مرة أخرى." } }, "de": { "stringUnit": { "state": "translated", - "value": "Zu viele Anmeldeanfragen aus diesem Netzwerk" + "value": "Alle aktiven Maschinen, die dein Plan erlaubt, sind in Gebrauch. Pausiere oder lösche eine laufende Maschine und versuche es dann erneut." } }, "es": { "stringUnit": { "state": "translated", - "value": "Demasiadas solicitudes de inicio de sesión desde esta red" + "value": "Todas las máquinas activas que permite tu plan están en uso. Pausa o elimina una máquina en ejecución y vuelve a intentarlo." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Trop de demandes de connexion depuis ce réseau" + "value": "Toutes les machines actives autorisées par votre forfait sont utilisées. Mettez en pause ou supprimez une machine en cours d'exécution, puis réessayez." } }, "ja": { "stringUnit": { "state": "translated", - "value": "このネットワークからのサインイン要求が多すぎます" + "value": "プランで利用できるアクティブなマシンはすべて使用中です。実行中のマシンを一時停止または削除してから、もう一度お試しください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 네트워크에서 로그인 요청이 너무 많습니다" + "value": "플랜에서 허용하는 활성 머신을 모두 사용 중입니다. 실행 중인 머신을 일시 정지하거나 삭제한 후 다시 시도하세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "来自此网络的登录请求过多" + "value": "您的套餐允许的所有活跃机器都已在使用。请暂停或删除一台正在运行的机器,然后重试。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "來自此網路的登入要求過多" + "value": "您的方案允許的所有使用中機器都已在使用。請暫停或刪除一台執行中的機器,然後再試一次。" } } } }, - "account.signIn.loading.rateLimited.instructions": { + "cloud.operation.failure.createCredits": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vaš tim više nema kredita za kreiranje mašina. Zamolite administratora tima da doda kredite ili nadogradite plan." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dit team har ikke flere kreditter til at oprette maskiner. Bed en teamadministrator om at tilføje kreditter, eller opgrader planen." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il tuo team non ha più crediti per creare macchine. Chiedi a un amministratore del team di aggiungere crediti o passa a un piano superiore." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ក្រុមរបស់អ្នកលែងមានក្រេឌីតសម្រាប់បង្កើតម៉ាស៊ីនទៀតហើយ។ សូមស្នើអ្នកគ្រប់គ្រងក្រុមឱ្យបន្ថែមក្រេឌីត ឬដំឡើងគម្រោង។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Teamet ditt har ikke flere kreditter for å opprette maskiner. Be en teamadministrator om å legge til kreditter, eller oppgrader planen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Twój zespół nie ma już kredytów na tworzenie maszyn. Poproś administratora zespołu o dodanie kredytów lub zmień plan na wyższy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sua equipe não tem mais créditos para criar máquinas. Peça a um administrador da equipe para adicionar créditos ou faça upgrade do plano." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "У вашей команды закончились кредиты на создание машин. Попросите администратора команды добавить кредиты или повысьте тариф." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทีมของคุณไม่มีเครดิตสำหรับสร้างเครื่องเหลือแล้ว ขอให้ผู้ดูแลทีมเพิ่มเครดิต หรืออัปเกรดแผน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekibinizin makine oluşturma kredisi kalmadı. Bir ekip yöneticisinden kredi eklemesini isteyin veya planı yükseltin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У вашої команди закінчилися кредити на створення машин. Попросіть адміністратора команди додати кредити або перейдіть на вищий план." + } + }, "en": { "stringUnit": { "state": "translated", - "value": "cmux retries automatically. Keep this pane open." + "value": "Your team has no machine create credits left. Ask a team admin to add credits or upgrade the plan." } }, "ar": { "stringUnit": { "state": "translated", - "value": "يعيد cmux المحاولة تلقائيًا. أبقِ هذه اللوحة مفتوحة." + "value": "لم يعد لدى فريقك أي أرصدة لإنشاء الأجهزة. اطلب من مسؤول الفريق إضافة أرصدة أو قم بترقية الخطة." } }, "de": { "stringUnit": { "state": "translated", - "value": "cmux versucht es automatisch erneut. Lass diesen Bereich geöffnet." + "value": "Dein Team hat keine Credits zum Erstellen von Maschinen mehr. Bitte einen Team-Admin, Credits hinzuzufügen, oder führe ein Upgrade des Plans durch." } }, "es": { "stringUnit": { "state": "translated", - "value": "cmux lo reintenta automáticamente. Mantén este panel abierto." + "value": "Tu equipo no tiene créditos para crear máquinas. Pide a un administrador del equipo que añada créditos o mejora el plan." } }, "fr": { "stringUnit": { "state": "translated", - "value": "cmux réessaie automatiquement. Gardez ce volet ouvert." + "value": "Votre équipe n'a plus de crédits de création de machines. Demandez à un administrateur de l'équipe d'ajouter des crédits ou passez à un forfait supérieur." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux が自動的に再試行します。このペインを開いたままにしてください。" + "value": "チームにマシン作成クレジットが残っていません。チーム管理者にクレジットの追加を依頼するか、プランをアップグレードしてください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "cmux가 자동으로 다시 시도합니다. 이 패널을 열어 두세요." + "value": "팀에 남은 머신 생성 크레딧이 없습니다. 팀 관리자에게 크레딧 추가를 요청하거나 플랜을 업그레이드하세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "cmux 会自动重试。请保持此面板打开。" + "value": "您的团队已没有创建机器的额度。请让团队管理员添加额度,或升级套餐。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "cmux 會自動重試。請保持此面板開啟。" + "value": "您的團隊已沒有建立機器的點數。請團隊管理員加入點數,或升級方案。" } } } }, - "cloud.link.requestTimedOut": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "The Cloud machine did not answer the request before its deadline. The connection will be checked before retrying." } }, - "de": { "stringUnit": { "state": "translated", "value": "Die Cloud-Maschine hat vor Ablauf der Frist nicht geantwortet. Die Verbindung wird vor dem erneuten Versuch geprüft." } }, - "fr": { "stringUnit": { "state": "translated", "value": "La machine Cloud n’a pas répondu avant la date limite. La connexion sera vérifiée avant une nouvelle tentative." } }, - "ar": { "stringUnit": { "state": "translated", "value": "لم تستجب آلة Cloud قبل الموعد النهائي للطلب. سيتم التحقق من الاتصال قبل إعادة المحاولة." } }, - "es": { "stringUnit": { "state": "translated", "value": "La máquina Cloud no respondió antes del plazo. Se comprobará la conexión antes de volver a intentarlo." } }, - "zh-Hant": { "stringUnit": { "state": "translated", "value": "Cloud 機器未在期限前回應要求。重試前會檢查連線。" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "Cloud 机器未在截止时间前响应请求。重试前将检查连接。" } }, - "ko": { "stringUnit": { "state": "translated", "value": "Cloud 머신이 기한 내에 요청에 응답하지 않았습니다. 다시 시도하기 전에 연결을 확인합니다." } }, - "ja": { "stringUnit": { "state": "translated", "value": "Cloud マシンは期限内に応答しませんでした。再試行する前に接続を確認します。" } }, - "bs": { "stringUnit": { "state": "translated", "value": "Cloud mašina nije odgovorila prije isteka roka. Veza će biti provjerena prije ponovnog pokušaja." } }, - "da": { "stringUnit": { "state": "translated", "value": "Cloud-maskinen svarede ikke inden tidsfristen. Forbindelsen kontrolleres, før der prøves igen." } }, - "it": { "stringUnit": { "state": "translated", "value": "La macchina Cloud non ha risposto entro il termine. La connessione verrà controllata prima di riprovare." } }, - "km": { "stringUnit": { "state": "translated", "value": "ម៉ាស៊ីន Cloud មិនបានឆ្លើយតបមុនពេលកំណត់ទេ។ ការតភ្ជាប់នឹងត្រូវបានពិនិត្យមុនពេលព្យាយាមម្តងទៀត។" } }, - "nb": { "stringUnit": { "state": "translated", "value": "Cloud-maskinen svarte ikke innen tidsfristen. Tilkoblingen blir kontrollert før nytt forsøk." } }, - "pl": { "stringUnit": { "state": "translated", "value": "Maszyna Cloud nie odpowiedziała przed upływem limitu czasu. Połączenie zostanie sprawdzone przed ponowną próbą." } }, - "pt-BR": { "stringUnit": { "state": "translated", "value": "A máquina Cloud não respondeu antes do prazo. A conexão será verificada antes de tentar novamente." } }, - "ru": { "stringUnit": { "state": "translated", "value": "Облачная машина не ответила до истечения срока. Перед повторной попыткой будет проверено подключение." } }, - "th": { "stringUnit": { "state": "translated", "value": "เครื่อง Cloud ไม่ตอบสนองก่อนถึงกำหนด จะตรวจสอบการเชื่อมต่อก่อนลองอีกครั้ง" } }, - "tr": { "stringUnit": { "state": "translated", "value": "Cloud makinesi süre dolmadan isteğe yanıt vermedi. Yeniden denemeden önce bağlantı kontrol edilecek." } }, - "uk": { "stringUnit": { "state": "translated", "value": "Хмарна машина не відповіла до завершення терміну. Перед повторною спробою з’єднання буде перевірено." } } - } - }, - "cloud.welcome.note.teamAvailable": { + "cloud.operation.failure.memoryRequiresPlan": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ova veličina je veća od onoga što vaš plan uključuje. Odaberite manju veličinu ili nadogradite plan." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelse er større, end din plan omfatter. Vælg en mindre størrelse, eller opgrader planen." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa dimensione supera quanto incluso nel tuo piano. Scegli una dimensione più piccola o passa a un piano superiore." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំហំនេះធំជាងអ្វីដែលគម្រោងរបស់អ្នករួមបញ្ចូល។ ជ្រើសរើសទំហំតូចជាងនេះ ឬដំឡើងគម្រោង។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelsen er større enn det planen din inkluderer. Velg en mindre størrelse, eller oppgrader planen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ten rozmiar przekracza to, co obejmuje Twój plan. Wybierz mniejszy rozmiar lub zmień plan na wyższy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Este tamanho é maior do que seu plano inclui. Escolha um tamanho menor ou faça upgrade do plano." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Этот размер больше, чем включено в ваш тариф. Выберите размер поменьше или повысьте тариф." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดนี้ใหญ่กว่าที่แผนของคุณรวมไว้ เลือกขนาดที่เล็กกว่า หรืออัปเกรดแผน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu boyut planınızın kapsadığından daha büyük. Daha küçük bir boyut seçin veya planı yükseltin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Цей розмір більший, ніж включено у ваш план. Виберіть менший розмір або перейдіть на вищий план." + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Cloud can be enabled through your team; machine access follows the team’s plan." + "value": "This size is larger than your plan includes. Choose a smaller size, or upgrade the plan." } }, "ar": { "stringUnit": { "state": "translated", - "value": "يمكن تفعيل Cloud عبر فريقك؛ ويعتمد الوصول إلى الأجهزة على خطة الفريق." + "value": "هذا الحجم أكبر مما تتضمنه خطتك. اختر حجمًا أصغر، أو قم بترقية الخطة." } }, "de": { "stringUnit": { "state": "translated", - "value": "Cloud kann über Ihr Team aktiviert werden; der Maschinenzugriff richtet sich nach dem Teamplan." + "value": "Diese Größe übersteigt, was dein Plan enthält. Wähle eine kleinere Größe oder führe ein Upgrade des Plans durch." } }, "es": { "stringUnit": { "state": "translated", - "value": "Cloud se puede activar a través de tu equipo; el acceso a las máquinas depende del plan del equipo." + "value": "Este tamaño supera lo que incluye tu plan. Elige un tamaño más pequeño o mejora el plan." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Cloud peut être activé via votre équipe ; l’accès aux machines dépend du forfait de l’équipe." + "value": "Cette taille dépasse ce que comprend votre forfait. Choisissez une taille plus petite ou passez à un forfait supérieur." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Cloud はチームを通じて有効にできます。マシンへのアクセスはチームのプランに従います。" + "value": "このサイズはプランに含まれる範囲を超えています。小さいサイズを選ぶか、プランをアップグレードしてください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "팀을 통해 Cloud를 활성화할 수 있으며 머신 사용 권한은 팀 요금제에 따라 결정됩니다." + "value": "이 크기는 플랜에 포함된 범위보다 큽니다. 더 작은 크기를 선택하거나 플랜을 업그레이드하세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "可通过您的团队启用 Cloud;机器访问权限取决于团队方案。" + "value": "此规格超出您的套餐所含范围。请选择较小的规格,或升级套餐。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Cloud 可透過您的團隊啟用;機器存取權限取決於團隊方案。" + "value": "此大小超出您的方案所包含的範圍。請選擇較小的大小,或升級方案。" } } } }, - "cloud.welcome.note.teamIncluded": { + "cloud.operation.failure.createCredits.account": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Included through your team’s plan." + "value": "Your account has no machine create credits left. Add Cloud VM create credits or upgrade your plan." } }, "ar": { "stringUnit": { "state": "translated", - "value": "مضمّن في خطة فريقك." + "value": "لم يعد لدى حسابك أي أرصدة لإنشاء الأجهزة. أضف أرصدة Cloud VM أو قم بترقية خطتك." } }, "de": { "stringUnit": { "state": "translated", - "value": "Im Teamplan enthalten." + "value": "Dein Konto hat keine Credits zum Erstellen von Maschinen mehr. Füge Cloud-VM-Credits hinzu oder führe ein Upgrade deines Plans durch." } }, "es": { "stringUnit": { "state": "translated", - "value": "Incluido en el plan de tu equipo." + "value": "Tu cuenta no tiene créditos para crear máquinas. Añade créditos de Cloud VM o mejora tu plan." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Inclus dans le forfait de votre équipe." + "value": "Votre compte n'a plus de crédits de création de machines. Ajoutez des crédits de machines Cloud ou passez à un forfait supérieur." } }, "ja": { "stringUnit": { "state": "translated", - "value": "チームのプランに含まれています。" + "value": "アカウントにマシン作成クレジットが残っていません。Cloud VM クレジットを追加するか、プランをアップグレードしてください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "팀 요금제에 포함되어 있습니다." + "value": "계정에 머신 생성 크레딧이 남아 있지 않습니다. Cloud VM 크레딧을 추가하거나 플랜을 업그레이드하세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已包含在您团队的方案中。" + "value": "您的帐户已没有创建机器的额度。请添加 Cloud VM 额度或升级套餐。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "已包含在您團隊的方案中。" + "value": "您的帳戶已沒有建立機器的點數。請新增 Cloud VM 點數或升級方案。" } } } @@ -653173,6 +653232,6 @@ } } } -}, + }, "version": "1.0" } diff --git a/Sources/AgentRestoreEvidenceObservation.swift b/Sources/AgentRestoreEvidenceObservation.swift index a21a305ea641..1c556bda960a 100644 --- a/Sources/AgentRestoreEvidenceObservation.swift +++ b/Sources/AgentRestoreEvidenceObservation.swift @@ -9,8 +9,8 @@ struct AgentRestoreEvidenceObservation: Sendable { #else @Sendable #endif - nonisolated func wait(process: AgentPIDProcessIdentity?, paths: [String]) async { - await wait(processes: process.map { [$0] } ?? [], paths: paths) + nonisolated func wait(process: AgentPIDProcessIdentity?, paths: [String], timeout: TimeInterval = 8) async { + await wait(processes: process.map { [$0] } ?? [], paths: paths, timeout: timeout) } /// Waits for any supplied process generation or watched path to change. @@ -23,10 +23,13 @@ struct AgentRestoreEvidenceObservation: Sendable { #endif nonisolated func wait( processes: [AgentPIDProcessIdentity], - paths: [String] + paths: [String], + timeout: TimeInterval = 8 ) async { guard processes.allSatisfy({ AgentPIDProcessIdentity(pid: $0.pid) == $0 }) else { return } - let observation = AgentRestoreEvidenceSubscription(processes: processes, paths: paths) + let observation = AgentRestoreEvidenceSubscription( + processes: processes, paths: paths, deadline: .now() + timeout + ) defer { observation.cancel() } // Close the registration race without ever accepting a reused PID. guard processes.allSatisfy({ AgentPIDProcessIdentity(pid: $0.pid) == $0 }) else { return } diff --git a/Sources/TerminalController+AgentRestoreAdmission.swift b/Sources/TerminalController+AgentRestoreAdmission.swift index 24cee146d1db..6b35b0d191a4 100644 --- a/Sources/TerminalController+AgentRestoreAdmission.swift +++ b/Sources/TerminalController+AgentRestoreAdmission.swift @@ -170,10 +170,22 @@ extension TerminalController { let kind = RestorableAgentKind(rawValue: inputs.kind) let hookPath = kind?.hookStoreFileURL(homeDirectory: NSHomeDirectory()) let lockDirectory = writer.map { URL(fileURLWithPath: $0.lockPath).deletingLastPathComponent().path } - await AgentRestoreEvidenceObservation().wait( - process: liveOwner?.processIdentity, - paths: [hookPath?.deletingLastPathComponent().path, lockDirectory, writer?.lockPath].compactMap { $0 } - ) + let paths = [hookPath?.deletingLastPathComponent().path, lockDirectory, writer?.lockPath].compactMap { $0 } + guard writer?.state == .changing else { + await AgentRestoreEvidenceObservation().wait(process: liveOwner?.processIdentity, paths: paths) + break + } + // Codex releasing its coordination lock raises no file event. + // Re-probe on the settle bounds within this one request, so a + // relaunch burst resolves quickly without extra RPC rounds. + for delay in CodexWriterLockSettle.delays { + await AgentRestoreEvidenceObservation().wait( + process: liveOwner?.processIdentity, paths: paths, timeout: delay + ) + guard !Task.isCancelled, AgentRestoreCodexEvidence().inspect( + record: record, sessionID: inputs.sessionID, effectiveHome: inputs.codexHome + )?.state == .changing else { break } + } case .admitted, .targetChanged, .liveOwner, .concurrentLaunch: break } diff --git a/cmuxTests/CLICodexWriterLockRestoreTests.swift b/cmuxTests/CLICodexWriterLockRestoreTests.swift index 9323bd7c03d9..54f94f400cfb 100644 --- a/cmuxTests/CLICodexWriterLockRestoreTests.swift +++ b/cmuxTests/CLICodexWriterLockRestoreTests.swift @@ -115,6 +115,96 @@ struct CLICodexWriterLockRestoreTests { } } + /// A relaunch resumes dozens of Codex threads at once. Each briefly holds + /// the shared coordination lock, which must not fail an unowned restore. + @Test("Coordination-lock contention is retried, and persistent contention says so", arguments: [true, false]) + func coordinationContentionIsTransient(releases: Bool) throws { + let runner = CMUXCLIErrorOutputRegressionTests() + let cli = try runner.bundledCLIPath() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-coordination-\(UUID().uuidString)") + let home = root.appendingPathComponent(".codex") + let cwd = root.appendingPathComponent("project") + let sessions = home.appendingPathComponent("sessions") + let locks = home.appendingPathComponent("thread-writer-locks") + let marker = home.appendingPathComponent("started") + let executable = root.appendingPathComponent("codex") + let session = UUID().uuidString.lowercased() + let surface = UUID().uuidString + for directory in [cwd, sessions, locks] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + defer { try? FileManager.default.removeItem(at: root) } + let metadata: [String: Any] = ["type": "session_meta", "payload": [ + "id": session, "cwd": cwd.path, "source": "cli", "originator": "codex-tui" + ]] + var rollout = try JSONSerialization.data(withJSONObject: metadata) + rollout.append(10) + try rollout.write(to: sessions.appendingPathComponent("rollout-\(session).jsonl")) + try "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$CODEX_HOME/started\"\n" + .write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + let record: [String: Any] = [ + "kind": "codex", "mode": "resumeAgent", "checkpoint_id": session, + "source": "session-snapshot", "working_directory": cwd.path, + "launch_command": [ + "launcher": "codex", "executable_path": executable.path, + "arguments": [executable.path], "verification_home": root.path, + "working_directory": cwd.path + ] + ] + // Another Codex is starting its own thread: it holds coordination + // exclusively, and this conversation has no writer at all. + let coordination = open(locks.appendingPathComponent(".coordination.lock").path, + O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + try #require(coordination >= 0) + let pendingRelease = DispatchGroup() + // The descriptor must outlive a scheduled release, even after a failure. + defer { + _ = pendingRelease.wait(timeout: .now() + 5) + close(coordination) + } + try #require(flock(coordination, LOCK_EX | LOCK_NB) == 0) + let socket = "/tmp/cmux-coord-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder( + path: socket, + responses: [try response(["workspace_id": UUID().uuidString, "surface_id": surface, "restore_record": record])], + onRequest: { _ in + guard releases else { return } + // Release only after the CLI is past its socket round trip, so + // the final exec guard observes the contention first. + pendingRelease.enter() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.4) { + _ = flock(coordination, LOCK_UN) + pendingRelease.leave() + } + } + ) + defer { responder.stop() } + var environment = [ + "HOME": root.path, "CFFIXED_USER_HOME": root.path, "PATH": "/usr/bin:/bin", + "CMUX_SOCKET_PATH": socket, "CMUX_CLI_SENTRY_DISABLED": "1", "SHELL": "/bin/sh" + ] + for key in ["DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"] { + environment[key] = ProcessInfo.processInfo.environment[key] + } + let result = runner.runProcess( + executablePath: cli, arguments: ["restore", "--surface", surface, "codex", session], + environment: environment, + timeout: 30 + ) + try #require(!result.timedOut, Comment(rawValue: result.diagnostics)) + if releases { + try #require(result.status == 0, Comment(rawValue: result.diagnostics)) + let started = try String(contentsOf: marker, encoding: .utf8) + #expect(started.contains(session)) + } else { + #expect(result.status != 0, Comment(rawValue: result.diagnostics)) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + #expect(result.stderr.contains("still updating its session locks"), Comment(rawValue: result.diagnostics)) + #expect(!result.stderr.contains("could not verify"), Comment(rawValue: result.diagnostics)) + } + } + private func response(_ result: [String: Any]) throws -> String { String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) } diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 9566917ec85d..a1617472e72a 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -4836,6 +4836,7 @@ final class UnixSocketResponder { let path: String private let responses: [String] private let responseDelay: TimeInterval + private let onRequest: (@Sendable (String) -> Void)? private let queue = DispatchQueue(label: "com.cmux.tests.unix-socket-responder") private let lock = NSLock() private var stopped = false @@ -4846,7 +4847,12 @@ final class UnixSocketResponder { try self.init(path: path, responses: [response], responseDelay: responseDelay) } - init(path: String, responses: [String], responseDelay: TimeInterval = 0) throws { + init( + path: String, + responses: [String], + responseDelay: TimeInterval = 0, + onRequest: (@Sendable (String) -> Void)? = nil + ) throws { guard !responses.isEmpty else { throw NSError( domain: NSCocoaErrorDomain, @@ -4857,6 +4863,7 @@ final class UnixSocketResponder { self.path = path self.responses = responses self.responseDelay = responseDelay + self.onRequest = onRequest unlink(path) listenerFD = socket(AF_UNIX, SOCK_STREAM, 0) @@ -4984,6 +4991,7 @@ final class UnixSocketResponder { responseIndex = requests.count requests.append(line) lock.unlock() + onRequest?(line) } if responseDelay > 0 { Thread.sleep(forTimeInterval: responseDelay) diff --git a/cmuxTests/CodexWriterAdmissionRecoveryTests.swift b/cmuxTests/CodexWriterAdmissionRecoveryTests.swift index 428cb5645306..e803c7ec56f0 100644 --- a/cmuxTests/CodexWriterAdmissionRecoveryTests.swift +++ b/cmuxTests/CodexWriterAdmissionRecoveryTests.swift @@ -16,6 +16,77 @@ import Testing struct CodexWriterAdmissionRecoveryTests { @Test(arguments: [false, true]) func heldForeignWriterIsPresentedAndReleaseAdmitsTheSameConversation(launchLeasePending: Bool) async throws { + try await withCodexRestoreSurface { workspace, panel, root, session in + let fd = open(root.appendingPathComponent("thread-writer-locks/\(session).lock").path, + O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + try #require(fd >= 0) + defer { close(fd) } + try #require(flock(fd, LOCK_EX | LOCK_NB) == 0) + let admission = ControlRequest(id: .string("admit"), method: "agent.restore.admit", params: [ + "workspace_id": .string(workspace.id.uuidString), "surface_id": .string(panel.id.uuidString), + "kind": .string("codex"), "session_id": .string(session), "codex_home": .string(root.path), + "launch_lease_pending": .bool(launchLeasePending) + ]) + let held = try result(await TerminalController.shared.agentRestoreAdmissionResponse(admission)) + #expect(held["admitted"] as? Bool == false) + if launchLeasePending { + #expect(held["recovering"] as? Bool != true) + #expect(held["launch_pending"] as? Bool == true) + #expect(panel.restoreRecovery.state == nil, "A rejected lease contender must leave checking") + #expect(workspace.surfaceResumeBinding(panelId: panel.id)?.checkpointId == session) + return + } + #expect(held["recovering"] as? Bool == true) + guard case .writerLock? = panel.restoreRecovery.state else { + Issue.record("Held writer must have a visible recovery message") + return + } + #expect(workspace.surfaceResumeBinding(panelId: panel.id)?.checkpointId == session) + try #require(flock(fd, LOCK_UN) == 0) + let released = try result(await TerminalController.shared.agentRestoreAdmissionResponse(admission)) + #expect(released["admitted"] as? Bool == true) + #expect(panel.restoreRecovery.state == nil) + } + } + + /// Another Codex starting during a relaunch holds the coordination lock + /// briefly. Its release raises no file event, so a waiting request must + /// re-probe instead of spending the whole evidence bound. + @Test func coordinationContentionResolvesWithinOneWaitingRequest() async throws { + try await withCodexRestoreSurface { workspace, panel, root, session in + let coordination = open(root.appendingPathComponent("thread-writer-locks/.coordination.lock").path, + O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + try #require(coordination >= 0) + let pendingRelease = DispatchGroup() + defer { + _ = pendingRelease.wait(timeout: .now() + 5) + close(coordination) + } + try #require(flock(coordination, LOCK_EX | LOCK_NB) == 0) + let admission = ControlRequest(id: .string("admit"), method: "agent.restore.admit", params: [ + "workspace_id": .string(workspace.id.uuidString), "surface_id": .string(panel.id.uuidString), + "kind": .string("codex"), "session_id": .string(session), "codex_home": .string(root.path), + "wait_for_change": .bool(true) + ]) + pendingRelease.enter() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + _ = flock(coordination, LOCK_UN) + pendingRelease.leave() + } + let start = ContinuousClock.now + let changing = try result(await TerminalController.shared.agentRestoreAdmissionResponse(admission)) + #expect(changing["recovering"] as? Bool == true) + #expect(start.duration(to: .now) < .seconds(4), "The 8 second event bound must not pace a lock-namespace change") + #expect(workspace.surfaceResumeBinding(panelId: panel.id)?.checkpointId == session) + let admitted = try result(await TerminalController.shared.agentRestoreAdmissionResponse(admission)) + #expect(admitted["admitted"] as? Bool == true) + } + } + + /// Hosts a terminal whose Codex restore record targets a private Codex home. + private func withCodexRestoreSurface( + _ body: (Workspace, TerminalPanel, URL, String) async throws -> Void + ) async throws { try await AppContextSerialGate.withExclusiveAppContext { _ = NSApplication.shared let root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-admission-\(UUID().uuidString)") @@ -23,10 +94,6 @@ struct CodexWriterAdmissionRecoveryTests { try FileManager.default.createDirectory(at: locks, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let session = UUID().uuidString.lowercased() - let fd = open(locks.appendingPathComponent(session + ".lock").path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600) - try #require(fd >= 0) - defer { close(fd) } - try #require(flock(fd, LOCK_EX | LOCK_NB) == 0) let previousApp = AppDelegate.shared let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() let app = AppDelegate() @@ -61,30 +128,7 @@ struct CodexWriterAdmissionRecoveryTests { let requestLine = String(decoding: try JSONSerialization.data(withJSONObject: set), as: UTF8.self) _ = try result(TerminalController.shared.handleSocketLine(requestLine)) panel.restoreRecovery.state = .checking - let admission = ControlRequest(id: .string("admit"), method: "agent.restore.admit", params: [ - "workspace_id": .string(workspace.id.uuidString), "surface_id": .string(panel.id.uuidString), - "kind": .string("codex"), "session_id": .string(session), "codex_home": .string(root.path), - "launch_lease_pending": .bool(launchLeasePending) - ]) - let held = try result(await TerminalController.shared.agentRestoreAdmissionResponse(admission)) - #expect(held["admitted"] as? Bool == false) - if launchLeasePending { - #expect(held["recovering"] as? Bool != true) - #expect(held["launch_pending"] as? Bool == true) - #expect(panel.restoreRecovery.state == nil, "A rejected lease contender must leave checking") - #expect(workspace.surfaceResumeBinding(panelId: panel.id)?.checkpointId == session) - return - } - #expect(held["recovering"] as? Bool == true) - guard case .writerLock? = panel.restoreRecovery.state else { - Issue.record("Held writer must have a visible recovery message") - return - } - #expect(workspace.surfaceResumeBinding(panelId: panel.id)?.checkpointId == session) - try #require(flock(fd, LOCK_UN) == 0) - let released = try result(await TerminalController.shared.agentRestoreAdmissionResponse(admission)) - #expect(released["admitted"] as? Bool == true) - #expect(panel.restoreRecovery.state == nil) + try await body(workspace, panel, root, session) } } From 1f4a24018155b16d142f8c4cd467e09e1ff6e341 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Wed, 14 Oct 2026 01:24:38 -0700 Subject: [PATCH 024/183] Fix OpenCode lifecycle notifications (#20444) * test: cover OpenCode lifecycle notification regressions * fix: restore OpenCode lifecycle notifications Handle current OpenCode completion and failure events, coalesce duplicate idle callbacks, and preserve error state through the feed and TUI journal bridges. Co-authored-by: e-jung <e-jung@users.noreply.github.com> * fix: finish OpenCode notification repair Route lifecycle plugins through OpenCode's XDG config directory, preserve failed turns in Feed, and cover the current error event shape and ordering. Co-authored-by: e-jung <e-jung@users.noreply.github.com> * fix: share OpenCode failure detail extraction Use the same non-optional failure message in notification and Feed delivery, and let the coalesced idle fixture finish on a session-end marker. Co-authored-by: e-jung <e-jung@users.noreply.github.com> * test: wait for OpenCode failure hook * test: cover OpenCode server entry notifications Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: activate OpenCode server plugin entries Released OpenCode (1.x and the dev channel) loads cmux through each plugin's default server(ctx) entry inside the TUI process. Those entries returned {}, so no lifecycle event reached cmux. Subscribe when the TUI process server context is present, once per process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(opencode): publish one terminal stop per turn A turn now settles on its first terminal event and is reopened only by a turn identity the session has not seen (a new user message or prompt). Repeated message updates and busy status no longer clear the recorded stop, and a failure after a published idle stop is dropped instead of producing a second notification. Applies to the Feed bridge and the session plugin source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(opencode): redact credentials in upstream failure text OpenCode error text reaches notifications and Feed error fields. Redact credentials, bearer tokens, JWTs and emails first, keep paths and model names readable, and fall back to product copy when nothing safe remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(opencode): record OpenCode's own launch for resume On OpenCode 1.x the TUI process argv is [opencode, /$bunfs/root/src/index.js], and the sanitizer only dropped the V2 TUI worker entry, so restore replayed the Bun virtual path as a project directory. Drop any Bun virtual entry. Also stop reusing an inherited launch capture that describes another agent, which recorded e.g. a Claude command for an OpenCode session started from a Claude shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): keep shutdown-interrupted OpenCode turns running OpenCode retains an execution interrupted by shutdown and resumes it, so map that interruption to agent.state.changed instead of completing the turn. Read the structured reason before redaction replaces free-form strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: register the OpenCode server-entry harness as manual No workflow names tests/test_opencode_server_entry.py, so the registry guard rejected its linux-guard lane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: resubscribe OpenCode server entries after scope reinit The process-global guards blocked a reinitialized plugin scope from subscribing again. Key the owner on the plugin ctx, which every loader in one scope shares, and replace the previous feed when it changes. The regression harness now waits on a session.deleted sentinel instead of a fixed delay and keeps its socket in the temporary directory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(opencode): open turns on run signals, not message updates A queued prompt's admission and user message arrive while the previous turn is still running, so keying turns on first-seen message IDs dropped the stop for the queued turn. Open a turn only on a run signal (session creation, busy status, a promoted prompt or an execution start) in all three bridges; a repeated message update after a stop still cannot reopen the turn. The TUI bridge tracks turn starts before its ownership check so a turn that starts in a hidden tab keeps its stop. Also: - Do not publish a stop for an execution interrupted by shutdown, which OpenCode resumes, matching cmux-tui. - Redact credentials in the Feed plugin's own failure text, which reaches Feed without passing through the CLI. - Drop inherited subrouter and routed-account launch keys when recapturing OpenCode's own launch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(opencode): cover queued prompts and shutdown interruptions Drive a prompt that is admitted during a running turn and promoted after its stop, a repeated user-message update after a stop, a late failure, and a shutdown interruption through the session plugin and the Feed bridge. Wait for a session-end sentinel before counting so a late extra stop is caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bind OpenCode hooks to this process and plugin scope An OpenCode started from another OpenCode inherited the parent's opencode launch capture and recorded the parent's resume command. Always capture this process's launch; no cmux wrapper exports one. OpenCode never disposes a replaced plugin scope's hooks, so the session bridge now ignores events once a new scope owns the process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover raw OpenCode failure events in Feed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: share OpenCode failure classification with Feed A raw session.error, session.execution.failed or session.next.step.failed stop made the notification an error but reached Feed as a successful Stop. Both paths now use one predicate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover OpenCode interrupts and concurrent plugin scopes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep OpenCode interrupts and concurrent scopes out of failures OpenCode publishes session.error for a user interrupt (MessageAbortedError) and a recoverable context overflow (ContextOverflowError), then goes idle. These turned an Esc into a red error notification. The bridges now let the idle stop decide the outcome. OpenCode keeps one plugin scope per directory and disposes a scope's hooks on teardown, so dedupe only within one ctx instead of evicting the other live scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: e-jung <e-jung@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- CLI/AgentHookNotificationPolicy.swift | 24 ++ CLI/CMUXCLI+AgentHookCatalog.swift | 1 + CLI/cmux.swift | 251 ++++++++++++++++-- .../AgentLaunchSanitizer.swift | 4 +- .../OpenCodeBunWorkerSanitizerTests.swift | 2 + Resources/opencode-plugin.js | 137 +++++++++- Resources/opencode-tui-plugin.js | 123 +++++++-- Sources/AgentFeedSemanticInput.swift | 2 + .../crates/cmux-tui-core/src/agent_hooks.rs | 63 ++++- .../cmux-tui/assets/agent-hooks/opencode.js | 12 +- cmuxTests/OpenCodeHookRegressionTests.swift | 168 +++++++++++- tests/test-execution.toml | 5 + tests/test_opencode_plugin_install.py | 103 ++++++- tests/test_opencode_server_entry.py | 232 ++++++++++++++++ tests/test_opencode_v2_tui_bridge.py | 40 ++- 15 files changed, 1101 insertions(+), 66 deletions(-) create mode 100644 tests/test_opencode_server_entry.py diff --git a/CLI/AgentHookNotificationPolicy.swift b/CLI/AgentHookNotificationPolicy.swift index c228b2458442..23d73a92b01c 100644 --- a/CLI/AgentHookNotificationPolicy.swift +++ b/CLI/AgentHookNotificationPolicy.swift @@ -435,6 +435,30 @@ enum AgentHookNotificationPolicy { return patternIndex == patternScalars.count } + /// Redacts credentials in upstream error text before notifications or + /// Feed show it. Unlike commands, paths and long identifiers such as model + /// names stay readable because they are the useful part of the error. + static func redactSensitiveErrorDetail(_ value: String) -> String { + let boundedValue = value.utf8.count > 8_192 + ? String(decoding: value.utf8.prefix(8_191), as: UTF8.self) + "…" + : value + let patterns: [(pattern: String, replacement: String)] = [ + (#"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, "<email>"), + (#"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b"#, "<token>"), + (#"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{12,}"#, "Bearer <token>"), + (#"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"#, "<token>"), + (#"(?i)\b(?:x[-_])?(?:api[-_]?key|password|secret|token|authorization|cookie)\s*[:=]\s*(?:Bearer\s+)?(?:'[^']*'|\"[^\"]*\"|[^\s'\";&|,]+)"#, "<credential>:<token>"), + (#"\b(?:sk|rk|sess)-[A-Za-z0-9._:-]{8,}\b"#, "<token>"), + ] + return patterns.reduce(boundedValue) { partial, entry in + partial.replacingOccurrences( + of: entry.pattern, + with: entry.replacement, + options: [.regularExpression, .caseInsensitive] + ) + } + } + /// Redacts credentials before a command reaches durable hook state or UI. static func redactSensitiveCommand(_ value: String) -> String { let boundedValue = value.utf8.count > 8_192 diff --git a/CLI/CMUXCLI+AgentHookCatalog.swift b/CLI/CMUXCLI+AgentHookCatalog.swift index b187fd7d96a5..e6e5b85e9f27 100644 --- a/CLI/CMUXCLI+AgentHookCatalog.swift +++ b/CLI/CMUXCLI+AgentHookCatalog.swift @@ -48,6 +48,7 @@ extension CMUXCLI { AgentHookDef( name: "opencode", displayName: "OpenCode", statusKey: "opencode", configDir: ".config/opencode", configFile: "plugins/cmux-session.js", configDirEnvOverride: "OPENCODE_CONFIG_DIR", + configDirResolver: { OpenCodePaths(environment: ProcessInfo.processInfo.environment).configDirectory.path }, sessionStoreSuffix: "opencode", disableEnvVar: "CMUX_OPENCODE_HOOKS_DISABLED", hookMarker: "cmux hooks opencode", format: .flat, events: [] diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 186241a49249..cd081d96dfe5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -36461,6 +36461,7 @@ function sessionState(sessionId) { lastUserMessage: null, assistantPreamble: null, cwd: null, + lastStopKind: null, updatedAt: Date.now(), }); } @@ -36496,6 +36497,57 @@ function dropSession(sessionId) { } } +function failureMessage(event) { + const props = eventProperties(event); + const raw = props.error || props.failure || props.reason; + if (typeof raw === "string") return normalizeText(raw, 500); + if (raw && typeof raw === "object") { + const nestedData = raw.data && typeof raw.data === "object" ? raw.data : {}; + const nested = firstString( + raw.message, + raw.error, + raw.reason, + raw.description, + nestedData.message, + nestedData.error, + nestedData.reason, + nestedData.description, + raw.name + ); + if (nested) return normalizeText(nested, 500); + } + return normalizeText(firstString(props.message, props.description), 500); +} + +function sessionStatusType(status) { + if (typeof status === "string") return status.trim().toLowerCase(); + if (!status || typeof status !== "object") return null; + return firstString(status.type, status.status, status.state)?.toLowerCase() || null; +} + +// A turn publishes exactly one terminal stop. Only a run signal (session +// creation, busy status, a promoted prompt or an execution start) opens the +// next turn. Message updates and queued prompts do not: a queued prompt's +// message arrives while the previous turn is still running, and a repeated +// update after a stop must not reopen a finished turn. +function beginTurn(sessionId) { + sessionState(sessionId).lastStopKind = null; +} + +// OpenCode keeps an execution interrupted by shutdown and resumes it. +function isResumableInterruption(event) { + return event?.type === "session.execution.interrupted" && eventProperties(event).reason === "shutdown"; +} + +// The first terminal event settles the turn. A published stop cannot be +// reclassified, so a later failure or idle for the same turn is dropped. +function shouldSendStop(sessionId, kind) { + const state = sessionState(sessionId); + if (state.lastStopKind) return false; + state.lastStopKind = kind; + return true; +} + function contextForSession(sessionId) { const state = sessionState(sessionId); const context = {}; @@ -36548,10 +36600,12 @@ function looksLikeOpenCodeScript(value) { return lower.includes("opencode") || lower.includes("open-code"); } +// Compiled OpenCode reports Bun's virtual entry (src/index.js on 1.x, the TUI +// worker on V2) as an argument. It is never a user argument. function isOpenCodeInternalWorkerArg(value) { if (!value) return false; const normalized = String(value).replaceAll("\\", "/"); - return normalized.includes("/$bunfs/") && normalized.endsWith("/tui/worker.js"); + return normalized.includes("/$bunfs/") || normalized.includes("/~BUN/"); } function withoutOpenCodeInternalWorkerArgs(argv) { @@ -36590,13 +36644,16 @@ function base64NulSeparated(values) { function hookEnvironment(cwd) { const env = { ...process.env }; delete env.AMP_API_KEY; - if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) { - const argv = normalizedLaunchArgv(); - env.CMUX_AGENT_LAUNCH_KIND = "opencode"; - env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("opencode"); - env.CMUX_AGENT_LAUNCH_ARGV_B64 = base64NulSeparated(argv); - env.CMUX_AGENT_LAUNCH_CWD = cwd || process.cwd(); - } + // Any inherited capture describes a parent process: another agent, or an + // OpenCode that started this one. Always describe this process's launch. + delete env.CMUX_AGENT_LAUNCH_SUBROUTER_CLAUDE_RESUME_COMMAND; + delete env.CMUX_AGENT_LAUNCH_SUBROUTER_CODEX_RESUME_COMMAND; + delete env.CMUX_AGENT_LAUNCH_ROUTED_CLAUDE_ACCOUNT; + const argv = normalizedLaunchArgv(); + env.CMUX_AGENT_LAUNCH_KIND = "opencode"; + env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("opencode"); + env.CMUX_AGENT_LAUNCH_ARGV_B64 = base64NulSeparated(argv); + env.CMUX_AGENT_LAUNCH_CWD = cwd || process.cwd(); return env; } @@ -36667,29 +36724,66 @@ function trackMessage(event) { } } +// Busy status carries no turn identity; prompts and user messages own it. +function resetTurnFromEvent(event) { + const props = eventProperties(event); + const running = ["session.next.prompted", "session.execution.started"].includes(event?.type) + || (event?.type === "session.status" && sessionStatusType(props.status) === "busy"); + if (!running) return; + const sessionId = sessionIdFor(event); + if (sessionId) beginTurn(sessionId); +} + +function sendStop(ctx, event, failed = false) { + const sessionId = sessionIdFor(event); + if (!sessionId || !shouldSendStop(sessionId, failed ? "failure" : "success")) return; + const error = failed ? failureMessage(event) : null; + sendHook("stop", ctx, event, { + hook_event_name: failed ? "StopFailure" : "Stop", + ...(error ? { error } : {}), + }); +} + +// OpenCode publishes these through session.error without ending the turn: a +// user interrupt (Esc) then goes idle, and a context overflow compacts and +// continues. The idle stop that follows is the turn's outcome. +const NON_TERMINAL_SESSION_ERRORS = new Set(["MessageAbortedError", "ContextOverflowError"]); + const createCMUXSessionRestore = async (ctx) => { const handleEvent = async (event) => { trackMessage(event); + resetTurnFromEvent(event); const props = eventProperties(event); switch (event && event.type) { case "session.created": + beginTurn(sessionIdFor(event)); sendHook("session-start", ctx, event); break; case "session.updated": if (props.info && props.info.time && props.info.time.archived) { sendHook("session-end", ctx, event); dropSession(sessionIdFor(event)); - } else { - sendHook("session-start", ctx, event); } break; case "session.status": - if (props.status && props.status.type === "idle") { - sendHook("stop", ctx, event); + if (sessionStatusType(props.status) === "idle") { + sendStop(ctx, event); } break; case "session.idle": - sendHook("stop", ctx, event); + sendStop(ctx, event); + break; + case "session.execution.succeeded": + case "session.execution.interrupted": + if (!isResumableInterruption(event)) sendStop(ctx, event); + break; + case "session.error": + if (NON_TERMINAL_SESSION_ERRORS.has(props.error?.name)) break; + sendStop(ctx, event, true); + break; + case "session.execution.failed": + case "session.next.step.failed": + sendStop(ctx, event, true); break; case "session.deleted": sendHook("session-end", ctx, event); @@ -36712,11 +36806,37 @@ export const CMUXSessionRestore = async (ctx) => { return createCMUXSessionRestore(ctx); }; +// Released OpenCode calls `server(ctx)` inside each TUI process with the +// project server context. Old V2 snapshots called it from a shared service +// without that context, so only subscribe when it is present. +function isTUIProcessServerContext(ctx) { + return Boolean(ctx && typeof ctx === "object" && ctx.client && ctx.serverUrl && ctx.directory); +} + +const CMUX_SESSION_SERVER_SCOPES_KEY = Symbol.for("cmux.session.server.scopes"); + export default { id: "cmux.server", - // Keep the legacy server entry inert in V2. Session hooks run from the - // per-TUI ./tui entrypoint so a shared service never reuses one TUI's IDs. - server() { return {}; }, + async server(ctx) { + if (!isTUIProcessServerContext(ctx)) return {}; + // Every loader in one plugin scope receives the same ctx. OpenCode keeps + // one scope per directory and disposes a scope's hooks when it tears it + // down, after which this scope must not send hooks. + const scopes = globalThis[CMUX_SESSION_SERVER_SCOPES_KEY] ||= new WeakSet(); + if (scopes.has(ctx)) return {}; + scopes.add(ctx); + globalThis[CMUX_PLUGIN_INSTALLED_KEY] = true; + const hooks = await createCMUXSessionRestore(ctx); + let disposed = false; + return { + event: async (input) => { + if (!disposed) await hooks.event(input); + }, + dispose() { + disposed = true; + }, + }; + }, // Compatibility alias for older V2 snapshots that called setup(). setup() { return () => {}; }, }; @@ -40406,6 +40526,18 @@ export default { ) return summary.status == .error ? summary : nil }() + let openCodeFailure: AgentHookNotificationSummary? = { + guard def.name == "opencode" else { return nil } + let event = reportedHookEventName(from: input)?.lowercased() ?? "" + guard isOpenCodeFailureHookEvent(event) else { return nil } + let rawObject = input.rawObject ?? input.object ?? [:] + return classifyAgentHookNotification( + def: def, + signal: event, + message: openCodeFailureMessage(from: rawObject) ?? "", + isFallback: false + ) + }() let rawCwd = hookCwd ?? mapped?.cwd let launchCommand = agentLaunchCommandFromEnvironment(env, fallbackPID: pid, fallbackKind: def.name, cwd: rawCwd) let cwd = preferredAgentHookResumeWorkingDirectory(kind: def.name, current: launchCommand, currentCwd: hookCwd, mapped: mapped) @@ -40432,8 +40564,10 @@ export default { ) if let antigravityFailure { subtitle = antigravityFailure.subtitle + } else if let openCodeFailure { + subtitle = openCodeFailure.subtitle } - if codexFailure == nil, antigravityFailure == nil, let projectName, !projectName.isEmpty { + if codexFailure == nil, antigravityFailure == nil, openCodeFailure == nil, let projectName, !projectName.isEmpty { subtitle = String.localizedStringWithFormat( String( localized: "agent.codex.completion.subtitle.completedInProject", @@ -40444,6 +40578,7 @@ export default { } let body = codexFailure?.body ?? antigravityFailure?.body + ?? openCodeFailure?.body ?? lastMsg.map { truncate(normalizedSingleLine($0), maxLength: 200) } ?? grokAssistantMessage.map { truncate(normalizedSingleLine($0), maxLength: 200) } ?? String( @@ -40460,7 +40595,7 @@ export default { inputTurnID: input.turnId, terminationReason: stopTerminationReason ) - let stopNotificationStatus: AgentHookNotificationStatus = codexFailure != nil || antigravityFailure != nil + let stopNotificationStatus: AgentHookNotificationStatus = codexFailure != nil || antigravityFailure != nil || openCodeFailure != nil ? .error : (sameTurnNeedsInput ? .needsInput : .idle) var lifecycleAfterStop: AgentHibernationLifecycleState = { @@ -40651,7 +40786,7 @@ export default { // reducer's per-session fold handles stale sessions (a newer // running session outranks this one) and subagent tagging keeps // nested sessions off the pane badge — no emit-side guessing. - let stopHadFailure = codexFailure != nil || antigravityFailure != nil + let stopHadFailure = codexFailure != nil || antigravityFailure != nil || openCodeFailure != nil emitJournal( stopHadFailure ? .errorReported : (sameTurnNeedsInput ? .questionRequested : .turnCompleted), workspaceId: workspaceId, @@ -40826,6 +40961,18 @@ export default { client: client ) } + } else if openCodeFailure != nil { + let statusValue = agentErrorStatusValue(for: def) + if def.name == "cursor" { + sendCursorCriticalCommand( + "set_status \(def.statusKey) \(statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))" + ) + } else { + _ = try? sendV1Command( + "set_status \(def.statusKey) \(statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } } else if hasActiveBackgroundWork { let runningStatus = String(localized: "agent.generic.status.running", defaultValue: "Running") if def.name == "cursor" { @@ -41677,9 +41824,17 @@ export default { ) { let fallbackHookEventName = Self.feedEventName(forClaudeSubcommand: subcommand) let reportedEvent = reportedHookEventName(from: parsedInput) + let isOpenCodeStopFailure = source == "opencode" + && reportedEvent.map(isOpenCodeFailureHookEvent) == true let hookEventName: String - if let reportedEvent, - (source == "claude" || source == "codex") { + if isOpenCodeStopFailure { + // OpenCode uses the Claude-compatible StopFailure discriminator in + // its lifecycle bridge, but Feed's wire schema represents a + // failed turn as Stop + is_error. Preserve the failure bit while + // avoiding a successful completion event in the app reducer. + hookEventName = "Stop" + } else if let reportedEvent, + (source == "claude" || source == "codex") { // Preserve an explicit lifecycle discriminator when a command // entrypoint was selected by stale/duplicated settings. The // classifier keeps approval semantics (including Codex's native @@ -41765,6 +41920,15 @@ export default { failureDetailsForFeed = failureDetails } } + if isOpenCodeStopFailure { + event["is_error"] = true + if let message = openCodeFailureMessage(from: fallbackObject) { + event["error"] = message + var failureDetails = event["failure"] as? [String: Any] ?? [:] + failureDetails["message"] = message + event["failure"] = failureDetails + } + } if let toolInput = parsedInput.object?["tool_input"] { event["tool_input"] = source == "cursor" ? sanitizedCursorFeedToolInput(toolInput) @@ -42016,6 +42180,51 @@ export default { return "fallback-\(String(digest.prefix(16)))" } + /// The notification and Feed paths share this classification, so a raw + /// OpenCode failure event is an error in both. + private func isOpenCodeFailureHookEvent(_ value: String) -> Bool { + let event = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalizedOpenCodeHookEventName(event) == "stopfailure" + || event.contains("execution.failed") + || event.contains("next.step.failed") + || event == "session.error" + || event == "error" + } + + private func normalizedOpenCodeHookEventName(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "-", with: "") + .lowercased() + } + + /// Upstream error text reaches notifications and Feed, so credentials are + /// redacted first. Nil when no safe detail remains; callers then + /// use their product-level failure copy. + private func openCodeFailureMessage(from object: [String: Any]) -> String? { + let raw: String? + if let message = firstString(in: object, keys: ["error", "message", "reason", "description"]) { + raw = message + } else if let error = object["error"] as? [String: Any] { + let nestedData = error["data"] as? [String: Any] + raw = firstString(in: error, keys: ["message", "reason", "description"]) + ?? nestedData.flatMap { + firstString(in: $0, keys: ["message", "error", "reason", "description"]) + } + ?? firstString(in: error, keys: ["name"]) + } else { + raw = nil + } + guard let raw else { return nil } + let redacted = normalizedSingleLine(AgentHookNotificationPolicy.redactSensitiveErrorDetail(normalizedSingleLine(raw))) + let remaining = redacted.replacingOccurrences( + of: #"<[a-z]+>|[\s\p{P}]"#, with: "", options: .regularExpression + ) + guard !remaining.isEmpty else { return nil } + return truncate(redacted, maxLength: 500) + } + /// Feed reading must retain the completion, not the notification preview. /// Both native Feed hooks and wrapper hooks call this with the raw payload. private func enrichStopFeedEvent( diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 861e653bcfb5..471d8ea06194 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -165,8 +165,10 @@ public enum AgentLaunchSanitizer { var tail = args while let first = tail.first { let normalized = first.replacingOccurrences(of: "\\", with: "/") + // Compiled OpenCode reports Bun's virtual entry (src/index.js on + // 1.x, the TUI worker on V2); it is never a user argument. let isInternalArgument = first == "tui-settings" || - (normalized.contains("/$bunfs/") && normalized.hasSuffix("/tui/worker.js")) + normalized.contains("/$bunfs/") || normalized.contains("/~BUN/") guard isInternalArgument else { break } tail.removeFirst() } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/OpenCodeBunWorkerSanitizerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/OpenCodeBunWorkerSanitizerTests.swift index 68c5c2dc4597..f958fa72cca2 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/OpenCodeBunWorkerSanitizerTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/OpenCodeBunWorkerSanitizerTests.swift @@ -6,6 +6,8 @@ struct OpenCodeBunWorkerSanitizerTests { @Test("Drops internal TUI worker paths", arguments: [ "/$bunfs/root/src/cli/cmd/tui/worker.js", "/$bunfs/root/src/cli/tui/worker.js", + "/$bunfs/root/src/index.js", + "B:/~BUN/root/src/index.js", ]) func dropsInternalTUIWorkerPaths(workerPath: String) { #expect( diff --git a/Resources/opencode-plugin.js b/Resources/opencode-plugin.js index 2d00ad80cd31..a476d2a30ae0 100644 --- a/Resources/opencode-plugin.js +++ b/Resources/opencode-plugin.js @@ -12,6 +12,17 @@ const DEFAULT_SOCKET = `${os.homedir()}/.config/cmux/cmux.sock`; const REPLY_TIMEOUT_MS = 120_000; const MAX_PLAN_BYTES = 128 * 1024; +// OpenCode publishes these through session.error without ending the turn: a +// user interrupt (Esc) then goes idle, and a context overflow compacts and +// continues. The idle stop that follows is the turn's outcome. +const NON_TERMINAL_SESSION_ERRORS = new Set(["MessageAbortedError", "ContextOverflowError"]); + +const isNonTerminalSessionError = (event) => { + if (event?.type !== "session.error") return false; + const props = event.properties || event.data || {}; + return NON_TERMINAL_SESSION_ERRORS.has(props.error?.name); +}; + const createCMUXFeed = async (ctx, options = {}) => { const environment = { ...process.env, ...options.environment }; const socketPath = environment.CMUX_SOCKET_PATH || DEFAULT_SOCKET; @@ -84,6 +95,52 @@ const createCMUXFeed = async (ctx, options = {}) => { return firstString(status.type, status.status, status.state)?.toLowerCase() === "idle"; }; + const sessionStatusType = (status) => { + if (typeof status === "string") return status.trim().toLowerCase(); + if (!isObject(status)) return null; + return firstString(status.type, status.status, status.state)?.toLowerCase() || null; + }; + + // Mirrors AgentHookNotificationPolicy.redactSensitiveErrorDetail: Feed + // receives this text directly, so credentials are redacted here. Paths and + // model names stay readable because they are the useful part of the error. + const redactErrorDetail = (value) => { + if (!value) return value; + const redacted = [ + [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "<email>"], + [/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b/gi, "<token>"], + [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer <token>"], + [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gi, "<token>"], + [/\b(?:x[-_])?(?:api[-_]?key|password|secret|token|authorization|cookie)\s*[:=]\s*(?:Bearer\s+)?(?:'[^']*'|"[^"]*"|[^\s'";&|,]+)/gi, "<credential>:<token>"], + [/\b(?:sk|rk|sess)-[A-Za-z0-9._:-]{8,}\b/gi, "<token>"], + ].reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), value); + return redacted.replace(/<[a-z]+>|[\s\p{P}]/gu, "") ? redacted : null; + }; + + const failureMessage = (event) => redactErrorDetail(rawFailureMessage(event)); + + const rawFailureMessage = (event) => { + const props = eventProperties(event); + const raw = props.error || props.failure || props.reason; + if (typeof raw === "string") return normalizeText(raw, 500); + if (isObject(raw)) { + const nestedData = isObject(raw.data) ? raw.data : {}; + const nested = firstString( + raw.message, + raw.error, + raw.reason, + raw.description, + nestedData.message, + nestedData.error, + nestedData.reason, + nestedData.description, + raw.name + ); + if (nested) return normalizeText(nested, 500); + } + return normalizeText(firstString(props.message, props.description), 500); + }; + const normalizeText = (value, max = 1000) => { if (typeof value !== "string") return null; const normalized = value.replace(/\s+/g, " ").trim(); @@ -98,11 +155,25 @@ const createCMUXFeed = async (ctx, options = {}) => { lastUserMessage: null, assistantPreamble: null, cwd: null, + lastStopKind: null, }); } return sessions.get(key); }; + // A turn publishes exactly one terminal stop. Only a run signal (session + // creation, busy status, a promoted prompt or an execution start) opens the + // next turn. Message updates and queued prompts do not: a queued prompt's + // message arrives while the previous turn is still running, and a repeated + // update after a stop must not reopen a finished turn. + const beginTurn = (sessionId) => { + sessionState(sessionId).lastStopKind = null; + }; + + // OpenCode keeps an execution interrupted by shutdown and resumes it. + const isResumableInterruption = (event) => + event?.type === "session.execution.interrupted" && eventProperties(event).reason === "shutdown"; + const contextForSession = (sessionId) => { const state = sessionState(sessionId); const context = {}; @@ -711,6 +782,15 @@ const createCMUXFeed = async (ctx, options = {}) => { return null; }; + const shouldEmitStop = (sessionId, kind) => { + // The first terminal event settles the turn. A published stop cannot be + // reclassified, so a later failure or idle for the same turn is dropped. + const state = sessionState(sessionId); + if (state.lastStopKind) return false; + state.lastStopKind = kind; + return true; + }; + const pushBlocking = (event, requestId) => { const reply = new Promise((resolve) => { let timeout; @@ -767,6 +847,7 @@ const createCMUXFeed = async (ctx, options = {}) => { const props = eventProperties(event); const info = props.info || {}; const sid = sessionIdFromProperties(props) || "unknown"; + beginTurn(sid); const state = sessionState(sid); state.cwd = info.directory || props.location?.directory || event.location?.directory || ctx?.location?.directory || ctx?.directory || state.cwd; pushTelemetry(base(sid, { @@ -777,19 +858,38 @@ const createCMUXFeed = async (ctx, options = {}) => { } case "session.status": { const props = eventProperties(event); - if (!sessionStatusIsIdle(props.status)) break; const sid = sessionIdFromProperties(props); - if (!sid) break; + if (sid && sessionStatusType(props.status) === "busy") { + beginTurn(sid); + break; + } + if (!sessionStatusIsIdle(props.status)) break; + if (!sid || !shouldEmitStop(sid, "success")) break; pushTelemetry(base(sid, { hook_event_name: "Stop", })); break; } case "session.execution.succeeded": - case "session.execution.failed": case "session.execution.interrupted": { const sid = sessionIdFromEvent(event); - if (sid) pushTelemetry(base(sid, { hook_event_name: "Stop" })); + if (isResumableInterruption(event)) break; + if (sid && shouldEmitStop(sid, "success")) pushTelemetry(base(sid, { hook_event_name: "Stop" })); + break; + } + case "session.execution.failed": + case "session.next.step.failed": + case "session.error": { + if (isNonTerminalSessionError(event)) break; + const sid = sessionIdFromEvent(event); + if (sid && shouldEmitStop(sid, "failure")) { + const error = failureMessage(event); + pushTelemetry(base(sid, { + hook_event_name: "Stop", + is_error: true, + ...(error ? { error } : {}), + })); + } break; } case "session.updated": { @@ -798,19 +898,25 @@ const createCMUXFeed = async (ctx, options = {}) => { if (eventProperties(event).info?.time?.archived) { pushTelemetry(base(sid, { hook_event_name: "SessionEnd" })); sessions.delete(sid); - } else { - pushTelemetry(base(sid, { hook_event_name: "SessionStart" })); } break; } case "session.idle": { const sid = sessionIdFromProperties(eventProperties(event)); - if (!sid) break; + if (!sid || !shouldEmitStop(sid, "success")) break; pushTelemetry(base(sid, { hook_event_name: "Stop", })); break; } + // Admission queues a prompt behind the running turn; promotion and + // execution start are when it actually runs. + case "session.next.prompted": + case "session.execution.started": { + const sid = sessionIdFromEvent(event); + if (sid) beginTurn(sid); + break; + } case "session.deleted": { const sid = sessionIdFromProperties(eventProperties(event)); if (!sid) break; @@ -999,10 +1105,23 @@ const createCMUXFeed = async (ctx, options = {}) => { export const CMUXFeed = createCMUXFeed; +const CMUX_FEED_SERVER_SCOPES_KEY = Symbol.for("cmux.feed.server.scopes"); + +const isTUIProcessServerContext = (ctx) => + Boolean(ctx && typeof ctx === "object" && ctx.client && ctx.serverUrl && ctx.directory); + export default { id: "cmux.server", - // V2 uses the package's ./tui export; never subscribe in the shared service. - server() { return {}; }, + async server(ctx) { + if (!isTUIProcessServerContext(ctx)) return {}; + // cmux-feed.js and plugins/cmux/index.js both load this source, and every + // loader in one plugin scope receives the same ctx. OpenCode keeps one + // scope per directory, and disposes a scope's hooks when it tears it down. + const scopes = globalThis[CMUX_FEED_SERVER_SCOPES_KEY] ||= new WeakSet(); + if (scopes.has(ctx)) return {}; + scopes.add(ctx); + return createCMUXFeed(ctx); + }, // Compatibility alias for older V2 snapshots that called setup(). setup() { return () => {}; }, }; diff --git a/Resources/opencode-tui-plugin.js b/Resources/opencode-tui-plugin.js index 45b0bbca8567..63f1bea27898 100644 --- a/Resources/opencode-tui-plugin.js +++ b/Resources/opencode-tui-plugin.js @@ -10,6 +10,11 @@ import { CMUXFeed } from "./index.js"; const firstString = (...values) => values.find((value) => typeof value === "string" && value.trim())?.trim() || null; const properties = (event) => event?.data || event?.properties || {}; +const statusType = (status) => { + if (typeof status === "string") return status.trim().toLowerCase(); + if (!status || typeof status !== "object") return null; + return firstString(status.type, status.status, status.state)?.toLowerCase() || null; +}; function sessionID(event) { const data = properties(event); @@ -51,6 +56,28 @@ function cwd(ctx, event) { ); } +function failureMessage(event) { + const data = properties(event); + const raw = data.error || data.failure || data.reason; + if (typeof raw === "string") return raw.slice(0, 500); + if (raw && typeof raw === "object") { + const nestedData = raw.data && typeof raw.data === "object" ? raw.data : {}; + const nested = firstString( + raw.message, + raw.error, + raw.reason, + raw.description, + nestedData.message, + nestedData.error, + nestedData.reason, + nestedData.description, + raw.name + ); + if (nested) return nested.slice(0, 500); + } + return firstString(data.message, data.description)?.slice(0, 500) || null; +} + function rootFor(ctx, id) { try { return ctx?.data?.session?.root?.(id) || id; } catch (_) { return id; } } @@ -130,8 +157,13 @@ function resolveExecutable(name) { function launchArgv() { const raw = Array.isArray(process.argv) ? process.argv.map(String) : []; if (raw.length === 0) return [resolveExecutable("opencode")]; - const worker = (value) => String(value).replaceAll("\\", "/").includes("/$bunfs/") && String(value).endsWith("/tui/worker.js"); - const filtered = raw.filter((value, index) => index === 0 || !worker(value)); + // Compiled OpenCode reports Bun's virtual entry (src/index.js on 1.x, the + // TUI worker on V2) as an argument. It is never a user argument. + const bunVirtual = (value) => { + const normalized = String(value).replaceAll("\\", "/"); + return normalized.includes("/$bunfs/") || normalized.includes("/~BUN/"); + }; + const filtered = raw.filter((value, index) => index === 0 || !bunVirtual(value)); const first = path.basename(filtered[0] || "").toLowerCase(); if (first.includes("opencode") || first.includes("open-code")) return filtered; const tail = filtered.slice(1); @@ -142,13 +174,16 @@ function launchArgv() { function launchEnvironment(cwdValue, baseEnvironment = process.env) { const env = { ...baseEnvironment, CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC: "1" }; delete env.AMP_API_KEY; - if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) { - const argv = launchArgv(); - env.CMUX_AGENT_LAUNCH_KIND = "opencode"; - env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("opencode"); - env.CMUX_AGENT_LAUNCH_ARGV_B64 = Buffer.from(`${argv.join("\0")}\0`, "utf8").toString("base64"); - env.CMUX_AGENT_LAUNCH_CWD = cwdValue || process.cwd(); - } + // Any inherited capture describes a parent process: another agent, or an + // OpenCode that started this one. Always describe this process's launch. + delete env.CMUX_AGENT_LAUNCH_SUBROUTER_CLAUDE_RESUME_COMMAND; + delete env.CMUX_AGENT_LAUNCH_SUBROUTER_CODEX_RESUME_COMMAND; + delete env.CMUX_AGENT_LAUNCH_ROUTED_CLAUDE_ACCOUNT; + const argv = launchArgv(); + env.CMUX_AGENT_LAUNCH_KIND = "opencode"; + env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("opencode"); + env.CMUX_AGENT_LAUNCH_ARGV_B64 = Buffer.from(`${argv.join("\0")}\0`, "utf8").toString("base64"); + env.CMUX_AGENT_LAUNCH_CWD = cwdValue || process.cwd(); return env; } @@ -172,17 +207,51 @@ export function dispatchSessionHook(eventName, payload, spawnImpl = spawn, baseE } } +// OpenCode publishes these through session.error without ending the turn: a +// user interrupt (Esc) then goes idle, and a context overflow compacts and +// continues. The idle stop that follows is the turn's outcome. +const NON_TERMINAL_SESSION_ERRORS = new Set(["MessageAbortedError", "ContextOverflowError"]); + function sessionEventName(event) { const data = properties(event); if (event?.type === "session.created") return "session-start"; if (event?.type === "session.deleted") return "session-end"; - if (event?.type === "session.updated") return data.info?.time?.archived ? "session-end" : "session-start"; - if (["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"].includes(event?.type)) return "stop"; + if (event?.type === "session.updated") return data.info?.time?.archived ? "session-end" : null; + // OpenCode keeps an execution interrupted by shutdown and resumes it. + if (event?.type === "session.execution.interrupted" && data.reason === "shutdown") return null; + if (["session.execution.succeeded", "session.execution.interrupted"].includes(event?.type)) return "stop"; + if (event?.type === "session.error" && NON_TERMINAL_SESSION_ERRORS.has(data.error?.name)) return null; + if (["session.execution.failed", "session.next.step.failed", "session.error"].includes(event?.type)) return "stop-failure"; if (event?.type === "session.idle") return "stop"; - if (event?.type === "session.status" && (data.status?.type || data.status?.status || data.status) === "idle") return "stop"; + if (event?.type === "session.status" && statusType(data.status) === "idle") return "stop"; return null; } +// True when the event shows the session running a turn. Message updates and +// queued prompts are not run signals: a queued prompt's message arrives while +// the previous turn is still running, and a repeated update after a stop must +// not reopen a finished turn. +function isTurnStart(event) { + if (["session.created", "session.next.prompted", "session.execution.started"].includes(event?.type)) return true; + return event?.type === "session.status" && statusType(properties(event).status) === "busy"; +} + +// A turn publishes exactly one terminal stop, and only a run signal opens the +// next one. The first terminal event settles the turn: a published stop cannot +// be reclassified, so a later idle or failure for the same turn is dropped. +function createTurnLedger() { + const stopped = new Set(); + return { + begin(id) { stopped.delete(id); }, + settle(id) { + if (stopped.has(id)) return false; + stopped.add(id); + return true; + }, + end(id) { stopped.delete(id); }, + }; +} + function reportError(ctx, _error) { try { const toast = ctx?.ui?.toast?.show; @@ -195,33 +264,44 @@ function reportError(ctx, _error) { } catch (_) {} } -async function deliverEvent(ctx, feed, event, id, environment) { +async function deliverEvent(ctx, feed, event, id, environment, turns) { const hook = sessionEventName(event); - if (hook) { - dispatchSessionHook(hook, { + let admittedHook = hook; + if (hook === "session-end") turns.end(id); + if ((hook === "stop" || hook === "stop-failure") && !turns.settle(id)) admittedHook = null; + if (admittedHook) { + const payload = { session_id: id, cwd: cwd(ctx, event), event: event.type, - hook_event_name: hook === "stop" ? "Stop" : event.type, - }, spawn, environment); + hook_event_name: admittedHook === "stop-failure" ? "StopFailure" : admittedHook === "stop" ? "Stop" : event.type, + }; + if (admittedHook === "stop-failure") { + const error = failureMessage(event); + if (error) payload.error = error; + } + dispatchSessionHook(admittedHook === "stop-failure" ? "stop" : admittedHook, payload, spawn, environment); } // The ownership check above is also the admission decision for the Feed // bridge. Avoid scanning the visible tab set a second time for this event. await feed.event({ event, ownedSessionId: id }); } -async function handleEvent(ctx, ownership, feed, details, environment, retrySessionStart) { +async function handleEvent(ctx, ownership, feed, details, environment, retrySessionStart, turns) { const event = details?.event || details; const id = sessionID(event); const hook = sessionEventName(event); if (!id) return; + // Track turn boundaries even while the session is hidden, so a turn that + // starts in a background tab still publishes its stop once visible again. + if (isTurnStart(event)) turns?.begin(id); if (hook === "session-end") retrySessionStart?.cancel(id); if (!ownership.belongs(id)) { if (hook === "session-start") retrySessionStart?.enqueue(event, id); return; } if (hook === "session-start") retrySessionStart?.cancel(id); - await deliverEvent(ctx, feed, event, id, environment); + await deliverEvent(ctx, feed, event, id, environment, turns); } // The expiry bounds memory for sessions that never become visible. It only @@ -231,6 +311,7 @@ const SESSION_START_QUEUE_TTL_MS = 30_000; export async function createCMUXTUIBridge(ctx, options = {}) { const environment = { ...process.env, ...options.environment }; let disposed = false; + const turns = createTurnLedger(); // Keep missed session-start events until the TUI reports a route/tab change. // The nested map lets session-end and delivery cancel one session in O(1). const pending = new Map(); @@ -252,7 +333,7 @@ export async function createCMUXTUIBridge(ctx, options = {}) { void handleEvent(ctx, ownership, feed, event, environment, { cancel: cancelPending, enqueue: enqueueSessionStart, - }).catch((error) => reportError(ctx, error)); + }, turns).catch((error) => reportError(ctx, error)); } } }; @@ -287,7 +368,7 @@ export async function createCMUXTUIBridge(ctx, options = {}) { void handleEvent(ctx, ownership, feed, incoming, environment, { cancel: cancelPending, enqueue: enqueueSessionStart, - }).catch((error) => reportError(ctx, error)); + }, turns).catch((error) => reportError(ctx, error)); }; const stop = ctx.data.listen(onEvent); const disposeReactive = createRoot((dispose) => { diff --git a/Sources/AgentFeedSemanticInput.swift b/Sources/AgentFeedSemanticInput.swift index 0bac6947bf64..46a70ef8c87d 100644 --- a/Sources/AgentFeedSemanticInput.swift +++ b/Sources/AgentFeedSemanticInput.swift @@ -34,6 +34,8 @@ struct AgentFeedSemanticInput: Sendable { let kind: AgentJournalEventKind if resolved { kind = .attentionResolved + } else if event.hookEventName == .stop, event.isError == true { + kind = .errorReported } else if isToolActivity { kind = .stateChanged } else { diff --git a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs index bb8375b370b2..f84ecebded60 100644 --- a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs +++ b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs @@ -47,10 +47,18 @@ pub fn agent_hook_journal_ingress( validate_agent_source(source)?; validate_native_event(native_event)?; let terminal_id = terminal_id.map(TerminalPublicId::parse).transpose()?; + // Read the structured reason before redaction replaces free-form strings. + let resumable_interruption = is_resumable_opencode_interruption(source, native_event, &native); let native = redact_agent_native(native_event, native); let mut normalized = normalized_fields(&native); add_agent_topology(source, native_event, terminal_id.as_ref(), &mut normalized); - let kind = semantic_kind(source, native_event, &normalized); + let kind = if resumable_interruption { + // OpenCode keeps a shutdown-interrupted execution and resumes it, so + // the turn is still running rather than completed. + "agent.state.changed" + } else { + semantic_kind(source, native_event, &normalized) + }; let mut subjects = Vec::with_capacity(4); if let Some(terminal_id) = terminal_id { subjects.push(JournalSubject { kind: "terminal".into(), id: terminal_id.to_string() }); @@ -297,6 +305,14 @@ fn semantic_kind( ("antigravity" | "hermes-agent", "sessionend" | "onsessionend") => "agent.turn.completed", ("opencode", "sessioncreated") => "agent.session.started", ("opencode", "sessionidle") => "agent.turn.completed", + ( + "opencode", + "sessionexecutionsucceeded" | "sessionexecutioninterrupted", + ) => "agent.turn.completed", + ( + "opencode", + "sessionexecutionfailed" | "sessionnextstepfailed" | "sessionerror", + ) => "agent.error.reported", ("opencode", "sessiondeleted") => "agent.session.ended", (_, event) if is_child_spawn(event) => "agent.child.spawned", (_, event) if is_child_completion(event) => "agent.child.completed", @@ -327,6 +343,17 @@ fn semantic_kind( } } +fn is_resumable_opencode_interruption(source: &str, native_event: &str, native: &Value) -> bool { + if source != "opencode" || semantic_key(native_event) != "sessionexecutioninterrupted" { + return false; + } + let event = native.get("event").unwrap_or(native); + ["data", "properties"] + .iter() + .filter_map(|key| event.get(key)) + .any(|properties| properties.get("reason").and_then(Value::as_str) == Some("shutdown")) +} + fn is_child_spawn(event: &str) -> bool { matches!( event, @@ -959,6 +986,40 @@ mod tests { assert_eq!(deleted.kind, "agent.session.ended"); } + #[test] + fn current_opencode_failure_events_report_errors() { + for event in ["session.execution.failed", "session.next.step.failed", "session.error"] { + let ingress = agent_hook_journal_ingress( + "opencode", + event, + None, + json!({"event":{"properties":{"sessionID":"opencode-session"}}}), + ) + .unwrap(); + assert_eq!(ingress.kind, "agent.error.reported", "{event}"); + } + } + + #[test] + fn opencode_shutdown_interruption_keeps_the_turn_running() { + let interrupted = "session.execution.interrupted"; + let kind = |field: &str, reason: &str| { + let native = json!({"event":{"type":interrupted,field:{"reason":reason}}}); + agent_hook_journal_ingress("opencode", interrupted, None, native).unwrap().kind + }; + assert_eq!(kind("data", "shutdown"), "agent.state.changed"); + assert_eq!(kind("properties", "shutdown"), "agent.state.changed"); + assert_eq!(kind("data", "user"), "agent.turn.completed"); + } + + #[test] + fn current_opencode_completion_events_report_completed_turns() { + for event in ["session.execution.succeeded", "session.execution.interrupted"] { + let ingress = agent_hook_journal_ingress("opencode", event, None, json!({})).unwrap(); + assert_eq!(ingress.kind, "agent.turn.completed", "{event}"); + } + } + #[test] fn wrapped_opencode_events_keep_native_shape_and_normalize_properties() { let native = json!({ diff --git a/cmux-tui/crates/cmux-tui/assets/agent-hooks/opencode.js b/cmux-tui/crates/cmux-tui/assets/agent-hooks/opencode.js index 14c569d6f9da..11b8ce0276e6 100644 --- a/cmux-tui/crates/cmux-tui/assets/agent-hooks/opencode.js +++ b/cmux-tui/crates/cmux-tui/assets/agent-hooks/opencode.js @@ -7,6 +7,11 @@ const events = new Set([ "session.updated", "session.status", "session.idle", + "session.execution.succeeded", + "session.execution.interrupted", + "session.execution.failed", + "session.next.step.failed", + "session.error", "session.compacted", "session.deleted", "message.updated", @@ -19,10 +24,11 @@ const events = new Set([ "question.replied", "question.rejected", ]); +const NON_TERMINAL_SESSION_ERRORS = new Set(["MessageAbortedError", "ContextOverflowError"]); const sessionRoots = new Map(); function topology(event) { - const properties = event?.properties || {}; + const properties = event?.data || event?.properties || {}; const info = properties.info || {}; const sessionId = info.id || properties.sessionID || properties.sessionId; if (!sessionId) return {}; @@ -62,6 +68,10 @@ const CmuxTuiJournal = async (context) => ({ // already records their visible bytes, so spawning one process per token // would add load without adding a semantic transition. if (!events.has(nativeEvent)) return; + // An interrupt (Esc) or a recoverable context overflow arrives as + // session.error but the turn continues to idle; it is not a failure. + const errorName = (event.properties || event.data || {}).error?.name; + if (nativeEvent === "session.error" && NON_TERMINAL_SESSION_ERRORS.has(errorName)) return; const sessionTopology = topology(event); emit(nativeEvent, { event, diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 88b81026516a..9a42dcdf0abb 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -34,6 +34,52 @@ final class OpenCodeHookRegressionTests: XCTestCase { XCTAssertEqual(OpenCodePaths(environment: environment).databaseURL.path, "/tmp/home/.local/share/opencode/opencode.db") } + func testOpenCodeInstallHooksUsesXDGConfigHome() throws { + let cliPath = try bundledCLIPath() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-opencode-xdg-install-\(UUID().uuidString)", isDirectory: true + ) + let xdgConfigHome = root.appendingPathComponent("xdg-config", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let fakeOpenCodeURL = binDir.appendingPathComponent("opencode", isDirectory: false) + try "#!/bin/sh\nexit 0\n".write(to: fakeOpenCodeURL, atomically: true, encoding: .utf8) + chmod(fakeOpenCodeURL.path, 0o755) + + var environment = ProcessInfo.processInfo.environment + environment.removeValue(forKey: "OPENCODE_CONFIG_DIR") + environment["HOME"] = root.appendingPathComponent("home", isDirectory: true).path + environment["XDG_CONFIG_HOME"] = xdgConfigHome.path + environment["PATH"] = "\(binDir.path):\(environment["PATH"] ?? "/usr/bin")" + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "opencode", "install", "--yes"], + environment: environment, + timeout: 5 + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let configDir = xdgConfigHome.appendingPathComponent("opencode", isDirectory: true) + XCTAssertTrue( + fileManager.fileExists(atPath: configDir.appendingPathComponent("plugins/cmux-session.js").path), + "OpenCode's lifecycle plugin must follow XDG_CONFIG_HOME" + ) + XCTAssertTrue( + fileManager.fileExists(atPath: configDir.appendingPathComponent("plugins/cmux-feed.js").path), + "OpenCode's Feed bridge must follow XDG_CONFIG_HOME" + ) + XCTAssertFalse( + fileManager.fileExists(atPath: root.appendingPathComponent("home/.config/opencode/plugins/cmux-session.js").path), + "OpenCode hooks must not be split across legacy and XDG config directories" + ) + } + private struct ProcessRunResult { let status: Int32 let stdout: String @@ -82,7 +128,7 @@ final class OpenCodeHookRegressionTests: XCTestCase { event["hook_event_name"] as? String == "Stop" else { return nil } return event } - XCTAssertEqual(stopEvents.count, 3, "Expected all OpenCode idle forms to emit Stop: \(frames)") + XCTAssertEqual(stopEvents.count, 1, "Expected duplicate OpenCode idle forms to coalesce: \(frames)") XCTAssertTrue(stopEvents.allSatisfy { $0["session_id"] as? String == "opencode-ses-feed-shape" }) let requestIDs = frames.compactMap { $0["id"] as? String } XCTAssertEqual(requestIDs.count, Set(requestIDs).count, "OpenCode telemetry request IDs must not collide: \(frames)") @@ -247,7 +293,7 @@ const fs = require("node:fs"); if (!line.trim()) continue; const frame = JSON.parse(line); frames.push(frame); - if (frames.length === 4) resolveFrames(); + if (frames.length === 3) resolveFrames(); conn.write(JSON.stringify({ id: frame.id, ok: true, result: { status: "acknowledged" } }) + "\n"); } }); @@ -288,6 +334,10 @@ const fs = require("node:fs"); type: "session.status", properties: { session_id: "ses-feed-shape", status: "idle" } } }); + await hooks.event({ event: { + type: "session.deleted", + properties: { session_id: "ses-feed-shape" } + } }); await framesReady; for (const socket of sockets) socket.destroy(); await new Promise((resolve) => server.close(resolve)); @@ -332,3 +382,117 @@ const fs = require("node:fs"); ) } } + +extension CLINotifyProcessIntegrationRegressionTests { + func testOpenCodeStopFailureFeedEventRetainsErrorState() throws { + let context = try makeClaudeHookContext(name: "opencode-stop-failure-feed") + defer { + Darwin.close(context.listenerFD) + unlink(context.socketPath) + try? FileManager.default.removeItem(at: context.root) + } + startAgentHookMockServerAccepting(context: context) + + let result = runAgentHook( + context: context, + agent: "opencode", + subcommand: "stop", + standardInput: #"{"session_id":"opencode-error-feed","cwd":"/tmp/opencode","hook_event_name":"StopFailure","error":{"name":"UnknownError","data":{"message":"Model not found: unknown/foo"}}}"# + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let deadline = Date().addingTimeInterval(5) + var feedEvent: [String: Any]? + while Date() < deadline, feedEvent == nil { + feedEvent = context.state.snapshot().compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "feed.push", + let params = payload["params"] as? [String: Any] else { return nil } + return params["event"] as? [String: Any] + }.first + if feedEvent == nil { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + } + + XCTAssertEqual(feedEvent?["hook_event_name"] as? String, "Stop") + XCTAssertEqual(feedEvent?["is_error"] as? Bool, true) + XCTAssertEqual(feedEvent?["error"] as? String, "Model not found: unknown/foo") + } + + func testOpenCodeRawFailureEventFeedEventRetainsErrorState() throws { + let context = try makeClaudeHookContext(name: "opencode-raw-failure-feed") + defer { + Darwin.close(context.listenerFD) + unlink(context.socketPath) + try? FileManager.default.removeItem(at: context.root) + } + startAgentHookMockServerAccepting(context: context) + + let result = runAgentHook( + context: context, + agent: "opencode", + subcommand: "stop", + standardInput: #"{"session_id":"opencode-raw-error-feed","cwd":"/tmp/opencode","hook_event_name":"session.error","error":{"name":"UnknownError","data":{"message":"Model not found: unknown/foo"}}}"# + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let deadline = Date().addingTimeInterval(5) + var feedEvent: [String: Any]? + while Date() < deadline, feedEvent == nil { + feedEvent = context.state.snapshot().compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "feed.push", + let params = payload["params"] as? [String: Any] else { return nil } + return params["event"] as? [String: Any] + }.first + if feedEvent == nil { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + } + + XCTAssertEqual(feedEvent?["hook_event_name"] as? String, "Stop") + XCTAssertEqual(feedEvent?["is_error"] as? Bool, true) + XCTAssertEqual(feedEvent?["error"] as? String, "Model not found: unknown/foo") + } + + func testOpenCodeStopFailureFeedEventRedactsCredentials() throws { + let context = try makeClaudeHookContext(name: "opencode-stop-failure-redact") + defer { + Darwin.close(context.listenerFD) + unlink(context.socketPath) + try? FileManager.default.removeItem(at: context.root) + } + startAgentHookMockServerAccepting(context: context) + + let result = runAgentHook( + context: context, + agent: "opencode", + subcommand: "stop", + standardInput: #"{"session_id":"opencode-error-redact","cwd":"/tmp/opencode","hook_event_name":"StopFailure","error":{"name":"UnknownError","data":{"message":"401 invalid x-api-key: sk-ant-api03-abcdefghijklmnop for me@example.com"}}}"# + ) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let deadline = Date().addingTimeInterval(5) + var feedEvent: [String: Any]? + while Date() < deadline, feedEvent == nil { + feedEvent = context.state.snapshot().compactMap { command -> [String: Any]? in + guard let payload = jsonObject(command), + payload["method"] as? String == "feed.push", + let params = payload["params"] as? [String: Any] else { return nil } + return params["event"] as? [String: Any] + }.first + if feedEvent == nil { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + } + + XCTAssertEqual(feedEvent?["hook_event_name"] as? String, "Stop") + XCTAssertEqual(feedEvent?["is_error"] as? Bool, true) + // Upstream error text reaches Feed and notifications; credentials must not. + XCTAssertEqual(feedEvent?["error"] as? String, "401 invalid <credential>:<token> for <email>") + } +} diff --git a/tests/test-execution.toml b/tests/test-execution.toml index dd70912a932a..6c13d617b9ec 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -1729,6 +1729,11 @@ path = "tests/test_opencode_v2_tui_bridge.py" lane = "manual" reason = "Focused OpenCode V2 Node bridge harness; it uses a TUI/socket fixture and is run by the OpenCode regression verification command." +[[test]] +path = "tests/test_opencode_server_entry.py" +lane = "manual" +reason = "Focused OpenCode server-entry Node harness; no workflow names it yet, so it is run by the OpenCode regression verification command." + [[test]] path = "tests/test_nightly_resume_tracks.py" lane = "linux-guard" diff --git a/tests/test_opencode_plugin_install.py b/tests/test_opencode_plugin_install.py index f2f161dfe7fb..6da9907e305a 100644 --- a/tests/test_opencode_plugin_install.py +++ b/tests/test_opencode_plugin_install.py @@ -177,13 +177,12 @@ def main() -> int: check_env["FAKE_CMUX_STDIN_LOG"] = str(fake_stdin_log) check_env["FAKE_CMUX_ENV_LOG"] = str(fake_env_log) check_env["CMUX_OPENCODE_HOOKS_DISABLED"] = "" - for launch_key in ( - "CMUX_AGENT_LAUNCH_KIND", - "CMUX_AGENT_LAUNCH_EXECUTABLE", - "CMUX_AGENT_LAUNCH_ARGV_B64", - "CMUX_AGENT_LAUNCH_CWD", - ): - check_env.pop(launch_key, None) + # OpenCode started from a shell inside another agent inherits that + # agent's launch capture; the plugin must record OpenCode's own. + check_env["CMUX_AGENT_LAUNCH_KIND"] = "claude" + check_env["CMUX_AGENT_LAUNCH_EXECUTABLE"] = "/opt/homebrew/bin/claude" + check_env["CMUX_AGENT_LAUNCH_ARGV_B64"] = base64.b64encode(b"/opt/homebrew/bin/claude\0").decode() + check_env["CMUX_AGENT_LAUNCH_CWD"] = "/tmp/claude-project" check_source = """ const pluginPath = process.env.CMUX_TEST_OPENCODE_PLUGIN_PATH; const pluginCopyPath = process.env.CMUX_TEST_OPENCODE_PLUGIN_COPY_PATH; @@ -235,6 +234,8 @@ def main() -> int: 0, process.argv.length, "/Users/example/.bun/bin/opencode", + // OpenCode 1.x reports src/index.js; V2 reports the TUI worker. + "/$bunfs/root/src/index.js", "/$bunfs/root/src/cli/cmd/tui/worker.js", "--model", "anthropic/claude-sonnet-4-6" @@ -250,6 +251,71 @@ def main() -> int: } } }); +await v1Hooks.event({ + event: { + type: "session.updated", + properties: { + info: { id: "opencode-session-test", time: { archived: false } } + } + } +}); +await v1Hooks.event({ + event: { + type: "session.error", + properties: { + sessionID: "opencode-session-test", + error: { name: "UnknownError", data: { message: "provider failed" } } + } + } +}); +await v1Hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "opencode-session-test", status: { type: "idle" } } + } +}); +await v1Hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "opencode-session-test" } + } +}); +// One terminal stop per turn, opened only by a run signal. Turn 2 starts on +// busy; a repeated user-message update after its stop must not reopen it, and +// a failure after the published idle stop is dropped, not reclassified. +// Turn 3 queues prompt 4 while running: the queued prompt's user message and +// admission arrive mid-turn, yet turn 4 still publishes its own stop. +const sid = "opencode-session-test"; +const userMessage = (id) => ({ + event: { type: "message.updated", properties: { info: { id, sessionID: sid, role: "user" } } } +}); +const busy = { event: { type: "session.status", properties: { sessionID: sid, status: { type: "busy" } } } }; +const idle = { event: { type: "session.idle", properties: { sessionID: sid } } }; +const failure = { + event: { + type: "session.error", + properties: { sessionID: sid, error: { name: "UnknownError", data: { message: "late failure" } } } + } +}; +await v1Hooks.event(userMessage("msg-turn-2")); +await v1Hooks.event(busy); +await v1Hooks.event(idle); +await v1Hooks.event(userMessage("msg-turn-2")); +await v1Hooks.event(idle); +await v1Hooks.event(failure); +await v1Hooks.event(userMessage("msg-turn-3")); +await v1Hooks.event(busy); +await v1Hooks.event(userMessage("msg-turn-4")); +await v1Hooks.event({ event: { type: "session.next.prompt.admitted", properties: { sessionID: sid, messageID: "msg-turn-4" } } }); +await v1Hooks.event(idle); +await v1Hooks.event({ event: { type: "session.next.prompted", properties: { sessionID: sid, messageID: "msg-turn-4" } } }); +await v1Hooks.event({ event: { type: "session.execution.succeeded", properties: { sessionID: sid } } }); +// Shutdown keeps the execution for resumption; it is not a completed turn. +await v1Hooks.event(busy); +await v1Hooks.event({ event: { type: "session.execution.interrupted", properties: { sessionID: sid, reason: "shutdown" } } }); +// Sentinel: hook processes are spawned in event order, so once session-end +// has run every earlier stop hook has been spawned too. +await v1Hooks.event({ event: { type: "session.deleted", properties: { info: { id: sid } } } }); """ check = subprocess.run( [bun, "--eval", check_source], @@ -267,8 +333,17 @@ def main() -> int: print(f"stderr={check.stderr.strip()}") return 1 + # Wait for the session-end sentinel and for every hook process to finish + # writing, so a late extra stop cannot land after the counts below. + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + args_text = fake_args_log.read_text(encoding="utf-8") if fake_args_log.exists() else "" + stdin_text = fake_stdin_log.read_text(encoding="utf-8") if fake_stdin_log.exists() else "" + if "opencode session-end" in args_text and stdin_text.count("\n---\n") >= args_text.count("\n"): + break + time.sleep(0.01) args_log = wait_for_log(fake_args_log, "hooks enqueue opencode session-start") - stdin_log = wait_for_log(fake_stdin_log, '"session_id":"opencode-session-test"') + stdin_log = wait_for_log(fake_stdin_log, '"hook_event_name":"StopFailure"') env_log = wait_for_log(fake_env_log, "kind=opencode") if "hooks enqueue opencode session-start" not in args_log: print(f"FAIL: plugin did not invoke hooks opencode session-start, got {args_log!r}") @@ -276,6 +351,18 @@ def main() -> int: if args_log.count("hooks enqueue opencode session-start") != 1: print(f"FAIL: plugin invoked duplicate session-start hooks, got {args_log!r}") return 1 + if args_log.count("hooks enqueue opencode stop") != 4: + print(f"FAIL: plugin did not publish exactly one stop per turn, got {args_log!r}") + return 1 + if stdin_log.count('"hook_event_name":"Stop"') != 3 or stdin_log.count('"hook_event_name":"StopFailure"') != 1: + print(f"FAIL: plugin did not settle one terminal outcome per turn, got {stdin_log!r}") + return 1 + if "late failure" in stdin_log: + print(f"FAIL: plugin reclassified a turn after its stop was published, got {stdin_log!r}") + return 1 + if '"hook_event_name":"StopFailure"' not in stdin_log or '"error":"provider failed"' not in stdin_log: + print(f"FAIL: plugin did not preserve the OpenCode nested error message, got {stdin_log!r}") + return 1 if '"session_id":"opencode-session-test"' not in stdin_log or '"/tmp/opencode-project"' not in stdin_log: print(f"FAIL: plugin did not pass expected session payload, got {stdin_log!r}") return 1 diff --git a/tests/test_opencode_server_entry.py b/tests/test_opencode_server_entry.py new file mode 100644 index 000000000000..3862e51d9d10 --- /dev/null +++ b/tests/test_opencode_server_entry.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Released OpenCode loads cmux through each plugin's default `server` entry. + +OpenCode 1.x (including the current dev channel) calls `default.server(ctx)` +inside the TUI process with `{ client, project, worktree, directory, +serverUrl, $ }` and delivers events through the returned `event` hook. It +loads `cmux-feed.js`, `cmux-session.js` and `plugins/cmux/index.js`, so the +server entries must deliver one Stop per turn instead of returning `{}`. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def session_plugin_source() -> str: + swift = (ROOT / "CLI/cmux.swift").read_text(encoding="utf-8") + match = re.search(r'openCodeSessionPluginSource = #"""\n(.*?)\n"""#', swift, re.S) + if match is None: + raise RuntimeError("openCodeSessionPluginSource not found") + return match.group(1) + + +HARNESS = r''' +const { pluginDir, logPath, socketPath, fakeCmux } = JSON.parse(process.env.CMUX_OPENCODE_SERVER_HARNESS); +const fs = await import("node:fs"); +const net = await import("node:net"); +const path = await import("node:path"); + +const frames = []; +const server = net.createServer((connection) => { + connection.setEncoding("utf8"); + connection.on("error", () => {}); + let buffered = ""; + connection.on("data", (chunk) => { + buffered += chunk; + let index; + while ((index = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, index); + buffered = buffered.slice(index + 1); + if (!line) continue; + const frame = JSON.parse(line); + frames.push(frame); + connection.write(JSON.stringify({ id: frame.id, ok: true, result: {} }) + "\n"); + } + }); +}); +await new Promise((resolve) => server.listen(socketPath, resolve)); + +// Shape observed from OpenCode 1.19.0 and the dev channel. +const ctx = { + client: {}, + project: { id: "project" }, + worktree: "/tmp", + directory: "/tmp", + experimental_workspace: {}, + serverUrl: new URL("http://127.0.0.1:4096"), + $: () => {}, +}; + +// Old V2 snapshots called setup()/server() without the server context; they +// must stay inert because their shared service is not owned by one TUI. +const v2 = await import(path.join(pluginDir, "cmux", "index.js")); +const v2Hooks = await v2.default.server({}); +if (v2Hooks && typeof v2Hooks.event === "function") throw new Error("V2 shared server entry subscribed"); +const v2Cleanup = await v2.default.setup({}); +if (typeof v2Cleanup !== "function") throw new Error("V2 setup did not return cleanup"); +v2Cleanup(); + +const { execFileSync } = await import("node:child_process"); +const hookLog = () => (fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""); +const fakeCmuxRunning = () => { + try { + execFileSync("pgrep", ["-f", fakeCmux], { stdio: "ignore" }); + return true; + } catch (_) { + return false; + } +}; +const waitFor = async (predicate, message) => { + const deadline = Date.now() + 30000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(message); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +}; + +// Load the three cmux entries for one plugin scope, as OpenCode does. +const loadScope = async (scopeCtx) => { + const hooks = []; + for (const file of ["cmux-feed.js", "cmux-session.js", path.join("cmux", "index.js")]) { + const mod = await import(path.join(pluginDir, file)); + if (typeof mod.default?.server !== "function") throw new Error(`${file} lacks a server entry`); + hooks.push(await mod.default.server(scopeCtx, {})); + } + const active = hooks.filter((hook) => hook && typeof hook.event === "function"); + if (active.length === 0) throw new Error("no OpenCode server entry subscribed to events"); + return active; +}; + +let sessionEndsExpected = 0; +const sessionEnds = () => hookLog().split("\n").filter((line) => line.includes("opencode session-end")).length; +const stopHookLines = () => hookLog().split("\n").filter((line) => line.includes("hooks enqueue opencode stop")); + +// Drive one turn. The session.deleted sentinel follows the duplicate-prone +// idle events: socket frames are ordered, and every hook process is spawned +// before the sentinel's. +const runTurn = async (scope, sid, { midTurn = [] } = {}) => { + const emit = async (event) => { + for (const hook of scope) await hook.event({ event }); + }; + await emit({ type: "session.created", properties: { info: { id: sid, directory: "/tmp" } } }); + await emit({ type: "session.status", properties: { sessionID: sid, status: { type: "busy" } } }); + for (const event of midTurn) await emit(event); + await emit({ type: "session.status", properties: { sessionID: sid, status: { type: "idle" } } }); + await emit({ type: "session.idle", properties: { sessionID: sid } }); + await emit({ type: "session.deleted", properties: { info: { id: sid } } }); + sessionEndsExpected++; + + const forSession = (frame) => frame.params?.event?.session_id === `opencode-${sid}`; + await waitFor( + () => frames.some((frame) => forSession(frame) && frame.params.event.hook_event_name === "SessionEnd"), + `feed sentinel never arrived for ${sid}: ${JSON.stringify(frames)}`, + ); + await waitFor(() => sessionEnds() >= sessionEndsExpected, `session-end hook never ran for ${sid}`); + await waitFor(() => !fakeCmuxRunning(), "hook processes did not exit"); + const stops = frames.filter((frame) => forSession(frame) && frame.params.event.hook_event_name === "Stop"); + if (stops.length !== 1) throw new Error(`expected one feed Stop for ${sid}, got ${stops.length}: ${JSON.stringify(frames)}`); + if (stops[0].params.event.surface_id !== "surface-server-entry") throw new Error("feed Stop lost the surface id"); + return stops[0]; +}; + +const first = await loadScope(ctx); +// Every loader in one scope receives the same ctx; a repeat load is a no-op. +if ((await loadScope(ctx).catch(() => [])).length !== 0) throw new Error("duplicate loader in one scope subscribed"); +await runTurn(first, "ses_server_entry"); +// OpenCode keeps one scope per directory; a second live scope must not evict +// the first. +const second = await loadScope({ ...ctx, directory: "/tmp/other" }); +await runTurn(second, "ses_server_entry_second"); +await runTurn(first, "ses_server_entry_concurrent"); + +// Esc publishes session.error(MessageAbortedError) and then goes idle. +const aborted = await runTurn(first, "ses_server_entry_abort", { + midTurn: [{ type: "session.error", properties: { sessionID: "ses_server_entry_abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } } }], +}); +if (aborted.params.event.is_error) throw new Error("an interrupt was reported as a failed turn"); +if (stopHookLines().some((line) => line.includes("StopFailure"))) throw new Error("an interrupt sent a StopFailure hook"); + +// OpenCode disposes a torn-down scope's hooks; they must not emit afterwards. +for (const hook of first) await hook.dispose?.(); +const stale = "ses_server_entry_stale"; +for (const event of [ + { type: "session.created", properties: { info: { id: stale, directory: "/tmp" } } }, + { type: "session.status", properties: { sessionID: stale, status: { type: "busy" } } }, + { type: "session.idle", properties: { sessionID: stale } }, +]) { + for (const hook of first) await hook.event({ event }); +} +// Hook processes are spawned synchronously inside event(); wait for them. +await waitFor(() => !fakeCmuxRunning(), "hook processes did not exit"); +if (frames.some((frame) => frame.params?.event?.session_id === `opencode-${stale}`)) { + throw new Error("a disposed feed scope still emitted events"); +} +if (stopHookLines().length !== 4) throw new Error(`expected one stop hook per turn, got ${stopHookLines().length}: ${hookLog()}`); +for (const hook of second) await hook.dispose?.(); +server.close(); +console.log("PASS: OpenCode server entry delivers one completion"); +process.exit(0); +''' + + +def main() -> int: + node = shutil.which("node") + if node is None: + print("SKIP: node not found") + return 0 + with tempfile.TemporaryDirectory(prefix="cmux-opencode-server-") as td: + root = Path(td) + plugin_dir = root / "plugins" + package_dir = plugin_dir / "cmux" + package_dir.mkdir(parents=True) + feed_source = (ROOT / "Resources/opencode-plugin.js").read_text(encoding="utf-8") + (plugin_dir / "cmux-feed.js").write_text(feed_source, encoding="utf-8") + (plugin_dir / "cmux-session.js").write_text(session_plugin_source(), encoding="utf-8") + (package_dir / "index.js").write_text( + "// cmux-opencode-tui-plugin-server-marker v2\n" + feed_source, encoding="utf-8" + ) + (plugin_dir / "package.json").write_text('{"type":"module"}', encoding="utf-8") + (package_dir / "package.json").write_text('{"type":"module"}', encoding="utf-8") + log_path = root / "cmux.log" + fake_cmux = root / "cmux" + fake_cmux.write_text(f'#!/bin/sh\necho "$* $(cat)" >> "{log_path}"\n', encoding="utf-8") + fake_cmux.chmod(0o755) + socket_path = str(root / "cmux.sock") + env = os.environ.copy() + env.update( + CMUX_SURFACE_ID="surface-server-entry", + CMUX_WORKSPACE_ID="workspace-server-entry", + CMUX_SOCKET_PATH=socket_path, + CMUX_OPENCODE_CMUX_BIN=str(fake_cmux), + CMUX_OPENCODE_HOOKS_DISABLED="", + CMUX_OPENCODE_SERVER_HARNESS=json.dumps( + {"pluginDir": str(plugin_dir), "logPath": str(log_path), "socketPath": socket_path, "fakeCmux": str(fake_cmux)} + ), + ) + harness = root / "harness.mjs" + harness.write_text(HARNESS, encoding="utf-8") + try: + result = subprocess.run([node, str(harness)], env=env, capture_output=True, text=True, timeout=30) + finally: + try: + os.unlink(socket_path) + except FileNotFoundError: + pass + sys_out = (result.stdout + result.stderr).strip() + if result.returncode != 0: + print(f"FAIL: {sys_out[-3000:]}") + return 1 + print(sys_out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_opencode_v2_tui_bridge.py b/tests/test_opencode_v2_tui_bridge.py index b67adc629b68..e3371d9d113b 100755 --- a/tests/test_opencode_v2_tui_bridge.py +++ b/tests/test_opencode_v2_tui_bridge.py @@ -48,7 +48,9 @@ def main() -> int: if (mod.sessionBelongsToTUI(makeContext("a"), "child-b")) throw new Error("TUI A claimed TUI B session"); const closed = { ui: { router: { current: () => fixture.starterClosed.route }, tabs: { enabled: () => true, list: () => [] } }, data: { session: { root: (id) => fixture.roots[id] || id } } }; if (mod.sessionBelongsToTUI(closed, "child-a")) throw new Error("closed starter surface retained ownership"); -const dispatchEnvironment = { CMUX_SURFACE_ID: "surface-a", CMUX_WORKSPACE_ID: "workspace-a", CMUX_OPENCODE_HOOKS_DISABLED: "" }; +// A parent OpenCode's capture is inherited by an OpenCode it starts. +const parentLaunchArgv = Buffer.from("/parent/opencode\0-s\0parent-session\0", "utf8").toString("base64"); +const dispatchEnvironment = { CMUX_SURFACE_ID: "surface-a", CMUX_WORKSPACE_ID: "workspace-a", CMUX_OPENCODE_HOOKS_DISABLED: "", CMUX_AGENT_LAUNCH_KIND: "opencode", CMUX_AGENT_LAUNCH_EXECUTABLE: "/parent/opencode", CMUX_AGENT_LAUNCH_ARGV_B64: parentLaunchArgv }; let spawnCalls = 0; let spawnOptions; const fakeSpawn = (_command, _args, options) => { @@ -62,6 +64,7 @@ def main() -> int: if (spawnOptions?.env?.CMUX_AGENT_LAUNCH_KIND !== "opencode") throw new Error("session admission dropped launch kind"); if (!spawnOptions?.env?.CMUX_AGENT_LAUNCH_EXECUTABLE) throw new Error("session admission dropped launch executable"); if (!spawnOptions?.env?.CMUX_AGENT_LAUNCH_ARGV_B64) throw new Error("session admission dropped launch argv"); +if (spawnOptions.env.CMUX_AGENT_LAUNCH_ARGV_B64 === parentLaunchArgv) throw new Error("session admission reused a parent OpenCode launch capture"); const socketPath = "/tmp/cmux-opencode-v2-" + process.pid + ".sock"; try { await fs.unlink(socketPath); } catch (_) {} @@ -194,6 +197,12 @@ def main() -> int: if (observed.some((event) => event.session_id === "opencode-child-stale" && event.hook_event_name === "SessionStart")) throw new Error("ended queued session-start was replayed"); liveA.setRoute(routeA); liveA.setTabs(tabsA); +liveA.emit({ details: { type: "session.updated", data: { sessionID: "child-a", info: { id: "child-a", time: { archived: false } } } } }); +// OpenCode 1.19 reports a failure before the idle status and idle marker. +// The failure must win the lifecycle race; the later idle forms are duplicates. +liveA.emit({ details: { type: "session.error", data: { sessionID: "child-a", error: { name: "UnknownError", data: { message: "provider failed" } } } } }); +liveA.emit({ details: { type: "session.status", data: { sessionID: "child-a", status: "idle" } } }); +liveA.emit({ details: { type: "session.idle", data: { sessionID: "child-a" } } }); liveB.emit({ details: { type: "session.execution.succeeded", data: { sessionID: "child-b" } } }); liveA.emit({ details: { type: "permission.asked", data: { sessionID: "child-a", id: "perm-a", action: "edit", resources: ["/tmp/a/file"] } } }); liveB.emit({ details: { type: "permission.asked", data: { sessionID: "child-a", id: "perm-wrong", action: "edit" } } }); @@ -254,7 +263,12 @@ def main() -> int: await waitForObserved((event) => event.hook_event_name === "SessionStart"); await waitForObserved((event) => event.hook_event_name === "Stop"); const sessionStart = observed.find((event) => event.hook_event_name === "SessionStart"); -const stop = observed.find((event) => event.hook_event_name === "Stop"); +const stop = observed.find((event) => event.hook_event_name === "Stop" && event.session_id === "opencode-child-b"); +const childALifecycle = observed.filter((event) => event.session_id === "opencode-child-a" + && ["SessionStart", "Stop"].includes(event.hook_event_name)); +if (childALifecycle.filter((event) => event.hook_event_name === "SessionStart").length !== 1) throw new Error("session.updated reopened the OpenCode session"); +if (childALifecycle.filter((event) => event.hook_event_name === "Stop").length !== 1) throw new Error("OpenCode failure-first lifecycle event was lost or duplicated"); +if (!childALifecycle.some((event) => event.hook_event_name === "Stop" && event.is_error === true && event.error === "provider failed")) throw new Error("OpenCode session.error did not preserve an error completion"); if (sessionStart?.surface_id !== "surface-a" || sessionStart?.workspace_id !== "workspace-a") throw new Error("Feed event was routed to the wrong surface"); if (sessionStart?.cwd !== "/tmp/a") throw new Error("V2 session location was dropped from Feed telemetry"); if (stop?.surface_id !== "surface-b" || stop?.workspace_id !== "workspace-b") throw new Error("second TUI Feed event was routed to the first surface"); @@ -267,6 +281,28 @@ def main() -> int: if (observed.find((event) => event.hook_event_name === "SessionEnd" && event.session_id === "opencode-child-b")?.surface_id !== "surface-b") throw new Error("archived session was not ended on its owning TUI"); if (observed.some((event) => event._opencode_request_id === "perm-wrong")) throw new Error("TUI B accepted a session owned by TUI A"); +// A prompt queued during a running turn is admitted mid-turn and promoted +// after the stop. Both turns publish exactly one Stop; the repeated idle +// forms, a late failure and a shutdown interruption publish none. +liveA.setRoute({ type: "session", sessionID: "child-queue" }); +const queueEvents = [ + ["session.status", { sessionID: "child-queue", status: { type: "busy" } }], + ["session.next.prompt.admitted", { sessionID: "child-queue", messageID: "queued-m2" }], + ["session.status", { sessionID: "child-queue", status: "idle" }], + ["session.idle", { sessionID: "child-queue" }], + ["session.error", { sessionID: "child-queue", error: { message: "late failure" } }], + ["session.next.prompted", { sessionID: "child-queue", messageID: "queued-m2" }], + ["session.execution.succeeded", { sessionID: "child-queue" }], + ["session.status", { sessionID: "child-queue", status: { type: "busy" } }], + ["session.execution.interrupted", { sessionID: "child-queue", reason: "shutdown" }], + ["session.deleted", { sessionID: "child-queue" }], +]; +for (const [type, data] of queueEvents) liveA.emit({ details: { type, data } }); +await waitForObserved((event) => event.hook_event_name === "SessionEnd" && event.session_id === "opencode-child-queue"); +const queueStops = observed.filter((event) => event.session_id === "opencode-child-queue" && event.hook_event_name === "Stop"); +if (queueStops.length !== 2 || queueStops.some((event) => event.is_error)) throw new Error(`queued prompt turns published ${JSON.stringify(queueStops)}`); +liveA.setRoute(routeA); + // A bridge that never owns a queued session must release its expiry timer on // disposal, rather than retaining the entire TUI context until the TTL elapses. const expiryTimers = new Set(); From 35a1346a78af6bd544d45f5a8c60f0531f109d18 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Wed, 14 Oct 2026 01:24:57 -0700 Subject: [PATCH 025/183] Fix Return delivery to browser forms (#20449) * test: cover browser Return routing and native form submission * fix: deliver browser Return to the owning web view * test: keep Return out of Web Inspector page routing * fix: preserve Web Inspector Return routing * test: cover inspector Return in browser focus mode * fix: keep Web Inspector outside browser focus mode * fix: compile browser inspector focus guard * test: keep foreign web content Return out of browser focus mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: use structural ownership for browser Return routing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../CmuxBrowser/WebView/CmuxWebView.swift | 3 +- .../WebView/NSResponder+WebInspector.swift | 49 ++++++ Sources/App/ShortcutRoutingSupport.swift | 7 +- Sources/App/WindowKeyDownReplayGuard.swift | 23 ++- Sources/AppDelegate.swift | 20 ++- ...rDeveloperToolsDockControlNormalizer.swift | 1 + Sources/Panels/BrowserPanel.swift | 21 --- .../BrowserPanelDeveloperToolsLifecycle.swift | 1 + cmux.xcodeproj/project.pbxproj | 4 + .../AppDelegateShortcutRoutingTests.swift | 85 +++++++++++ cmuxTests/BrowserConfigTests.swift | 140 +++++------------- .../CmuxWebViewKeyDownReentryTests.swift | 88 +++++++++++ .../BrowserFixtures/return-submit.html | 28 ++++ .../BrowserReturnSubmissionUITests.swift | 24 +++ 14 files changed, 354 insertions(+), 140 deletions(-) create mode 100644 cmuxUITests/BrowserFixtures/return-submit.html create mode 100644 cmuxUITests/BrowserReturnSubmissionUITests.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/CmuxWebView.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/CmuxWebView.swift index 3daf91569db3..da4feac922bb 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/CmuxWebView.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/CmuxWebView.swift @@ -811,7 +811,8 @@ public final class CmuxWebView: CmuxUndoableWebView { } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - if let decision = host?.handleBrowserFocusModeKeyEvent( + if !cmuxResponderBelongsToOtherWebContent(window?.firstResponder), + let decision = host?.handleBrowserFocusModeKeyEvent( event, webView: self, source: "web.performKeyEquivalent" diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/NSResponder+WebInspector.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/NSResponder+WebInspector.swift index 37cab194d4e9..a17394c4f48f 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/NSResponder+WebInspector.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/NSResponder+WebInspector.swift @@ -1,4 +1,5 @@ public import AppKit +public import WebKit extension String { /// Whether this Objective-C or Swift class name belongs to WebKit's Web Inspector. @@ -36,6 +37,54 @@ extension NSResponder { } } +extension WKWebView { + /// WebKit's private `_WKInspector` for this web view, when available. + public func cmuxInspectorObject() -> NSObject? { + let selector = NSSelectorFromString("_inspector") + guard responds(to: selector), + let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else { + return nil + } + return inspector + } + + /// The web view that renders this web view's Web Inspector frontend. + public func cmuxInspectorFrontendWebView() -> WKWebView? { + guard let inspector = cmuxInspectorObject() else { return nil } + let selector = NSSelectorFromString("inspectorWebView") + guard inspector.responds(to: selector), + let inspectorWebView = inspector.perform(selector)?.takeUnretainedValue() as? WKWebView else { + return nil + } + return inspectorWebView + } + + /// Whether `responder` belongs to web content other than this page: this + /// page's Web Inspector frontend, any Web Inspector view, or a view inside + /// another web view. Page key routing must leave such responders alone. + /// + /// The inspector frontend is itself a web view, so the ancestor walk fails + /// closed for it even when WebKit's class names change. + public func cmuxResponderBelongsToOtherWebContent(_ responder: NSResponder?) -> Bool { + guard let responder, responder !== self else { return false } + if responder.cmuxIsInsideWebInspector { + return true + } + guard let view = responder as? NSView else { return false } + if let frontendWebView = cmuxInspectorFrontendWebView(), + view === frontendWebView || view.isDescendant(of: frontendWebView) { + return true + } + var node: NSView? = view + while let current = node { + if current === self { return false } + if current is WKWebView { return true } + node = current.superview + } + return false + } +} + extension NSEvent { /// Whether this command equivalent should go straight to the main menu. /// diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 53e315a8bfda..b840f5aa9398 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -310,9 +310,10 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown( guard firstResponderIsBrowser else { return false } guard !firstResponderHasMarkedText else { return false } guard keyCode == 36 || keyCode == 76 else { return false } - // Keep browser Return forwarding narrow: only plain/Shift Return is submit; - // Command-modified Return is reserved for app shortcuts like Toggle Pane Zoom. - return browserOmnibarShouldSubmitOnReturn(flags: flags) + // Unclaimed Cmd+Return also belongs to the page. Keep Cmd+Shift+Return + // (pane zoom) and other modified shortcuts on the app's normal route. + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags.isEmpty || normalizedFlags == [.shift] || normalizedFlags == [.command] } func shouldDispatchBrowserArrowViaFirstResponderKeyDown( diff --git a/Sources/App/WindowKeyDownReplayGuard.swift b/Sources/App/WindowKeyDownReplayGuard.swift index 7cb0a9f6f8bb..0a075797e83f 100644 --- a/Sources/App/WindowKeyDownReplayGuard.swift +++ b/Sources/App/WindowKeyDownReplayGuard.swift @@ -35,6 +35,14 @@ private struct CmuxForceDispatchedKeyEventIdentity: Hashable { let keyCode: UInt16 let modifierFlags: UInt let timestamp: TimeInterval + + init(_ event: NSEvent, windowNumber: Int) { + self.windowNumber = windowNumber + eventType = event.type.rawValue + keyCode = event.keyCode + modifierFlags = event.modifierFlags.rawValue + timestamp = event.timestamp + } } /// Events whose force-dispatch is currently on the main-thread stack. @@ -57,6 +65,13 @@ extension NSApplication { } extension NSWindow { + /// Identifies synchronous replay of the exact key this window is forwarding. + func cmuxIsForceDispatchingKeyDown(_ event: NSEvent) -> Bool { + cmuxInFlightForceDispatchedKeyEventIdentities.contains( + CmuxForceDispatchedKeyEventIdentity(event, windowNumber: windowNumber) + ) + } + /// Routes an application undo or redo command through the focused cmux /// surface, keeping AppKit's shared window undo stack out of the path. func cmuxRouteApplicationUndoRedoCommandEquivalent(_ event: NSEvent) -> Bool { @@ -156,13 +171,7 @@ extension NSWindow { to target: NSResponder, reason: @autoclosure () -> String ) -> Bool { - let identity = CmuxForceDispatchedKeyEventIdentity( - windowNumber: self.windowNumber, - eventType: event.type.rawValue, - keyCode: event.keyCode, - modifierFlags: event.modifierFlags.rawValue, - timestamp: event.timestamp - ) + let identity = CmuxForceDispatchedKeyEventIdentity(event, windowNumber: windowNumber) guard !cmuxInFlightForceDispatchedKeyEventIdentities.contains(identity) else { #if DEBUG cmuxDebugLog(" → \(reason()) reentry; declining force-dispatch of in-flight key event") diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index dd036b5a18bd..ae1fefe64976 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -20148,6 +20148,10 @@ private extension NSWindow { let firstResponderWebView = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0, in: self, event: event) } + // Web Inspector and other web views' responders are not page content. + let firstResponderIsPageContent = firstResponderWebView.map { + !$0.cmuxResponderBelongsToOtherWebContent(self.firstResponder) + } ?? false let firstResponderHasMarkedText = shortcutResponderHasMarkedText(self.firstResponder) let firstResponderIsCommandPaletteFieldEditor = Self.cmuxCommandPaletteOwnsFieldEditor( self.firstResponder as? NSTextView, @@ -20385,15 +20389,24 @@ private extension NSWindow { return true } - // Web forms rely on Return/Enter flowing through keyDown. Route it directly to the first responder. + // WebKit's focused responder can be an internal view or field editor. + // The owning web view must receive Return so its native form handling runs. if shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: event.keyCode, - firstResponderIsBrowser: firstResponderWebView != nil, + firstResponderIsBrowser: firstResponderIsPageContent, firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { + // Consume only our exact synchronous replay, preventing the alert beep + // without swallowing a different event or redispatching into WebKit. + if cmuxIsForceDispatchingKeyDown(event) { return true } if browserWebKitKeyDownReentry { return false } - guard let target = self.firstResponder else { return false } + guard let target = firstResponderWebView else { return false } + if event.modifierFlags.contains(.command), + AppDelegate.shared?.isBrowserFocusModeActive(for: target) != true, + AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { + return true + } if cmuxForceDispatchKeyDownOnce(event, to: target, reason: "browser Return/Enter") { return true } @@ -20458,6 +20471,7 @@ private extension NSWindow { } if let firstResponderWebView, + firstResponderIsPageContent, AppDelegate.shared?.isBrowserFocusModeActive(for: firstResponderWebView) == true { let handled = firstResponderWebView.performKeyEquivalent(with: event) #if DEBUG diff --git a/Sources/Panels/BrowserDeveloperToolsDockControlNormalizer.swift b/Sources/Panels/BrowserDeveloperToolsDockControlNormalizer.swift index ad45e0293df4..71e13ea68f94 100644 --- a/Sources/Panels/BrowserDeveloperToolsDockControlNormalizer.swift +++ b/Sources/Panels/BrowserDeveloperToolsDockControlNormalizer.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxBrowser import WebKit @MainActor diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 0e75d68c7b25..165fccade39f 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8605,27 +8605,6 @@ extension BrowserPanel { } } -extension WKWebView { - func cmuxInspectorObject() -> NSObject? { - let selector = NSSelectorFromString("_inspector") - guard responds(to: selector), - let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else { - return nil - } - return inspector - } - - func cmuxInspectorFrontendWebView() -> WKWebView? { - guard let inspector = cmuxInspectorObject() else { return nil } - let selector = NSSelectorFromString("inspectorWebView") - guard inspector.responds(to: selector), - let inspectorWebView = inspector.perform(selector)?.takeUnretainedValue() as? WKWebView else { - return nil - } - return inspectorWebView - } -} - @MainActor enum WebViewInspectorTeardown { @discardableResult diff --git a/Sources/Panels/BrowserPanelDeveloperToolsLifecycle.swift b/Sources/Panels/BrowserPanelDeveloperToolsLifecycle.swift index 53c81920ced2..5c5adc65b7d8 100644 --- a/Sources/Panels/BrowserPanelDeveloperToolsLifecycle.swift +++ b/Sources/Panels/BrowserPanelDeveloperToolsLifecycle.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxBrowser @MainActor extension BrowserPanel { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 04dc1577e11d..46b418dd3058 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -570,6 +570,7 @@ 4FAE5FD3871D480E4A030434 /* BrowserReplRenderHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */; }; 1E6BCCFDEF21C4FC646852E8 /* BrowserReplResourceLoadObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEFE6D4B189E472E1F7FDE8 /* BrowserReplResourceLoadObserver.swift */; }; F4E24D134C1F867CD04C29E8 /* BrowserReplTabAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8CED7FEDE9FA8F3A85F5D4 /* BrowserReplTabAttachment.swift */; }; + D5F4120F22CDD7AFA015F7BB /* BrowserReturnSubmissionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A074689235BE614781DC6FA /* BrowserReturnSubmissionUITests.swift */; }; 4472A0014472A0014472A001 /* BrowserScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0014472B0014472B001 /* BrowserScreenshot.swift */; }; A92100010000000000000009 /* BrowserScreenshotAnimationFrameWaiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9210001000000000000000A /* BrowserScreenshotAnimationFrameWaiter.swift */; }; A92100010000000000000003 /* BrowserScreenshotCaptureService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92100010000000000000004 /* BrowserScreenshotCaptureService.swift */; }; @@ -5272,6 +5273,7 @@ 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserReplRenderHostTests.swift"; sourceTree = "<group>"; }; FDEFE6D4B189E472E1F7FDE8 /* BrowserReplResourceLoadObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplResourceLoadObserver.swift; sourceTree = "<group>"; }; 6A8CED7FEDE9FA8F3A85F5D4 /* BrowserReplTabAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplTabAttachment.swift; sourceTree = "<group>"; }; + 5A074689235BE614781DC6FA /* BrowserReturnSubmissionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReturnSubmissionUITests.swift; sourceTree = "<group>"; }; 4472B0014472B0014472B001 /* BrowserScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshot.swift; sourceTree = "<group>"; }; A9210001000000000000000A /* BrowserScreenshotAnimationFrameWaiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotAnimationFrameWaiter.swift; sourceTree = "<group>"; }; A92100010000000000000004 /* BrowserScreenshotCaptureService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotCaptureService.swift; sourceTree = "<group>"; }; @@ -9553,6 +9555,7 @@ 83418958EF0A36762FBB8A36 /* SettingsComputersBehaviorUITests.swift */, 94600B483EE4B6BC3F7506DA /* SidebarCompactAgentStatusUITests.swift */, A45FD277385970AA8FA5508D /* DeviceDiscoverabilityUITests.swift */, + 5A074689235BE614781DC6FA /* BrowserReturnSubmissionUITests.swift */, ); path = cmuxUITests; sourceTree = "<group>"; @@ -17663,6 +17666,7 @@ D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, 8478A0000000000000000002 /* BrowserRecoveryHTTPServer.swift in Sources */, 7B5F1A2E9C0D4B6A8E217304 /* BrowserReliabilityRegressionUITests.swift in Sources */, + D5F4120F22CDD7AFA015F7BB /* BrowserReturnSubmissionUITests.swift in Sources */, B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index ac451fb0f8ac..407dc5d24c81 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -5,6 +5,7 @@ import AppKit import Carbon.HIToolbox import Combine import SwiftUI +import WebKit @testable import CmuxSettingsUI #if canImport(cmux_DEV) @@ -12518,6 +12519,90 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(harness.panel.isBrowserFocusModeActive) } + func testBrowserFocusModeReturnDoesNotRouteWebInspectorResponderToPage() { + guard let harness = makeBrowserFocusModeHarness() else { return } + defer { closeWindow(withId: harness.windowId) } + + installCmuxUnitTestWKWebViewPerformKeyEquivalentOverride() + var forwardedEvents: [NSEvent] = [] + cmuxUnitTestWKWebViewPerformKeyEquivalentHook = { currentWebView, event in + guard currentWebView === harness.webView else { return nil } + forwardedEvents.append(event) + return true + } + defer { cmuxUnitTestWKWebViewPerformKeyEquivalentHook = nil } + + XCTAssertTrue( + harness.panel.setBrowserFocusModeActive(true, reason: "unit.inspectorReturn", focusWebView: false) + ) + let inspectorView = FakeWKInspectorContainerView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) + inspectorView.addSubview(FocusableTestView(frame: inspectorView.bounds)) + harness.webView.addSubview(inspectorView) + XCTAssertTrue(harness.window.makeFirstResponder(inspectorView.subviews[0])) + + guard let event = makeKeyDownEvent( + key: "\r", + modifiers: [.command], + keyCode: 36, + windowNumber: harness.window.windowNumber + ) else { + XCTFail("Failed to construct Return event") + return + } + + _ = harness.window.performKeyEquivalent(with: event) + + XCTAssertTrue(forwardedEvents.isEmpty) + XCTAssertTrue(harness.window.firstResponder === inspectorView.subviews[0]) + } + + func testBrowserFocusModeReturnDoesNotRouteForeignWebContentResponderToPage() { + guard let harness = makeBrowserFocusModeHarness() else { return } + defer { closeWindow(withId: harness.windowId) } + guard let pageSuperview = harness.webView.superview else { + XCTFail("Expected the page web view to be attached") + return + } + + installCmuxUnitTestWKWebViewPerformKeyEquivalentOverride() + var forwardedEvents: [NSEvent] = [] + cmuxUnitTestWKWebViewPerformKeyEquivalentHook = { currentWebView, event in + guard currentWebView === harness.webView else { return nil } + forwardedEvents.append(event) + return true + } + defer { cmuxUnitTestWKWebViewPerformKeyEquivalentHook = nil } + + XCTAssertTrue( + harness.panel.setBrowserFocusModeActive(true, reason: "unit.foreignWebContentReturn", focusWebView: false) + ) + // WebKit docks the inspector frontend as a sibling web view whose class + // names do not have to match the inspector heuristic. + let foreignWebView = WKWebView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) + let nestedContainer = NSView(frame: foreignWebView.bounds) + let nestedResponder = FocusableTestView(frame: nestedContainer.bounds) + nestedContainer.addSubview(nestedResponder) + foreignWebView.addSubview(nestedContainer) + pageSuperview.addSubview(foreignWebView) + defer { foreignWebView.removeFromSuperview() } + XCTAssertTrue(harness.window.makeFirstResponder(nestedResponder)) + + guard let event = makeKeyDownEvent( + key: "\r", + modifiers: [.command], + keyCode: 36, + windowNumber: harness.window.windowNumber + ) else { + XCTFail("Failed to construct Return event") + return + } + + _ = harness.window.performKeyEquivalent(with: event) + + XCTAssertTrue(forwardedEvents.isEmpty) + XCTAssertTrue(harness.window.firstResponder === nestedResponder) + } + func testShowNotificationsShortcutYieldsToFocusedBrowserPane() { // With a browser pane focused, app shortcut routing must yield Cmd+I (a // browser document-editing command) so the keystroke reaches the focused diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 80d5b5c200b9..bde4c9ffceb1 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -5009,117 +5009,47 @@ final class BrowserInputEventPerformanceTests: XCTestCase { } -final class BrowserReturnKeyDownRoutingTests: XCTestCase { - func testRoutesForReturnWhenBrowserFirstResponder() { - XCTAssertTrue( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [] - ) - ) - } - - func testRoutesForKeypadEnterWhenBrowserFirstResponder() { - XCTAssertTrue( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 76, - firstResponderIsBrowser: true, - flags: [] - ) - ) - } - - func testDoesNotRouteForNonEnterKey() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 13, - firstResponderIsBrowser: true, - flags: [] - ) - ) - } - - func testDoesNotRouteWhenFirstResponderIsNotBrowser() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: false, - flags: [] - ) - ) - } - - func testDoesNotRouteReturnWhenBrowserFirstResponderHasMarkedText() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - firstResponderHasMarkedText: true, - flags: [] - ) - ) - } - - func testDoesNotRouteKeypadEnterWhenBrowserFirstResponderHasMarkedText() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 76, - firstResponderIsBrowser: true, - firstResponderHasMarkedText: true, - flags: [] - ) - ) - } - - func testRoutesForShiftReturnWhenBrowserFirstResponder() { - XCTAssertTrue( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.shift] - ) - ) - } - - func testDoesNotRouteForCommandShiftReturnWhenBrowserFirstResponder() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.command, .shift] - ) - ) +@Suite +struct BrowserReturnKeyDownRoutingTests { + @Test(arguments: [UInt16(36), 76], [NSEvent.ModifierFlags(), .shift, .command, .capsLock]) + func routesReturnToBrowser(keyCode: UInt16, flags: NSEvent.ModifierFlags) { + #expect(shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: keyCode, + firstResponderIsBrowser: true, + flags: flags + )) } - func testDoesNotRouteForCommandReturnWhenBrowserFirstResponder() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.command] - ) - ) + @Test(arguments: [NSEvent.ModifierFlags.command.union(.shift), .option, .control]) + func leavesOtherModifiedReturnOnNormalRoute(flags: NSEvent.ModifierFlags) { + #expect(!shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: flags + )) } - func testDoesNotRouteForOptionReturnWhenBrowserFirstResponder() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.option] - ) - ) + @Test(arguments: [UInt16(36), 76], [NSEvent.ModifierFlags(), .shift, .command]) + func leavesMarkedTextToIME(keyCode: UInt16, flags: NSEvent.ModifierFlags) { + #expect(!shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: keyCode, + firstResponderIsBrowser: true, + firstResponderHasMarkedText: true, + flags: flags + )) } - func testDoesNotRouteForControlReturnWhenBrowserFirstResponder() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.control] - ) - ) + @Test func ignoresOtherKeysAndResponders() { + #expect(!shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 13, + firstResponderIsBrowser: true, + flags: [] + )) + #expect(!shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: false, + flags: [] + )) } } diff --git a/cmuxTests/CmuxWebViewKeyDownReentryTests.swift b/cmuxTests/CmuxWebViewKeyDownReentryTests.swift index 5bb58576c308..847dc2132b0d 100644 --- a/cmuxTests/CmuxWebViewKeyDownReentryTests.swift +++ b/cmuxTests/CmuxWebViewKeyDownReentryTests.swift @@ -18,6 +18,16 @@ private final class FakeWKInspectorUndoResponderView: NSView { override var acceptsFirstResponder: Bool { true } } +private final class BrowserReturnContentResponder: NSView { + private(set) var receivedKeyCodes: [UInt16] = [] + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + receivedKeyCodes.append(event.keyCode) + } +} + private final class BrowserUndoMenuActionSpy: NSObject { private(set) var invoked = false @@ -69,6 +79,84 @@ private func uninstallCmuxUnitTestCmuxWebViewKeyDownOverride() { @Suite(.serialized) final class CmuxWebViewKeyDownReentryTests { + @Test(arguments: [UInt16(36), 76], [NSEvent.ModifierFlags(), .shift, .command]) + @MainActor + func browserReturnFromContentResponderReachesOwningWebView( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags + ) throws { + try withHookedBrowserKeyDownWindow { window, keyDownEvents in + let webView = try #require(window.firstResponder as? CmuxWebView) + let contentResponder = BrowserReturnContentResponder(frame: webView.bounds) + webView.addSubview(contentResponder) + #expect(window.makeFirstResponder(contentResponder)) + let event = try #require(makeKeyDownEvent( + key: "\r", + modifiers: modifiers, + keyCode: keyCode, + windowNumber: window.windowNumber + )) + + #expect(window.performKeyEquivalent(with: event)) + #expect(keyDownEvents().map(\.keyCode) == [keyCode]) + #expect(contentResponder.receivedKeyCodes.isEmpty) + #expect(window.firstResponder === contentResponder) + } + } + + @Test + @MainActor + func browserReturnDoesNotRouteToPageWhenWebInspectorResponderIsFocused() throws { + try withHookedBrowserKeyDownWindow { window, keyDownEvents in + let webView = try #require(window.firstResponder as? CmuxWebView) + let inspectorView = FakeWKInspectorUndoResponderView( + frame: NSRect(x: 0, y: 0, width: 32, height: 20) + ) + webView.addSubview(inspectorView) + #expect(window.makeFirstResponder(inspectorView)) + + let event = try #require(makeKeyDownEvent( + key: "\r", + modifiers: [], + keyCode: 36, + windowNumber: window.windowNumber + )) + + _ = window.performKeyEquivalent(with: event) + + #expect(keyDownEvents().isEmpty) + #expect(window.firstResponder === inspectorView) + } + } + + @Test + @MainActor + func browserReturnConsumesOnlyItsOwnSynchronousReplay() throws { + try withHookedBrowserKeyDownWindow { window, _ in + let webView = try #require(window.firstResponder as? CmuxWebView) + let event = try #require(makeKeyDownEvent( + key: "\r", + modifiers: [], + keyCode: 36, + windowNumber: window.windowNumber + )) + var deliveries = 0 + var replayHandled = false + cmuxUnitTestCmuxWebViewKeyDownHook = { currentWebView, forwardedEvent in + guard currentWebView === webView else { return false } + deliveries += 1 + replayHandled = webView.withBrowserWebKitKeyDownDispatch { + window.performKeyEquivalent(with: forwardedEvent) + } + return true + } + + #expect(window.performKeyEquivalent(with: event)) + #expect(deliveries == 1) + #expect(replayHandled) + } + } + @Test @MainActor func printableOptionTextRoutesToBrowserKeyDownOnce() throws { diff --git a/cmuxUITests/BrowserFixtures/return-submit.html b/cmuxUITests/BrowserFixtures/return-submit.html new file mode 100644 index 000000000000..023033eabe95 --- /dev/null +++ b/cmuxUITests/BrowserFixtures/return-submit.html @@ -0,0 +1,28 @@ +<!doctype html> +<html lang="en"> +<meta charset="utf-8"> +<title>Return submission fixture +
+ + + +
+

Submissions: 0

+

Trusted Return: 0

+

Trusted Command Return: 0

+ + diff --git a/cmuxUITests/BrowserReturnSubmissionUITests.swift b/cmuxUITests/BrowserReturnSubmissionUITests.swift new file mode 100644 index 000000000000..1ad30a7638b2 --- /dev/null +++ b/cmuxUITests/BrowserReturnSubmissionUITests.swift @@ -0,0 +1,24 @@ +import XCTest + +/// Native keys must traverse the app/window routing before reaching WebKit. +/// Socket browser.press bypasses that routing and cannot cover this regression. +final class BrowserReturnSubmissionUITests: BrowserFixtureSocketTestCase { + func testReturnSubmitsFormAndCommandReturnReachesPage() throws { + let app = try launchApp() + let surfaceID = try openFixture("return-submit") + let field = app.webViews.textFields["Account name"].firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 15)) + field.click() + app.typeText("browser-regression") + XCTAssertEqual(try evalString("document.activeElement.id", surfaceID: surfaceID), "account") + XCTAssertEqual(try evalString("document.getElementById('account').value", surfaceID: surfaceID), "browser-regression") + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + XCTAssertTrue(app.webViews.staticTexts["Submissions: 1"].firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue(app.webViews.staticTexts["Trusted Return: 1"].firstMatch.waitForExistence(timeout: 5)) + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command]) + XCTAssertTrue(app.webViews.staticTexts["Trusted Command Return: 1"].firstMatch.waitForExistence(timeout: 5)) + XCTAssertEqual(try evalString("document.activeElement.id", surfaceID: surfaceID), "account") + } +} From d1e23b13c25913ea50efc0bce95d4ff89c32984e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:25:15 -0700 Subject: [PATCH 026/183] Don't restore a Claude session that never wrote a transcript (#20498) * test: don't save or restore a Claude session that never wrote a transcript Claude Code writes its transcript only after the first message. A tab whose Claude was started and quit before any prompt keeps its agent-hook binding, so relaunch types 'cmux restore claude ' and Claude fails with 'No conversation found'. Expected red until the fix. Co-Authored-By: Claude Opus 5.5 * fix: don't save or restore a Claude session that never wrote a transcript Claude Code writes /projects//.jsonl only after the first message. A session started and quit before any prompt kept its agent-hook binding, so relaunch typed 'cmux restore claude ' and Claude failed with 'No conversation found'. - The agent index records Claude sessions whose hook record names where the transcript goes (transcript_path or CLAUDE_CONFIG_DIR) but finds none in any config root, with the paths to re-check. - Workspace and Dock session snapshots drop that session's Claude agent and agent-hook bindings after the snapshot is built, re-checking the paths first so a first prompt sent after the load still restores. An older session on the surface is never promoted in its place. A cached index marked incomplete carries no such evidence. - cmux restore claude checks every config root the record and shell name and fails early with a localized message, retiring only the binding for that checkpoint, instead of launching Claude. Co-Authored-By: Claude Opus 5.5 * fix: address review of transcript-less Claude restore - cmux restore claude checks only when the session names its transcript location (the hook-store transcript_path or a captured CLAUDE_CONFIG_DIR) and accepts the reported transcript directly, so a session in a root the lookup cannot see is never refused or unbound. - A cached index marked incomplete keeps the transcript-less evidence. The filter re-checks the expected paths, so update relaunch and the quit deadline fallback drop the empty session without discarding one whose first prompt landed later. - Closed-tab history for workspace and Dock panels applies the filter. - Neutral error wording, since a live session elsewhere has no transcript yet either. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CLI/CMUXCLI+Restore.swift | 95 +++++++ .../ClaudeResumeTranscriptLocator.swift | 98 +++++++ .../ClaudeResumeTranscriptLocatorTests.swift | 98 +++++++ Resources/Localizable.xcstrings | 197 +++++++++----- Sources/DockSplitStore+SessionSnapshot.swift | 9 +- Sources/RestorableAgentSession.swift | 90 ++++++- ...sionIndex+ClaudeTranscriptlessResume.swift | 75 ++++++ Sources/Workspace.swift | 9 +- cmux.xcodeproj/project.pbxproj | 8 + .../ClaudeTranscriptlessRestoreTests.swift | 255 ++++++++++++++++++ 10 files changed, 857 insertions(+), 77 deletions(-) create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeResumeTranscriptLocator.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeResumeTranscriptLocatorTests.swift create mode 100644 Sources/RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift create mode 100644 cmuxTests/ClaudeTranscriptlessRestoreTests.swift diff --git a/CLI/CMUXCLI+Restore.swift b/CLI/CMUXCLI+Restore.swift index 477ee0e39dc5..f7213ba40449 100644 --- a/CLI/CMUXCLI+Restore.swift +++ b/CLI/CMUXCLI+Restore.swift @@ -108,6 +108,20 @@ extension CMUXCLI { } } + if claudeRestoreTranscriptIsMissing( + record: record, + processEnvironment: processEnvironment + ) { + try handleMissingClaudeRestoreTranscript( + record: record, + bindingPayload: bindingPayload, + surfaceID: surfaceID, + workspaceID: payload["workspace_id"] as? String + ?? processEnvironment["CMUX_WORKSPACE_ID"], + client: client + ) + } + let requestedWorkingDirectory = requestedRestoreWorkingDirectory(for: record) let appliedWorkingDirectory = try applyRestoreWorkingDirectory( requestedWorkingDirectory @@ -192,6 +206,87 @@ extension CMUXCLI { ) } + /// Whether a Claude resume record names a session Claude never wrote a + /// transcript for. Claude writes it only after the first message, so a + /// session quit before any prompt fails with "No conversation found". + /// + /// The check runs only when the session says where its transcript lives: + /// the `transcript_path` Claude reported to the hook store, or a captured + /// `CLAUDE_CONFIG_DIR`. Without either, Claude may write to a root this + /// lookup cannot see, so the restore proceeds unchanged. + func claudeRestoreTranscriptIsMissing( + record: RestoreRecord, + processEnvironment: [String: String] + ) -> Bool { + guard record.mode == AgentRestoreRequestMode.resumeAgent.rawValue, + record.kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "claude", + let sessionID = normalizedHookValue(record.checkpointID) else { + return false + } + let hookRecord = try? ClaudeHookSessionStore(processEnv: processEnvironment).lookup( + sessionId: sessionID, + deadline: Date().addingTimeInterval(1) + ) + let reportedTranscriptPath = normalizedHookValue(hookRecord?.transcriptPath) + .map { ($0 as NSString).expandingTildeInPath } + let capturedConfigDirectories = [ + record.environment["CLAUDE_CONFIG_DIR"], + record.launchCommand?.environment?["CLAUDE_CONFIG_DIR"], + hookRecord?.launchCommand?.environment?["CLAUDE_CONFIG_DIR"], + ].compactMap { normalizedHookValue($0) } + guard reportedTranscriptPath != nil || !capturedConfigDirectories.isEmpty else { + return false + } + if let reportedTranscriptPath, + let size = (try? FileManager.default.attributesOfItem(atPath: reportedTranscriptPath))?[.size] as? NSNumber, + size.intValue > 0 { + return false + } + let configuredDirectories = capturedConfigDirectories + + [processEnvironment["CLAUDE_CONFIG_DIR"]].compactMap { normalizedHookValue($0) } + return !ClaudeResumeTranscriptLocator().transcriptExists( + sessionId: sessionID, + configuredDirectories: configuredDirectories + ) + } + + /// Leaves the shell in the saved directory, retires the binding that named + /// the empty session, and fails before anything is launched. + private func handleMissingClaudeRestoreTranscript( + record: RestoreRecord, + bindingPayload: [String: Any]?, + surfaceID: String, + workspaceID: String?, + client: SocketClient + ) throws { + _ = try? applyRestoreWorkingDirectory(requestedRestoreWorkingDirectory(for: record)) + let bindingCheckpointID = normalizedHookValue( + (bindingPayload?["checkpoint_id"] as? String) + ?? (bindingPayload?["checkpointId"] as? String) + ) + if normalizedHookValue(bindingPayload?["source"] as? String)?.lowercased() == "agent-hook", + normalizedHookValue(bindingPayload?["kind"] as? String)?.lowercased() == "claude", + let checkpointID = normalizedHookValue(record.checkpointID), + bindingCheckpointID == checkpointID { + // Guarded by checkpoint: a session started since keeps its binding. + _ = clearAgentSurfaceResumeBindingOutcome( + client: client, + workspaceId: workspaceID ?? "", + surfaceId: surfaceID, + sessionId: checkpointID, + updatedAt: (bindingPayload?["updated_at"] as? NSNumber)?.doubleValue, + sessionDidEnd: true + ) + } + throw loggedRestoreError( + stage: "claude.restore.transcript-missing", + message: String( + localized: "cli.restore.error.conversationNotFound", + defaultValue: "restore: this session has no saved conversation to resume. Start the agent again in this terminal." + ) + ) + } + func currentRestoreSurfaceID( client: SocketClient, processEnvironment: [String: String] diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeResumeTranscriptLocator.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeResumeTranscriptLocator.swift new file mode 100644 index 000000000000..5e143f23e8ee --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeResumeTranscriptLocator.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Finds the transcript Claude Code needs to resume a session. +/// +/// Claude writes `/projects//.jsonl` only after the first +/// message, so a session that was quit before any prompt has nothing to +/// resume and `claude --resume ` fails with "No conversation found". +public struct ClaudeResumeTranscriptLocator: Sendable { + private let homeDirectory: String + + /// Creates a locator that resolves the default config roots under `homeDirectory`. + public init(homeDirectory: String = NSHomeDirectory()) { + self.homeDirectory = homeDirectory + } + + /// Returns the config roots to search: each configured `CLAUDE_CONFIG_DIR` + /// first, then the cmux account roots, `~/.claude`, and the legacy + /// Subrouter root. Duplicates are removed. + public func configRoots(configuredDirectories: [String]) -> [String] { + var roots: [String] = [] + var seen: Set = [] + func appendRoot(_ path: String) { + let standardized = ((path as NSString).expandingTildeInPath as NSString).standardizingPath + guard seen.insert(standardized).inserted else { return } + roots.append(standardized) + } + + for directory in configuredDirectories { + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + appendRoot(ClaudeConfigDirectoryPath.preferredPath(trimmed, homeDirectory: homeDirectory)) + } + let accountRoot = (homeDirectory as NSString).appendingPathComponent(".codex-accounts/claude") + if directoryExists(atPath: accountRoot), + let accountDirectories = try? FileManager.default.contentsOfDirectory(atPath: accountRoot) { + for accountDirectory in accountDirectories.sorted() { + appendRoot((accountRoot as NSString).appendingPathComponent(accountDirectory)) + } + } + appendRoot((homeDirectory as NSString).appendingPathComponent(".claude")) + appendRoot( + ClaudeConfigDirectoryPath.preferredPath( + (homeDirectory as NSString).appendingPathComponent(".subrouter/codex/claude"), + homeDirectory: homeDirectory + ) + ) + return roots + } + + /// Whether a non-empty transcript for `sessionId` exists in any project + /// under ``configRoots(configuredDirectories:)``. + public func transcriptExists(sessionId: String, configuredDirectories: [String]) -> Bool { + let sessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sessionId.isEmpty, + sessionId != ".", + sessionId != "..", + !sessionId.contains("/"), + !sessionId.contains("\\") else { + return false + } + for root in configRoots(configuredDirectories: configuredDirectories) { + let projectsRoot = (root as NSString).appendingPathComponent("projects") + guard directoryExists(atPath: projectsRoot), + let projectDirectories = try? FileManager.default.contentsOfDirectory(atPath: projectsRoot) else { + continue + } + for projectDirectory in projectDirectories { + let projectRoot = (projectsRoot as NSString).appendingPathComponent(projectDirectory) + let directPath = (projectRoot as NSString).appendingPathComponent("\(sessionId).jsonl") + let nestedMessagesPath = (((projectRoot as NSString) + .appendingPathComponent(sessionId) as NSString) + .appendingPathComponent("messages") as NSString) + .appendingPathComponent("\(sessionId).jsonl") + if regularNonEmptyFileExists(atPath: directPath) + || regularNonEmptyFileExists(atPath: nestedMessagesPath) { + return true + } + } + } + return false + } + + private func regularNonEmptyFileExists(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), + !isDirectory.boolValue, + let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let size = attributes[.size] as? NSNumber else { + return false + } + return size.intValue > 0 + } + + private func directoryExists(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeResumeTranscriptLocatorTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeResumeTranscriptLocatorTests.swift new file mode 100644 index 000000000000..6edd6fbb35a1 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeResumeTranscriptLocatorTests.swift @@ -0,0 +1,98 @@ +import CMUXAgentLaunch +import Foundation +import Testing + +@Suite("Claude resume transcript lookup") +struct ClaudeResumeTranscriptLocatorTests { + private let sessionID = "8b1f3c2e-5d4a-4e6f-9a7b-0c1d2e3f4a5b" + + private func makeHome() throws -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-transcript-locator-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + return home + } + + private func writeTranscript( + configRoot: URL, + project: String = "-tmp-repo", + nested: Bool = false, + contents: String = "{\"type\":\"user\"}\n" + ) throws { + var directory = configRoot + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent(project, isDirectory: true) + if nested { + directory = directory + .appendingPathComponent(sessionID, isDirectory: true) + .appendingPathComponent("messages", isDirectory: true) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try contents.write( + to: directory.appendingPathComponent("\(sessionID).jsonl"), + atomically: true, + encoding: .utf8 + ) + } + + @Test("A session started but never prompted has no transcript") + func missingTranscript() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(at: home) } + let configRoot = home.appendingPathComponent("proxy-home", isDirectory: true) + try FileManager.default.createDirectory( + at: configRoot.appendingPathComponent("projects/-tmp-repo", isDirectory: true), + withIntermediateDirectories: true + ) + + let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) + #expect(!locator.transcriptExists(sessionId: sessionID, configuredDirectories: [configRoot.path])) + } + + @Test("An empty transcript file is not resumable") + func emptyTranscript() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(at: home) } + try writeTranscript(configRoot: home.appendingPathComponent(".claude"), contents: "") + + let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) + #expect(!locator.transcriptExists(sessionId: sessionID, configuredDirectories: [])) + } + + @Test("A transcript under the configured config directory is found") + func configuredDirectory() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(at: home) } + let configRoot = home.appendingPathComponent(".subrouter/codex/claude-proxy/abc", isDirectory: true) + try writeTranscript(configRoot: configRoot) + + let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) + #expect(locator.transcriptExists(sessionId: sessionID, configuredDirectories: [configRoot.path])) + #expect(!locator.transcriptExists(sessionId: sessionID, configuredDirectories: [])) + } + + @Test("Default roots cover ~/.claude, cmux accounts, and nested message transcripts") + func defaultRoots() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(at: home) } + let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) + + try writeTranscript(configRoot: home.appendingPathComponent(".codex-accounts/claude/work"), nested: true) + #expect(locator.transcriptExists(sessionId: sessionID, configuredDirectories: [])) + try FileManager.default.removeItem(at: home.appendingPathComponent(".codex-accounts")) + + try writeTranscript(configRoot: home.appendingPathComponent(".claude")) + #expect(locator.transcriptExists(sessionId: sessionID, configuredDirectories: [])) + } + + @Test("Path-like session ids never match a transcript") + func unsafeSessionID() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(at: home) } + try writeTranscript(configRoot: home.appendingPathComponent(".claude")) + + let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) + #expect(!locator.transcriptExists(sessionId: "../\(sessionID)", configuredDirectories: [])) + #expect(!locator.transcriptExists(sessionId: "..", configuredDirectories: [])) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 5a875be8eb6a..f7531997e503 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -652319,120 +652319,61 @@ } } }, - "agentRestore.writerLock.changing": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Codex was still updating its session locks, so cmux could not confirm this conversation is free. Retry 'cmux restore --surface'." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "كان Codex لا يزال يحدّث أقفال الجلسات، لذلك لم يتمكن cmux من التأكد من أن هذه المحادثة غير مستخدمة. أعد تشغيل 'cmux restore --surface'." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Codex hat seine Sitzungssperren noch aktualisiert, daher konnte cmux nicht bestätigen, dass diese Unterhaltung frei ist. Führe 'cmux restore --surface' erneut aus." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Codex todavía estaba actualizando sus bloqueos de sesión, así que cmux no pudo confirmar que esta conversación esté libre. Vuelve a ejecutar 'cmux restore --surface'." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Codex mettait encore à jour ses verrous de session, cmux n'a donc pas pu confirmer que cette conversation est libre. Relancez 'cmux restore --surface'." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Codex がまだセッションロックを更新していたため、cmux はこの会話が使用中でないことを確認できませんでした。'cmux restore --surface' を再実行してください。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Codex가 아직 세션 잠금을 갱신하고 있어 cmux가 이 대화가 사용 중이 아닌지 확인할 수 없습니다. 'cmux restore --surface'를 다시 실행하세요." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Codex 仍在更新其会话锁,因此 cmux 无法确认此对话未被占用。请重新运行 'cmux restore --surface'。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Codex 仍在更新其工作階段鎖定,因此 cmux 無法確認此對話未被使用。請重新執行 'cmux restore --surface'。" - } - } - } - }, - "agentRestore.writerLock.unreadable": { + "cli.restore.error.conversationNotFound": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux could not read this conversation's Codex session lock, so it could not confirm the conversation is free. Retry 'cmux restore --surface'." + "value": "restore: this session has no saved conversation to resume. Start the agent again in this terminal." } }, "ar": { "stringUnit": { "state": "translated", - "value": "تعذّر على cmux قراءة قفل جلسة Codex لهذه المحادثة، لذلك لم يتمكن من التأكد من أنها غير مستخدمة. أعد تشغيل 'cmux restore --surface'." + "value": "restore: لا تحتوي هذه الجلسة على محادثة محفوظة لاستئنافها. ابدأ الوكيل مرة أخرى في هذه الطرفية." } }, "de": { "stringUnit": { "state": "translated", - "value": "cmux konnte die Codex-Sitzungssperre dieser Unterhaltung nicht lesen und daher nicht bestätigen, dass die Unterhaltung frei ist. Führe 'cmux restore --surface' erneut aus." + "value": "restore: Diese Sitzung hat keine gespeicherte Unterhaltung zum Fortsetzen. Starten Sie den Agenten in diesem Terminal neu." } }, "es": { "stringUnit": { "state": "translated", - "value": "cmux no pudo leer el bloqueo de sesión de Codex de esta conversación, así que no pudo confirmar que esté libre. Vuelve a ejecutar 'cmux restore --surface'." + "value": "restore: esta sesión no tiene ninguna conversación guardada que reanudar. Vuelva a iniciar el agente en esta terminal." } }, "fr": { "stringUnit": { "state": "translated", - "value": "cmux n'a pas pu lire le verrou de session Codex de cette conversation et n'a donc pas pu confirmer qu'elle est libre. Relancez 'cmux restore --surface'." + "value": "restore : cette session n'a aucune conversation enregistrée à reprendre. Redémarrez l'agent dans ce terminal." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux はこの会話の Codex セッションロックを読み取れなかったため、会話が使用中でないことを確認できませんでした。'cmux restore --surface' を再実行してください。" + "value": "restore: このセッションには再開できる保存済みの会話がありません。このターミナルでエージェントをもう一度起動してください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "cmux가 이 대화의 Codex 세션 잠금을 읽을 수 없어 대화가 사용 중이 아닌지 확인할 수 없습니다. 'cmux restore --surface'를 다시 실행하세요." + "value": "restore: 이 세션에는 재개할 저장된 대화가 없습니다. 이 터미널에서 에이전트를 다시 시작하십시오." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "cmux 无法读取此对话的 Codex 会话锁,因此无法确认此对话未被占用。请重新运行 'cmux restore --surface'。" + "value": "restore: 此会话没有可恢复的已保存对话。请在此终端中重新启动代理。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "cmux 無法讀取此對話的 Codex 工作階段鎖定,因此無法確認此對話未被使用。請重新執行 'cmux restore --surface'。" + "value": "restore:此工作階段沒有可繼續的已儲存對話。請在此終端機中重新啟動代理程式。" } } } @@ -653231,6 +653172,124 @@ } } } + }, + "agentRestore.writerLock.changing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex was still updating its session locks, so cmux could not confirm this conversation is free. Retry 'cmux restore --surface'." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كان Codex لا يزال يحدّث أقفال الجلسات، لذلك لم يتمكن cmux من التأكد من أن هذه المحادثة غير مستخدمة. أعد تشغيل 'cmux restore --surface'." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codex hat seine Sitzungssperren noch aktualisiert, daher konnte cmux nicht bestätigen, dass diese Unterhaltung frei ist. Führe 'cmux restore --surface' erneut aus." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Codex todavía estaba actualizando sus bloqueos de sesión, así que cmux no pudo confirmar que esta conversación esté libre. Vuelve a ejecutar 'cmux restore --surface'." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Codex mettait encore à jour ses verrous de session, cmux n'a donc pas pu confirmer que cette conversation est libre. Relancez 'cmux restore --surface'." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex がまだセッションロックを更新していたため、cmux はこの会話が使用中でないことを確認できませんでした。'cmux restore --surface' を再実行してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Codex가 아직 세션 잠금을 갱신하고 있어 cmux가 이 대화가 사용 중이 아닌지 확인할 수 없습니다. 'cmux restore --surface'를 다시 실행하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codex 仍在更新其会话锁,因此 cmux 无法确认此对话未被占用。请重新运行 'cmux restore --surface'。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codex 仍在更新其工作階段鎖定,因此 cmux 無法確認此對話未被使用。請重新執行 'cmux restore --surface'。" + } + } + } + }, + "agentRestore.writerLock.unreadable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not read this conversation's Codex session lock, so it could not confirm the conversation is free. Retry 'cmux restore --surface'." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على cmux قراءة قفل جلسة Codex لهذه المحادثة، لذلك لم يتمكن من التأكد من أنها غير مستخدمة. أعد تشغيل 'cmux restore --surface'." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte die Codex-Sitzungssperre dieser Unterhaltung nicht lesen und daher nicht bestätigen, dass die Unterhaltung frei ist. Führe 'cmux restore --surface' erneut aus." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo leer el bloqueo de sesión de Codex de esta conversación, así que no pudo confirmar que esté libre. Vuelve a ejecutar 'cmux restore --surface'." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n'a pas pu lire le verrou de session Codex de cette conversation et n'a donc pas pu confirmer qu'elle est libre. Relancez 'cmux restore --surface'." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はこの会話の Codex セッションロックを読み取れなかったため、会話が使用中でないことを確認できませんでした。'cmux restore --surface' を再実行してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 이 대화의 Codex 세션 잠금을 읽을 수 없어 대화가 사용 중이 아닌지 확인할 수 없습니다. 'cmux restore --surface'를 다시 실행하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法读取此对话的 Codex 会话锁,因此无法确认此对话未被占用。请重新运行 'cmux restore --surface'。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法讀取此對話的 Codex 工作階段鎖定,因此無法確認此對話未被使用。請重新執行 'cmux restore --surface'。" + } + } + } } }, "version": "1.0" diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index a32420e81887..2adf90a3fa74 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -47,7 +47,7 @@ extension DockSplitStore { panelIds: terminalPanelIds ) } - let panelSnapshots = orderedPanelIds + let builtPanelSnapshots = orderedPanelIds .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) .compactMap { panelId in // A Dock's owner UUID can change when its window/workspace is restored or @@ -85,6 +85,9 @@ extension DockSplitStore { agentProcessPresence: agentProcessPresence ) } + let panelSnapshots = restorableAgentIndex?.removingTranscriptlessClaudeResume( + from: builtPanelSnapshots + ) ?? builtPanelSnapshots let persistedPanelIds = Set(panelSnapshots.map(\.id)) let sourceWorkspaceIdsByPanelId: [UUID: UUID] = Dictionary( uniqueKeysWithValues: panelSnapshots.compactMap { panel -> (UUID, UUID)? in @@ -161,7 +164,7 @@ extension DockSplitStore { terminalFontSizeSnapshotProjection = nil } - return sessionPanelSnapshot( + let snapshot = sessionPanelSnapshot( panelId: panelId, includeScrollback: true, observation: restorableAgentIndex?.entryForStablePanel( @@ -197,6 +200,8 @@ extension DockSplitStore { return PIDPresence.current(pid: pid_t($0)) } ) + guard let snapshot, let restorableAgentIndex else { return snapshot } + return restorableAgentIndex.removingTranscriptlessClaudeResume(from: [snapshot]).first } private func orderedSessionPanelIds() -> [UUID] { diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index e53c5ae5596a..eee89005a6a8 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1189,6 +1189,13 @@ struct RestorableAgentSessionIndex: Sendable { /// Whether truncation left additional Codex panels without a retained /// per-panel marker. Such panels are incomplete unless explicitly verified. private let hasUnboundedCodexIncompleteness: Bool + /// Claude sessions whose hook records were read in this load but whose + /// transcript was not on disk in any config root, keyed to the paths where + /// Claude would write it. Claude only writes the transcript after the first + /// message, so these cannot be resumed. Consumers re-check the paths + /// because the first prompt may land after this load, which also keeps + /// the map safe to carry into a cached copy marked incomplete. + let claudeTranscriptPathsBySessionWithoutTranscript: [String: [String]] private let candidatesByPanelId: [UUID: [(PanelKey, Entry)]] private let entriesByPanelId: [UUID: Entry] private let ambiguousPanelIds: Set @@ -1331,7 +1338,11 @@ struct RestorableAgentSessionIndex: Sendable { incompleteCodexPanelKeys: incompleteCodexPanelKeys, inconclusiveCodexPanelKeys: inconclusiveCodexPanelKeys, verifiedCodexPanelKeys: verifiedCodexPanelKeys, - hasUnboundedCodexIncompleteness: hasUnboundedCodexIncompleteness + hasUnboundedCodexIncompleteness: hasUnboundedCodexIncompleteness, + // Consumers re-check the expected transcript paths, so evidence + // from an older load cannot drop a session that wrote one since. + claudeTranscriptPathsBySessionWithoutTranscript: + claudeTranscriptPathsBySessionWithoutTranscript ) } @@ -1787,7 +1798,9 @@ struct RestorableAgentSessionIndex: Sendable { incompleteCodexPanelKeys: self.incompleteCodexPanelKeys, inconclusiveCodexPanelKeys: self.inconclusiveCodexPanelKeys, verifiedCodexPanelKeys: self.verifiedCodexPanelKeys, - hasUnboundedCodexIncompleteness: self.hasUnboundedCodexIncompleteness + hasUnboundedCodexIncompleteness: self.hasUnboundedCodexIncompleteness, + claudeTranscriptPathsBySessionWithoutTranscript: + self.claudeTranscriptPathsBySessionWithoutTranscript ) } @@ -1865,6 +1878,8 @@ struct RestorableAgentSessionIndex: Sendable { var hasUnboundedCodexIncompleteness = false var codexIndexedStoreByHome: [String: Bool] = [:] var codexHookRecordsForIndex: [RestorableAgentHookSessionRecord]? + var claudeSessionIDsWithTranscript = Set() + var claudeTranscriptPathsBySessionMissingTranscript: [String: [String]] = [:] func codexPanelKey( for record: RestorableAgentHookSessionRecord @@ -2193,6 +2208,28 @@ struct RestorableAgentSessionIndex: Sendable { // only the rejected checkpoint. incompleteCodexPanelKeys.insert(panelKey) } + if kind == .claude { + if codexOwnerIsAdmitted { + claudeSessionIDsWithTranscript.insert(normalizedSessionId) + if let originalSessionId = normalizedNonEmptyValue(record.sessionId) { + claudeSessionIDsWithTranscript.insert(originalSessionId) + } + } else if claudeRecordNamesTranscriptLocation(record), + !claudeTranscriptExists( + for: record, + fileManager: fileManager, + lookup: claudeTranscriptLookup + ) { + // The trusted record found nothing; the raw record can + // still name a CLAUDE_CONFIG_DIR its launch capture lost. + claudeTranscriptPathsBySessionMissingTranscript[normalizedSessionId, default: []] + .append(contentsOf: claudeExpectedTranscriptPaths( + for: record, + sessionId: normalizedSessionId, + lookup: claudeTranscriptLookup + )) + } + } guard codexOwnerIsAdmitted else { continue } @@ -2523,7 +2560,9 @@ struct RestorableAgentSessionIndex: Sendable { incompleteCodexPanelKeys: incompleteCodexPanelKeys, inconclusiveCodexPanelKeys: inconclusiveCodexPanelKeys, verifiedCodexPanelKeys: verifiedCodexPanelKeys, - hasUnboundedCodexIncompleteness: hasUnboundedCodexIncompleteness + hasUnboundedCodexIncompleteness: hasUnboundedCodexIncompleteness, + claudeTranscriptPathsBySessionWithoutTranscript: claudeTranscriptPathsBySessionMissingTranscript + .filter { !claudeSessionIDsWithTranscript.contains($0.key) } ) } @@ -2890,6 +2929,46 @@ struct RestorableAgentSessionIndex: Sendable { return claudeTranscriptExists(for: record, fileManager: fileManager, lookup: claudeTranscriptLookup) } + /// Whether a Claude record says where Claude writes its transcript: the + /// SessionStart `transcript_path`, or an explicit config directory. Older + /// records without either may live in a root the lookup cannot see, so a + /// miss there is not evidence that the conversation is empty. + private static func claudeRecordNamesTranscriptLocation( + _ record: RestorableAgentHookSessionRecord + ) -> Bool { + normalizedNonEmptyValue(record.transcriptPath) != nil + || normalizedNonEmptyValue(record.launchCommand?.environment?["CLAUDE_CONFIG_DIR"]) != nil + } + + /// Where Claude writes this session's transcript: the reported + /// `transcript_path`, then `/projects//.jsonl` for each + /// config root and recorded cwd. A later save re-checks these paths. + private static func claudeExpectedTranscriptPaths( + for record: RestorableAgentHookSessionRecord, + sessionId: String, + lookup: ClaudeTranscriptLookupCache + ) -> [String] { + var paths: [String] = [] + if let transcriptPath = normalizedNonEmptyValue(record.transcriptPath) { + paths.append((transcriptPath as NSString).expandingTildeInPath) + } + guard claudeSessionIdIsSafeFilename(sessionId) else { return paths } + let cwdCandidates = [ + normalizedWorkingDirectory(record.cwd), + normalizedWorkingDirectory(record.launchCommand?.workingDirectory), + ].compactMap { $0 } + for root in lookup.configRoots(for: record) { + let projectsRoot = (root as NSString).appendingPathComponent("projects") + for cwd in cwdCandidates { + let projectRoot = (projectsRoot as NSString) + .appendingPathComponent(encodeClaudeProjectDir(cwd)) + paths.append((projectRoot as NSString).appendingPathComponent("\(sessionId).jsonl")) + } + } + var seen: Set = [] + return paths.filter { seen.insert(($0 as NSString).standardizingPath).inserted } + } + private static func codexLegacyLaunchHasPositiveEvidence( _ record: RestorableAgentHookSessionRecord, fileManager: FileManager @@ -3742,7 +3821,8 @@ struct RestorableAgentSessionIndex: Sendable { incompleteCodexPanelKeys: Set = [], inconclusiveCodexPanelKeys: Set? = nil, verifiedCodexPanelKeys: Set = [], - hasUnboundedCodexIncompleteness: Bool = false + hasUnboundedCodexIncompleteness: Bool = false, + claudeTranscriptPathsBySessionWithoutTranscript: [String: [String]] = [:] ) { self.entriesByPanel = entriesByPanel self.liveSessionOwners = liveSessionOwners @@ -3758,6 +3838,8 @@ struct RestorableAgentSessionIndex: Sendable { self.verifiedCodexPanelKeys = verifiedCodexPanelKeys self.verifiedCodexPanelIds = Set(verifiedCodexPanelKeys.map(\.panelId)) self.hasUnboundedCodexIncompleteness = hasUnboundedCodexIncompleteness + self.claudeTranscriptPathsBySessionWithoutTranscript = + claudeTranscriptPathsBySessionWithoutTranscript // Keep only the bounded candidate prefix while indexing. Exact owner // lookups still use `entriesByPanel`, but stable-panel resolution must // never retain or sort an unbounded owner history a second time. diff --git a/Sources/RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift b/Sources/RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift new file mode 100644 index 000000000000..525467bc980c --- /dev/null +++ b/Sources/RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift @@ -0,0 +1,75 @@ +import Foundation + +extension RestorableAgentSessionIndex { + /// Whether this load saw a Claude hook record for `sessionId`, found no + /// transcript for it in any config root, and Claude still has not written + /// one at the paths it reported. The re-check covers a first prompt sent + /// after this index loaded; it stats at most a few files per session. + func claudeSessionLacksTranscript(_ sessionId: String?) -> Bool { + guard let sessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionId.isEmpty, + let expectedPaths = claudeTranscriptPathsBySessionWithoutTranscript[sessionId] else { + return false + } + return !expectedPaths.contains(where: Self.regularNonEmptyFileExists(atPath:)) + } + + /// Drops the Claude resume state a saved terminal would replay when Claude + /// never wrote that session's transcript (it was quit before its first + /// prompt). Resuming it fails with "No conversation found", so the tab + /// reopens as a plain shell. Applied after the snapshot is built so an + /// older session on the same surface is never promoted in its place. + func removingTranscriptlessClaudeResume( + from terminal: SessionTerminalPanelSnapshot + ) -> SessionTerminalPanelSnapshot { + guard !claudeTranscriptPathsBySessionWithoutTranscript.isEmpty else { return terminal } + var terminal = terminal + if let agent = terminal.agent, + agent.kind == .claude, + claudeSessionLacksTranscript(agent.sessionId) { + terminal.agent = nil + terminal.hibernation = nil + } + if bindingResumesTranscriptlessClaude(terminal.resumeBinding) { + terminal.resumeBinding = nil + } + if bindingResumesTranscriptlessClaude(terminal.managedAgentResumeBinding) { + terminal.managedAgentResumeBinding = nil + } + return terminal + } + + /// Applies ``removingTranscriptlessClaudeResume(from:)`` to every terminal panel. + func removingTranscriptlessClaudeResume( + from panels: [SessionPanelSnapshot] + ) -> [SessionPanelSnapshot] { + guard !claudeTranscriptPathsBySessionWithoutTranscript.isEmpty else { return panels } + return panels.map { panel in + guard let terminal = panel.terminal else { return panel } + var panel = panel + panel.terminal = removingTranscriptlessClaudeResume(from: terminal) + return panel + } + } + + private static func regularNonEmptyFileExists(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), + !isDirectory.boolValue, + let size = (try? FileManager.default.attributesOfItem(atPath: path))?[.size] as? NSNumber else { + return false + } + return size.intValue > 0 + } + + private func bindingResumesTranscriptlessClaude( + _ binding: SurfaceResumeBindingSnapshot? + ) -> Bool { + guard let binding, binding.isAgentHookBinding, + binding.kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + == RestorableAgentKind.claude.rawValue else { + return false + } + return claudeSessionLacksTranscript(binding.checkpointId) + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c1d789cb3e92..d08774c5ab86 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -105,7 +105,7 @@ extension Workspace { for: self, panelIds: Set(allPanelIds) ) - let panelSnapshots = allPanelIds + let builtPanelSnapshots = allPanelIds .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) .compactMap { panelId in sessionPanelSnapshot( @@ -134,6 +134,9 @@ extension Workspace { agentProcessPresence: agentProcessPresence ) } + let panelSnapshots = restorableAgentIndex?.removingTranscriptlessClaudeResume( + from: builtPanelSnapshots + ) ?? builtPanelSnapshots let persistedPanelIds = Set(panelSnapshots.map(\.id)) let layout = layoutCodec.pruned(rawLayout, keeping: persistedPanelIds) ?? .pane( SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) @@ -916,7 +919,7 @@ extension Workspace { for: self, panelIds: [panelId] ) - guard let snapshot = sessionPanelSnapshot( + guard let builtSnapshot = sessionPanelSnapshot( panelId: panelId, includeScrollback: true, restorableAgentObservation: restorableAgentObservation, @@ -929,6 +932,8 @@ extension Workspace { ) else { return nil } + let snapshot = agentIndex.removingTranscriptlessClaudeResume(from: [builtSnapshot]).first + ?? builtSnapshot let projection = SurfaceCatalog.shared.projectionRecord(forPanel: panelId) return ClosedPanelHistoryEntry( workspaceId: id, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 46b418dd3058..d90e4817e0d6 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -649,6 +649,7 @@ A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */; }; 97CEF52570A6C3AFB83E1A7C /* ClaudeStopFailureStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEAB02E73CAA231F5E266CF7 /* ClaudeStopFailureStatusTests.swift */; }; A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */; }; + 3064F30D440A86FA4F6007B1 /* ClaudeTranscriptlessRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0DDF5F330E26DA03118A56 /* ClaudeTranscriptlessRestoreTests.swift */; }; C1175700000000000000003A /* ClaudeWrapperResumeEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11757000000000000000021 /* ClaudeWrapperResumeEnvironmentTests.swift */; }; A0DD6EEA0656443ED94D975D /* CLIAgentHibernationTargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B6FD335AD1A538E8DBB23D /* CLIAgentHibernationTargetTests.swift */; }; AE55A6E50000000000000005 /* CLIAgentMessageCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE55A6E50000000000000006 /* CLIAgentMessageCommandTests.swift */; }; @@ -3124,6 +3125,7 @@ 842300000000000000000003 /* RestorableAgentProcessLiveness+ProcessIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842300000000000000000004 /* RestorableAgentProcessLiveness+ProcessIdentity.swift */; }; C0A710140000000000000002 /* RestorableAgentSession+ComputerUse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710140000000000000001 /* RestorableAgentSession+ComputerUse.swift */; }; A5001660 /* RestorableAgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* RestorableAgentSession.swift */; }; + A354816DF0BD4BF87C895D88 /* RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AD342B158976C822DD03E1B /* RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift */; }; 9923A0019923A0019923A001 /* RestorableAgentSessionIndex+ManagedSessionIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9923A0029923A0029923A002 /* RestorableAgentSessionIndex+ManagedSessionIdentity.swift */; }; A401DFE16CF1BC7701922F8B /* RestorableAgentSessionIndex+ProcessCensus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 008194BFAF62176FE8C0340A /* RestorableAgentSessionIndex+ProcessCensus.swift */; }; 7DB6CFFD0F8C9EB3DB5041B2 /* RestorableAgentSessionIndex+SnapshotLiveness.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B4D50DCEF633BE0A75CD4A /* RestorableAgentSessionIndex+SnapshotLiveness.swift */; }; @@ -5349,6 +5351,7 @@ A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationStatusLifecycleTests.swift; sourceTree = ""; }; DEAB02E73CAA231F5E266CF7 /* ClaudeStopFailureStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClaudeStopFailureStatusTests.swift"; sourceTree = ""; }; A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ClaudeStreamJSONAccumulator.swift; sourceTree = ""; }; + BB0DDF5F330E26DA03118A56 /* ClaudeTranscriptlessRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClaudeTranscriptlessRestoreTests.swift"; sourceTree = ""; }; C11757000000000000000021 /* ClaudeWrapperResumeEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeWrapperResumeEnvironmentTests.swift; sourceTree = ""; }; A2B6FD335AD1A538E8DBB23D /* CLIAgentHibernationTargetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLIAgentHibernationTargetTests.swift"; sourceTree = ""; }; AE55A6E50000000000000006 /* CLIAgentMessageCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIAgentMessageCommandTests.swift; sourceTree = ""; }; @@ -7658,6 +7661,7 @@ 842300000000000000000004 /* RestorableAgentProcessLiveness+ProcessIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestorableAgentProcessLiveness+ProcessIdentity.swift"; sourceTree = ""; }; C0A710140000000000000001 /* RestorableAgentSession+ComputerUse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/RestorableAgentSession+ComputerUse.swift"; sourceTree = ""; }; A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; + 8AD342B158976C822DD03E1B /* RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift"; sourceTree = ""; }; 9923A0029923A0029923A002 /* RestorableAgentSessionIndex+ManagedSessionIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestorableAgentSessionIndex+ManagedSessionIdentity.swift"; sourceTree = ""; }; 008194BFAF62176FE8C0340A /* RestorableAgentSessionIndex+ProcessCensus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestorableAgentSessionIndex+ProcessCensus.swift"; sourceTree = ""; }; E7B4D50DCEF633BE0A75CD4A /* RestorableAgentSessionIndex+SnapshotLiveness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestorableAgentSessionIndex+SnapshotLiveness.swift"; sourceTree = ""; }; @@ -10694,6 +10698,7 @@ A6AC73020000000000000002 /* Workspace+AgentLifecycle.swift */, FEE760B8DF421ADA91B9E469 /* Workspace+DeferredAgentRestoreAdmission.swift */, F164F24D9F2A42688990D73E /* DeferredAgentResumeAdmissionOwner.swift */, + 8AD342B158976C822DD03E1B /* RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift */, D4A9923A0000000000000002 /* TerminalStartupRestoreCoordinator.swift */, 9923B0019923B0019923B001 /* PendingTerminalStartupRestoreChatBinding.swift */, 9923B0029923B0029923B002 /* PendingTerminalStartupRestore.swift */, @@ -13845,6 +13850,7 @@ DB2534FB1BD7FDBD3A7474EE /* CloudManualMirrorHandshakeTests.swift */, 220FD07E397B72CAE8025EC7 /* CloudWelcomePresentationTests.swift */, DA688992ADFE3696B36EEC3D /* CloudStaleInventoryMaterializationTests.swift */, + BB0DDF5F330E26DA03118A56 /* ClaudeTranscriptlessRestoreTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -16297,6 +16303,7 @@ 842300000000000000000003 /* RestorableAgentProcessLiveness+ProcessIdentity.swift in Sources */, C0A710140000000000000002 /* RestorableAgentSession+ComputerUse.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, + A354816DF0BD4BF87C895D88 /* RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift in Sources */, 9923A0019923A0019923A001 /* RestorableAgentSessionIndex+ManagedSessionIdentity.swift in Sources */, A401DFE16CF1BC7701922F8B /* RestorableAgentSessionIndex+ProcessCensus.swift in Sources */, 7DB6CFFD0F8C9EB3DB5041B2 /* RestorableAgentSessionIndex+SnapshotLiveness.swift in Sources */, @@ -17926,6 +17933,7 @@ F6332A8451C63D5C64997403 /* ClaudeHookWorkStateTests.swift in Sources */, A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */, 97CEF52570A6C3AFB83E1A7C /* ClaudeStopFailureStatusTests.swift in Sources */, + 3064F30D440A86FA4F6007B1 /* ClaudeTranscriptlessRestoreTests.swift in Sources */, A0DD6EEA0656443ED94D975D /* CLIAgentHibernationTargetTests.swift in Sources */, 9758A0000000000000000004 /* CLIAmpLifecycleIntegrationTests.swift in Sources */, A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */, diff --git a/cmuxTests/ClaudeTranscriptlessRestoreTests.swift b/cmuxTests/ClaudeTranscriptlessRestoreTests.swift new file mode 100644 index 000000000000..aeada3a0479c --- /dev/null +++ b/cmuxTests/ClaudeTranscriptlessRestoreTests.swift @@ -0,0 +1,255 @@ +import CMUXAgentLaunch +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Claude Code writes `/projects//.jsonl` only after the +/// first message. A session that was started and quit before any prompt has +/// nothing to resume, so cmux must not save or replay `cmux restore claude`. +@MainActor +@Suite(.serialized) +struct ClaudeTranscriptlessRestoreTests { + private struct Fixture { + let root: URL + let configDirectory: URL + let workingDirectory: URL + let hookStateDirectory: URL + let defaults: UserDefaults + let defaultsName: String + + var environment: [String: String] { + ["CMUX_AGENT_HOOK_STATE_DIR": hookStateDirectory.path] + } + + func transcriptURL(sessionID: String) -> URL { + configDirectory + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent( + RestorableAgentSessionIndex.encodeClaudeProjectDir(workingDirectory.path), + isDirectory: true + ) + .appendingPathComponent("\(sessionID).jsonl", isDirectory: false) + } + + func cleanup() { + try? FileManager.default.removeItem(at: root) + defaults.removePersistentDomain(forName: defaultsName) + } + } + + private func makeFixture() throws -> Fixture { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-claude-transcriptless-\(UUID().uuidString)", isDirectory: true) + let configDirectory = root.appendingPathComponent("claude-config", isDirectory: true) + let workingDirectory = root.appendingPathComponent("repo", isDirectory: true) + let hookStateDirectory = root.appendingPathComponent("hook-state", isDirectory: true) + try fileManager.createDirectory(at: workingDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: hookStateDirectory, withIntermediateDirectories: true) + let defaultsName = "cmux-claude-transcriptless-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) + let fixture = Fixture( + root: root, + configDirectory: configDirectory, + workingDirectory: workingDirectory, + hookStateDirectory: hookStateDirectory, + defaults: defaults, + defaultsName: defaultsName + ) + try fileManager.createDirectory( + at: fixture.transcriptURL(sessionID: "placeholder").deletingLastPathComponent(), + withIntermediateDirectories: true + ) + return fixture + } + + private func writeHookRecord( + _ fixture: Fixture, + sessionID: String, + workspaceID: UUID, + panelID: UUID + ) throws { + let storeURL = RestorableAgentKind.claude.hookStoreFileURL( + homeDirectory: fixture.root.path, + environment: fixture.environment + ) + // SessionStart reports where Claude will write the transcript before + // the file exists. + let record: [String: Any] = [ + "sessionId": sessionID, + "workspaceId": workspaceID.uuidString, + "surfaceId": panelID.uuidString, + "cwd": fixture.workingDirectory.path, + "transcriptPath": fixture.transcriptURL(sessionID: sessionID).path, + "updatedAt": 1_791_483_500, + "launchCommand": [ + "launcher": "claude", + "executablePath": "/usr/local/bin/claude", + "arguments": ["/usr/local/bin/claude"], + "workingDirectory": fixture.workingDirectory.path, + "environment": ["CLAUDE_CONFIG_DIR": fixture.configDirectory.path], + "capturedAt": 1_791_483_500, + "source": "test", + ], + ] + let data = try JSONSerialization.data( + withJSONObject: ["version": 1, "sessions": [sessionID: record]], + options: [.prettyPrinted, .sortedKeys] + ) + try data.write(to: storeURL, options: .atomic) + } + + private func writeTranscript(_ fixture: Fixture, sessionID: String) throws { + try """ + {"type":"user","sessionId":"\(sessionID)","cwd":"\(fixture.workingDirectory.path)","message":{"role":"user","content":"hello"}} + + """.write(to: fixture.transcriptURL(sessionID: sessionID), atomically: true, encoding: .utf8) + } + + private func hookBinding(_ fixture: Fixture, sessionID: String) -> SurfaceResumeBindingSnapshot { + SurfaceResumeBindingSnapshot( + name: "Claude Code", + kind: "claude", + command: "claude --resume \(sessionID)", + cwd: fixture.workingDirectory.path, + checkpointId: sessionID, + source: "agent-hook", + environment: ["CLAUDE_CONFIG_DIR": fixture.configDirectory.path], + autoResume: true, + approvalPolicy: .auto, + updatedAt: 1_791_483_501 + ) + } + + private func loadIndex(_ fixture: Fixture) -> RestorableAgentSessionIndex { + RestorableAgentSessionIndex.load( + homeDirectory: fixture.root.path, + fileManager: .default, + registry: CmuxVaultAgentRegistry(registrations: []), + detectedSnapshots: [:], + environment: fixture.environment, + processArgumentsProvider: { _ in nil }, + processPresenceProvider: { _ in .absent } + ) + } + + /// Saves the source workspace with `index`, forces unknown liveness (the + /// live-tab-at-quit case from #20246), and restores the result. + private enum TranscriptTiming { + case never + case beforeIndexLoad + case afterIndexLoad + } + + private func saveAndRestore( + _ fixture: Fixture, + transcript: TranscriptTiming, + index: (Fixture) -> RestorableAgentSessionIndex + ) throws -> (terminal: SessionTerminalPanelSnapshot?, input: String?) { + let source = Workspace( + agentSessionAutoResumeDefaults: fixture.defaults, + restorableAgentIndexProvider: { .empty } + ) + defer { source.teardownAllPanels() } + let panelID = try #require(source.focusedPanelId) + let sessionID = UUID().uuidString.lowercased() + try writeHookRecord(fixture, sessionID: sessionID, workspaceID: source.id, panelID: panelID) + if transcript == .beforeIndexLoad { + try writeTranscript(fixture, sessionID: sessionID) + } + source.surfaceResumeBindingsByPanelId[panelID] = hookBinding(fixture, sessionID: sessionID) + + let agentIndex = index(fixture) + if transcript == .afterIndexLoad { + try writeTranscript(fixture, sessionID: sessionID) + } + var snapshot = source.sessionSnapshot( + includeScrollback: false, + restorableAgentIndex: agentIndex + ) + let savedTerminal = snapshot.panels.first?.terminal + for panelIndex in snapshot.panels.indices { + snapshot.panels[panelIndex].terminal?.wasAgentRunning = nil + } + + let restored = Workspace( + agentSessionAutoResumeDefaults: fixture.defaults, + restorableAgentIndexProvider: { .empty } + ) + defer { restored.teardownAllPanels() } + restored.restoreSessionSnapshot(snapshot) + let restoredPanelID = try #require(restored.focusedPanelId) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) + return (savedTerminal, restoredPanel.surface.debugInitialInputForTesting()) + } + + @Test("A Claude session quit before its first prompt is not saved or restored") + func transcriptlessClaudeSessionIsNotSavedOrRestored() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + + let result = try saveAndRestore(fixture, transcript: .never) { loadIndex($0) } + + #expect(result.terminal?.resumeBinding == nil) + #expect(result.terminal?.agent == nil) + #expect(result.input == nil, Comment(rawValue: result.input ?? "")) + } + + @Test("A Claude session with a transcript still saves and restores") + func claudeSessionWithTranscriptStillRestores() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + + let result = try saveAndRestore(fixture, transcript: .beforeIndexLoad) { loadIndex($0) } + + let binding = try #require(result.terminal?.resumeBinding) + #expect(binding.kind == "claude") + let input = try #require(result.input) + #expect(input == " cmux restore claude \(binding.checkpointId ?? "")\n", Comment(rawValue: input)) + } + + @Test("A first prompt sent after the index loaded keeps the Claude binding") + func transcriptWrittenAfterIndexLoadKeepsBinding() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + + let result = try saveAndRestore(fixture, transcript: .afterIndexLoad) { loadIndex($0) } + + let binding = try #require(result.terminal?.resumeBinding) + #expect(binding.kind == "claude") + #expect(result.input == " cmux restore claude \(binding.checkpointId ?? "")\n") + } + + @Test("A cached index marked incomplete still drops a transcript-less Claude session") + func cachedIndexDropsTranscriptlessClaudeSession() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + + // Update relaunch and the quit deadline fallback save from a cached + // index. Its evidence is re-checked on disk before anything is dropped. + let result = try saveAndRestore(fixture, transcript: .never) { + loadIndex($0).markingIncomplete() + } + + #expect(result.terminal?.resumeBinding == nil) + #expect(result.input == nil, Comment(rawValue: result.input ?? "")) + } + + @Test("A cached index keeps a Claude session whose first prompt landed after it loaded") + func cachedIndexKeepsClaudeSessionWithLaterTranscript() throws { + let fixture = try makeFixture() + defer { fixture.cleanup() } + + let result = try saveAndRestore(fixture, transcript: .afterIndexLoad) { + loadIndex($0).markingIncomplete() + } + + #expect(result.terminal?.resumeBinding?.kind == "claude") + } +} From 330155661dd02d999e91d8acc3fe892831700b90 Mon Sep 17 00:00:00 2001 From: Hoang Anh Pham <117793920+vjintageboy@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:29:32 +0700 Subject: [PATCH 027/183] Fix Backspace dropping text an IME commits on the same keypress (#20466) * Add regression test for Vietnamese IME Backspace dropping preedit macOS Vietnamese Telex commits its marked text on Backspace, then sends deleteBackward:. The committed text is currently attached to the Backspace key event, which Ghostty's encoder drops. Co-Authored-By: Claude Opus 5.5 * Fix Vietnamese IME Backspace wiping the preedit macOS Vietnamese Telex handles Backspace on underlined text by committing the preedit with insertText and then sending deleteBackward:. keyDown sent the commit as a Backspace key event with UTF-8 attached, which Ghostty's encoder drops, and doCommand ignored deleteBackward:, so the whole word vanished. When interpretKeyEvents both commits text and asks for deleteBackward:, send the commit as typed text and then the Backspace on its own. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Sources/GhosttyTerminalView.swift | 22 +++++++- cmuxTests/CJKIMEInputTests.swift | 92 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cde2126b8545..5aacc8882cec 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7052,6 +7052,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private(set) var keyTextAccumulator: [String]? = nil private var markedText = NSMutableAttributedString() private var markedSelectedRange = NSRange(location: NSNotFound, length: 0) + // Set when interpretKeyEvents asks for deleteBackward: during the current keyDown. + private var keyRequestedDeleteBackward = false private var lastPerformKeyEvent: TimeInterval? private(set) var externalCommittedTextDepth = 0 var numpadIMECommitDeduplicator = NumpadIMECommitDeduplicator() @@ -7086,7 +7088,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Prevents NSBeep for unimplemented actions from interpretKeyEvents override func doCommand(by selector: Selector) { - // Intentionally empty - prevents system beep on unhandled key commands + // Otherwise empty - prevents system beep on unhandled key commands + if keyTextAccumulator != nil, selector == #selector(deleteBackward(_:)) { + keyRequestedDeleteBackward = true + } } /// Some third-party voice input apps inject committed text by sending the @@ -7529,6 +7534,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Set up text accumulator for interpretKeyEvents keyTextAccumulator = [] + keyRequestedDeleteBackward = false defer { keyTextAccumulator = nil } let markedTextBefore = markedText.length > 0 @@ -7616,6 +7622,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // composition without deleting the preceding terminal input. keyEvent.composing = markedText.length > 0 || markedTextBefore + // Vietnamese Telex commits its preedit on Backspace, then asks for + // deleteBackward:. Ghostty drops UTF-8 attached to a Backspace key, so + // send the commit as typed text and replay the Backspace on its own. + if keyRequestedDeleteBackward, !accumulatedText.isEmpty { + for text in accumulatedText where shouldSendText(text) { + sendCommittedTextChunk(text) + } + keyEvent.composing = false + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.text = nil + _ = sendGhosttyKey(surface, keyEvent) + return + } + // Use accumulated text from insertText (for IME), or compute text for key if !accumulatedText.isEmpty { // Accumulated text comes from insertText (IME composition result). diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 0944fa4dc114..f1a1fe7d26ed 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -1205,6 +1205,98 @@ final class KoreanIMEReturnCommitRegressionTests: XCTestCase { } } +@MainActor +final class VietnameseIMEBackspaceRegressionTests: XCTestCase { + func testBackspaceCommitsPreeditThenDeletesOneCharacter() async { + await AppContextSerialGate.withExclusiveAppContext { + _ = NSApplication.shared + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil + surface.releaseHostedSurfaceForTesting() + window.orderOut(nil) + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + _ = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { surface.surface != nil } + XCTAssertNotNil(surface.surface, "Expected native surface before dispatching composed input") + + guard let view = findGhosttyNSView(in: hostedView) else { + XCTFail("Expected hosted GhosttyNSView") + return + } + + view.setMarkedText("iêt", selectedRange: NSRange(location: 3, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0)) + + // macOS Vietnamese Telex on Backspace: commit the preedit, then request deleteBackward:. + installCJKIMEInterpretKeyEventsSwizzle() + cjkIMEInterpretKeyEventsHook = { candidateView, _ in + guard candidateView === view else { return false } + candidateView.insertText("iêt", replacementRange: NSRange(location: NSNotFound, length: 0)) + candidateView.doCommand(by: #selector(NSResponder.deleteBackward(_:))) + return true + } + defer { cjkIMEInterpretKeyEventsHook = nil } + + var presses: [(keycode: UInt32, text: String?)] = [] + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + guard keyEvent.action == GHOSTTY_ACTION_PRESS else { return } + presses.append((keyEvent.keycode, keyEvent.text.map { String(cString: $0) })) + } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{7f}", + charactersIgnoringModifiers: "\u{7f}", + isARepeat: false, + keyCode: 51 + ) else { + XCTFail("Failed to create Backspace event") + return + } + + window.makeFirstResponder(view) + view.keyDown(with: event) + + XCTAssertFalse(view.hasMarkedText()) + XCTAssertEqual(presses.map(\.text), ["iêt", nil], "Committed preedit must reach the terminal before the Backspace") + XCTAssertEqual(presses.last?.keycode, 51, "The Backspace requested via deleteBackward: must be forwarded without text") + } + } +} + @MainActor final class KoreanIMEMarkedTextLeakRegressionTests: XCTestCase { func testKeyDownDoesNotLeakJamoWhileMarkedTextIsActive() async { From 140cc9503f9a761b8d9a32dfcb4a6c9326cd077d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:30:05 -0700 Subject: [PATCH 028/183] Raise the Freestyle TLS rule limit default to 200,000 (#20503) * test: Freestyle TLS rule limit defaults to the raised 200,000 cap Co-Authored-By: Claude Opus 5.5 * fix(cloud): raise the Freestyle TLS rule limit default to 200,000 Freestyle raised the account cap from 2,000. The old default made the operator alert page as "limit reached" at 2,367 rules. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- web/services/observability/providerRuleCapacity.ts | 4 ++-- web/tests/vm-tls-rule-limit.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/services/observability/providerRuleCapacity.ts b/web/services/observability/providerRuleCapacity.ts index 9b38d018e368..e480af3b90f1 100644 --- a/web/services/observability/providerRuleCapacity.ts +++ b/web/services/observability/providerRuleCapacity.ts @@ -5,9 +5,9 @@ import type { AlertInput } from "./alerts"; * (the CodeRouter and reflection aliases), allowlist domains add one each, and * any other workload sharing the account draws from the same pool. At the cap * a network policy save cannot add a rule, so operators need to hear about it - * before users do. + * before users do. Freestyle raised the account cap from 2,000 to 200,000. */ -export const DEFAULT_FREESTYLE_TLS_RULE_LIMIT = 2000; +export const DEFAULT_FREESTYLE_TLS_RULE_LIMIT = 200_000; export const TLS_RULE_CAPACITY_ALERT_KEY = "provider-tls-rule-capacity"; const WARNING_FRACTION = 0.85; /** The capacity read runs inside the alert cron; a slow provider must not stall the other checks. */ diff --git a/web/tests/vm-tls-rule-limit.test.ts b/web/tests/vm-tls-rule-limit.test.ts index 8d64a4a9e84a..640811bea770 100644 --- a/web/tests/vm-tls-rule-limit.test.ts +++ b/web/tests/vm-tls-rule-limit.test.ts @@ -242,10 +242,10 @@ describe("the account-wide Freestyle TLS rule cap", () => { }); test("FREESTYLE_TLS_RULE_LIMIT accepts only a positive whole number", () => { - expect(freestyleTlsRuleLimit({})).toBe(2000); + expect(freestyleTlsRuleLimit({})).toBe(200_000); expect(freestyleTlsRuleLimit({ FREESTYLE_TLS_RULE_LIMIT: "5000" })).toBe(5000); for (const value of ["2,000", "2000abc", "-5", "0", "1e3", "12.5"]) { - expect(freestyleTlsRuleLimit({ FREESTYLE_TLS_RULE_LIMIT: value })).toBe(2000); + expect(freestyleTlsRuleLimit({ FREESTYLE_TLS_RULE_LIMIT: value })).toBe(200_000); } }); From b0be0db136ec729eb0bc9823ed954fce9bd0a4b4 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:43:00 -0700 Subject: [PATCH 029/183] Fix upside-down Design Mode selection screenshots (#20424) * test: cover upright Design Mode selection PNGs * fix: preserve Design Mode screenshot orientation * test: verify the Design Mode handoff artifact --- .../BrowserScreenshotPasteboardWriter.swift | 70 ++------------- ...gnModeOffscreenSelectionHandoffTests.swift | 86 +++++++++++++++++++ cmuxTests/BrowserScreenshotCropTests.swift | 66 +++++++++++++- 3 files changed, 156 insertions(+), 66 deletions(-) diff --git a/Sources/Panels/BrowserScreenshotPasteboardWriter.swift b/Sources/Panels/BrowserScreenshotPasteboardWriter.swift index c8134690e8ed..c47038537be4 100644 --- a/Sources/Panels/BrowserScreenshotPasteboardWriter.swift +++ b/Sources/Panels/BrowserScreenshotPasteboardWriter.swift @@ -60,10 +60,9 @@ struct BrowserScreenshotPasteboardWriter: Sendable { ) else { throw BrowserScreenshotError.invalidImageRepresentation } - return try await encode( - cgImage, - verticallyFlip: bitmapImage != nil - ) + // `NSBitmapImageRep.cgImage` and AppKit's snapshot reps are both + // top-row-first, so the encoder must never reorder rows (#18793). + return try await encode(cgImage) } #if compiler(>=6.2) @@ -72,14 +71,10 @@ struct BrowserScreenshotPasteboardWriter: Sendable { @Sendable #endif nonisolated private func encode( - _ source: CGImage, - verticallyFlip: Bool + _ source: CGImage ) async throws -> BrowserScreenshotEncodedImage { try Task.checkCancellation() - let bounded = try boundedImage( - source, - verticallyFlip: verticallyFlip - ) + let bounded = try boundedImage(source) try Task.checkCancellation() return BrowserScreenshotEncodedImage( png: try encodedData(for: bounded, type: UTType.png.identifier), @@ -87,10 +82,7 @@ struct BrowserScreenshotPasteboardWriter: Sendable { ) } - private nonisolated func boundedImage( - _ source: CGImage, - verticallyFlip: Bool - ) throws -> CGImage { + private nonisolated func boundedImage(_ source: CGImage) throws -> CGImage { guard maximumPixelCount > 0, source.width > 0, source.height > 0 else { @@ -120,11 +112,8 @@ struct BrowserScreenshotPasteboardWriter: Sendable { height -= 1 } } - let oriented = verticallyFlip - ? try verticallyFlippedImage(source) - : source guard width != source.width || height != source.height else { - return oriented + return source } guard let colorSpace = source.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), let context = CGContext( @@ -140,7 +129,7 @@ struct BrowserScreenshotPasteboardWriter: Sendable { } context.interpolationQuality = .high context.draw( - oriented, + source, in: CGRect(x: 0, y: 0, width: width, height: height) ) guard let bounded = context.makeImage() else { @@ -149,49 +138,6 @@ struct BrowserScreenshotPasteboardWriter: Sendable { return bounded } - private nonisolated func verticallyFlippedImage(_ source: CGImage) throws -> CGImage { - guard let sourceData = source.dataProvider?.data, - source.bytesPerRow > 0, - source.height > 0 else { - throw BrowserScreenshotError.invalidImageRepresentation - } - let byteCount = source.bytesPerRow.multipliedReportingOverflow(by: source.height) - guard !byteCount.overflow, - CFDataGetLength(sourceData) >= byteCount.partialValue else { - throw BrowserScreenshotError.invalidImageRepresentation - } - var flipped = Data(count: byteCount.partialValue) - flipped.withUnsafeMutableBytes { destination in - guard let destinationBase = destination.baseAddress, - let sourceBase = CFDataGetBytePtr(sourceData) else { return } - for row in 0.. Data { let data = NSMutableData() guard let destination = CGImageDestinationCreateWithData( diff --git a/cmuxTests/BrowserDesignModeOffscreenSelectionHandoffTests.swift b/cmuxTests/BrowserDesignModeOffscreenSelectionHandoffTests.swift index 56eaccf62650..adb53ad36e08 100644 --- a/cmuxTests/BrowserDesignModeOffscreenSelectionHandoffTests.swift +++ b/cmuxTests/BrowserDesignModeOffscreenSelectionHandoffTests.swift @@ -409,6 +409,92 @@ struct BrowserDesignModeOffscreenSelectionHandoffTests { _ = navigationDelegate } + /// Regression for #18793: a static selection's handoff PNG must preserve + /// the selected element's top-to-bottom visual order. + @Test func staticSelectionHandoffPNGKeepsTopAboveBottom() async throws { + let directory = URL.temporaryDirectory.appendingPathComponent( + "cmux-design-mode-static-selection-test-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + var copiedPrompt: String? + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 640, height: 480)) + let controller = BrowserDesignModeController( + surfaceID: UUID(), + script: BrowserDesignModeScript(), + promptFormatter: BrowserDesignModePromptFormatter(), + artifactStore: BrowserDesignModeArtifactStore(directory: directory), + javaScriptEvaluator: BrowserDesignModeJavaScriptEvaluator(), + screenshotEvaluator: BrowserDesignModeScreenshotEvaluator( + timeout: 10, + cleanupTimeout: 2, + scrollSettleTimeout: 0.05 + ), + canEnable: { true }, + clipboardWriter: { prompt in + copiedPrompt = prompt + return true + }, + onActivityChanged: {} + ) + controller.install(on: webView) + + let (loaded, loadedContinuation) = AsyncStream.makeStream() + let navigationDelegate = BrowserDesignModeOffscreenNavigationDelegate { + loadedContinuation.yield() + loadedContinuation.finish() + } + webView.navigationDelegate = navigationDelegate + webView.loadHTMLString( + """ + +
TOP
BOTTOM
+ """, + baseURL: nil + ) + var loadedIterator = loaded.makeAsyncIterator() + _ = await loadedIterator.next() + + #expect(await controller.setEnabled(true, reason: "test")) + let evaluator = BrowserDesignModeJavaScriptEvaluator() + _ = try await evaluator.call( + "return globalThis.__cmuxDesignMode.select('#card');", + arguments: [:], + in: webView, + contentWorld: BrowserDesignModeController.contentWorld + ) + + await controller.copySelection() + + let prompt = try #require(copiedPrompt) + let contextURL = try artifactURL(in: prompt, marker: "Details: ") + let payload = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: contextURL)) as? [String: Any] + ) + let selections = try #require(payload["selections"] as? [[String: Any]]) + let screenshotPath = try #require(selections.first?["screenshot_path"] as? String) + let bitmap = try #require( + NSBitmapImageRep(data: try Data(contentsOf: URL(fileURLWithPath: screenshotPath))) + ) + let centerX = bitmap.pixelsWide / 2 + let top = try #require( + bitmap.colorAt(x: centerX, y: bitmap.pixelsHigh / 8)?.usingColorSpace(.sRGB) + ) + let bottom = try #require( + bitmap.colorAt(x: centerX, y: bitmap.pixelsHigh * 7 / 8)?.usingColorSpace(.sRGB) + ) + + #expect(top.redComponent > 0.7 && top.blueComponent < 0.3, "top was \(top)") + #expect(bottom.blueComponent > 0.7 && bottom.redComponent < 0.3, "bottom was \(bottom)") + _ = navigationDelegate + } + private func solidImage(size: NSSize) -> NSImage { let image = NSImage(size: size) image.lockFocus() diff --git a/cmuxTests/BrowserScreenshotCropTests.swift b/cmuxTests/BrowserScreenshotCropTests.swift index 52d853cdc998..06ce64a89e60 100644 --- a/cmuxTests/BrowserScreenshotCropTests.swift +++ b/cmuxTests/BrowserScreenshotCropTests.swift @@ -99,10 +99,37 @@ struct BrowserScreenshotCropTests { #expect(bitmap.pixelsWide == 200) #expect(bitmap.pixelsHigh == 100) - try expectColor(testRed, atX: 25, y: 25, in: bitmap) - try expectColor(testGreen, atX: 175, y: 25, in: bitmap) - try expectColor(testBlue, atX: 25, y: 75, in: bitmap) - try expectColor(testYellow, atX: 175, y: 75, in: bitmap) + // The crop rect is in AppKit's bottom-left space, so the source's upper + // blue/yellow band stays on top of the encoded PNG. + try expectColor(testBlue, atX: 25, y: 25, in: bitmap) + try expectColor(testYellow, atX: 175, y: 25, in: bitmap) + try expectColor(testRed, atX: 25, y: 75, in: bitmap) + try expectColor(testGreen, atX: 175, y: 75, in: bitmap) + } + + /// Regression for #18793: bitmap-backed Design Mode crops were written to + /// PNG upside down while WebKit-backed page captures stayed upright. + @Test(arguments: [ + BrowserScreenshotPasteboardWriter.maximumOrdinaryClipboardPixelCount, + // Forces the downscaling branch as well as the native-size branch. + 2_000, + ]) + func bitmapBackedImageEncodesTopRowFirst(maximumPixelCount: Int) async throws { + let image = try makeTopBottomBitmapImage(width: 80, height: 60) + + let pngData = try await BrowserScreenshotPasteboardWriter( + maximumPixelCount: maximumPixelCount, + oversizedImagePolicy: .downscale + ).pngData(for: image) + let bitmap = try #require(NSBitmapImageRep(data: pngData)) + + try expectColor(testRed, atX: bitmap.pixelsWide / 2, y: 2, in: bitmap) + try expectColor( + testBlue, + atX: bitmap.pixelsWide / 2, + y: bitmap.pixelsHigh - 3, + in: bitmap + ) } @Test @@ -1485,6 +1512,37 @@ struct BrowserScreenshotCropTests { return image } + /// Red over the top half, blue over the bottom half, drawn the way the + /// capture paths draw: through a bottom-left AppKit bitmap context. + private func makeTopBottomBitmapImage(width: Int, height: Int) throws -> NSImage { + let bitmap = try #require(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + testBlue.setFill() + NSRect(x: 0, y: 0, width: width, height: height / 2).fill() + testRed.setFill() + NSRect(x: 0, y: height / 2, width: width, height: height - height / 2).fill() + NSGraphicsContext.restoreGraphicsState() + + let size = NSSize(width: width, height: height) + bitmap.size = size + let image = NSImage(size: size) + image.addRepresentation(bitmap) + return image + } + private func makeBlankBitmapImage(width: Int, height: Int) throws -> NSImage { let bitmap = try #require(NSBitmapImageRep( bitmapDataPlanes: nil, From 5a06fb1fc7b75d82fcdacebd9fcd0c2a12f079ee Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:43:17 -0700 Subject: [PATCH 030/183] Fix display reconnect windows staying on the active Space (#20428) * test: cover inactive-space display reconciliation * fix: preserve inactive Spaces during display changes --- Sources/AppDelegate+MonitorMemory.swift | 19 ++++++++++++++++++- Sources/AppDelegate+WindowFramePolicy.swift | 9 +++++++++ ...AppDelegateDisplayConfigRestoreTests.swift | 10 ++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate+MonitorMemory.swift b/Sources/AppDelegate+MonitorMemory.swift index 83f32f582d12..749e2cda7461 100644 --- a/Sources/AppDelegate+MonitorMemory.swift +++ b/Sources/AppDelegate+MonitorMemory.swift @@ -182,6 +182,12 @@ extension AppDelegate { let mainWindows = mainWindowsForVisibilityController() for window in mainWindows { guard let window = window as? CmuxMainWindow else { continue } + // AppKit owns inactive-Space placement while it restores a display + // topology. Setting a frame on those windows moves them onto the + // currently visible Space instead of letting macOS restore them. + guard Self.shouldReconcileWindowAfterScreenChange( + isOnActiveSpace: window.isOnActiveSpace + ) else { continue } // Native-fullscreen windows are owned by AppKit's Space machinery; // clamping them mid-transition fights the fullscreen teardown. guard !window.styleMask.contains(.fullScreen) else { continue } @@ -200,9 +206,17 @@ extension AppDelegate { #endif window.setFrameForManagedPlacement(corrected, display: true) } + // Keep inactive-Space windows out of the fit pass for the same reason: + // the reconciler applies frames even when reachability needed no fix. + let activeSpaceWindows = mainWindows.filter { window in + guard let window = window as? CmuxMainWindow else { return false } + return Self.shouldReconcileWindowAfterScreenChange( + isOnActiveSpace: window.isOnActiveSpace + ) + } let fitCompleted = MainWindowFrameReconciler().repair( displays: displays.available, - windows: mainWindows, + windows: activeSpaceWindows, trigger: .displayTopology(changed: visibleFrameFitTopologyChanged) ) if visibleFrameFitTopologyChanged { @@ -227,6 +241,9 @@ extension AppDelegate { for window in mainWindowsForVisibilityController() { guard let window = window as? CmuxMainWindow else { continue } guard !window.styleMask.contains(.fullScreen) else { continue } + guard Self.shouldReconcileWindowAfterScreenChange( + isOnActiveSpace: window.isOnActiveSpace + ) else { continue } guard let context = contextForMainTerminalWindow(window) else { continue } let windowTag = context.windowId.uuidString.prefix(8) guard let entry = windowConfigFrames[context.windowId]?.entry(for: signature) else { diff --git a/Sources/AppDelegate+WindowFramePolicy.swift b/Sources/AppDelegate+WindowFramePolicy.swift index 8903194cc037..fb660108278d 100644 --- a/Sources/AppDelegate+WindowFramePolicy.swift +++ b/Sources/AppDelegate+WindowFramePolicy.swift @@ -1,6 +1,15 @@ import AppKit extension AppDelegate { + /// Display reconfiguration must leave windows on inactive Spaces to + /// AppKit. Applying a frame to one of those windows can move it onto the + /// currently visible Space while macOS is restoring the display topology. + nonisolated static func shouldReconcileWindowAfterScreenChange( + isOnActiveSpace: Bool + ) -> Bool { + isOnActiveSpace + } + nonisolated static func shouldPreserveAccessibleFrame( frame: CGRect, targetDisplay: SessionDisplayGeometry diff --git a/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift b/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift index f1f76555ef4b..2cee6b63e98d 100644 --- a/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift +++ b/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift @@ -74,6 +74,16 @@ struct AppDelegateDisplayConfigRestoreTests { // MARK: the headline round-trip + @Test + func displayReconciliationLeavesVisibleWindowsOnInactiveSpacesToAppKit() { + #expect(!AppDelegate.shouldReconcileWindowAfterScreenChange( + isOnActiveSpace: false + )) + #expect(AppDelegate.shouldReconcileWindowAfterScreenChange( + isOnActiveSpace: true + )) + } + @Test func windowFrameIsRestoredToExternalMonitorAfterReconnect() throws { // 1. Docked: built-in + external. User's window lives on the external. From a9b104f3cb7d3808cf7eb5ae08b4819333b25870 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:49:19 -0700 Subject: [PATCH 031/183] test: cover Codex tmux hook routing for issue 18768 (#20417) --- .../crates/cmux-tui/tests/codex_wrapper.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs index fdad0c149a06..6dfd0461fa76 100644 --- a/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs +++ b/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs @@ -13,6 +13,17 @@ use std::time::{Duration, Instant}; const TERMINAL_ONE: &str = "term_00000000000000000000000000000001"; const TERMINAL_TWO: &str = "term_00000000000000000000000000000002"; +#[cfg(target_os = "linux")] +struct RouteOwner(std::process::Child); + +#[cfg(target_os = "linux")] +impl Drop for RouteOwner { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + struct Launch { root: tempfile::TempDir, socket: PathBuf, @@ -174,6 +185,67 @@ fn codex_wrapper_remote_hooks_keep_each_terminal_under_a_shared_daemon_environme assert_eq!(stale.listener.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock); } +#[cfg(target_os = "linux")] +#[test] +fn codex_wrapper_resolves_the_attached_tmux_terminal_when_launch_environment_is_missing() { + let launch = Launch::new(); + // This process stands in for a tmux attach client. Its initial environment + // is readable through /proc, and the open stdin keeps it alive without a timer. + let route_owner = RouteOwner( + Command::new("/bin/cat") + .env_clear() + .env("CMUX_TUI_SOCKET", &launch.socket) + .env("CMUX_TUI_TERMINAL_ID", TERMINAL_TWO) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + + let tmux_dir = launch.root.path().join("tmux-bin"); + fs::create_dir(&tmux_dir).unwrap(); + let tmux = tmux_dir.join("tmux"); + fs::write( + &tmux, + format!( + "#!/bin/sh\ncase \"$1\" in\ndisplay-message) printf '%b\\n' '$1\\t\\t@1';;\nlist-clients) printf '%b\\n' '{}\\t999\\t$1\\t\\t@1';;\nesac\n", + route_owner.0.id() + ), + ) + .unwrap(); + fs::set_permissions(&tmux, fs::Permissions::from_mode(0o700)).unwrap(); + + let mut command = launch.command(); + command + .env_remove("CMUX_TUI_SOCKET") + .env_remove("CMUX_TUI_TERMINAL_ID") + .env("TMUX", "fake-server,1,0") + .env("TMUX_PANE", "%1") + .env( + "PATH", + format!( + "{}:{}:/usr/bin:/bin", + tmux_dir.display(), + launch.root.path().join("bin").display() + ), + ) + .args(["exec", "hello"]); + let argv = launch.argv(&mut command); + let config = settings(&argv); + let hook = hook_command(&config, "SessionStart"); + run_hook(&hook, None, TERMINAL_TWO); + let received = launch.event(); + assert_eq!(received["payload"]["native_event"], "SessionStart"); + assert!( + received["subjects"] + .as_array() + .unwrap() + .iter() + .any(|subject| { subject["kind"] == "terminal" && subject["id"] == TERMINAL_TWO }) + ); +} + #[test] fn codex_wrapper_passes_through_management_opt_out_and_missing_routes() { let launch = Launch::new(); From 496a40b994a0f907e40599089e632213f56467a1 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 01:51:49 -0700 Subject: [PATCH 032/183] cloud: keep the agent updater off a resumed clone's daemon start (#20440) * cloud: keep the agent updater off a resumed clone's daemon start A new machine opted into agent updates (the New Machine default) got its first full update from the attach seconds after the clone resumed, while the cmux-tui daemon was starting on a slow, just-restored disk. The updater's downloads and unpacks stalled the daemon's journal commits past its readiness timeout, the supervisor restarted it, and for minutes the hub answered every CONNECT with connection refused. The detached updater now waits until the clone's resume window (the boot supervisor's HOUSEKEEPING_DELAY, measured from /run/cmux/clone-started and re-read after each wait) has passed, then runs at nice 19 and idle I/O class. Attach still returns at once. Fixes #20438 Co-Authored-By: Claude Opus 5.5 * cloud: harden the agent updater's resume wait; deterministic tests Review findings on the resume window: - A fork taken while the waiter sleeps resumes with its clocks jumped, so the sleep returns at once, possibly before the fork's supervisor rewrites the marker. A wake far past the requested wait now starts a fresh window. - A marker dated in the future (clock stepped back) counts as written when first seen, so the wait is never longer than one window. - ionice's idle class only takes effect under BFQ; the docs no longer promise idle I/O priority. The tests drive the launch script synchronously under a fake date/sleep clock instead of sleeping and asserting (the determinism guard's finding), and resolve the boot script's path with fileURLToPath (typecheck). Co-Authored-By: Claude Opus 5.5 * cloud: run the updater even if ionice can't set idle; fixed test epoch ionice -t keeps the detached updater running when the idle class can't be set. The launch-script tests now read a fixed fake epoch for both the clone marker and date +%s, so they never depend on real elapsed time. README qualifies the idle I/O claim. Co-Authored-By: Claude Opus 5.5 * cloud: cap the updater's resume wait at one window on clock steps A marker less than a window in the future no longer counts at its own date once the clock passes it, and a window start left in the future by a backward clock step is pulled back to now, so the wait stays one window. Tests cover both, plus a marker rewritten mid-wait. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- web/services/vms/guestAgentUpdates.ts | 73 +++++++- web/services/vms/images/devbox/README.md | 6 +- web/tests/vm-agent-updates.test.ts | 202 ++++++++++++++++++++++- 3 files changed, 270 insertions(+), 11 deletions(-) diff --git a/web/services/vms/guestAgentUpdates.ts b/web/services/vms/guestAgentUpdates.ts index 58350049af9e..d98a0aad6cc8 100644 --- a/web/services/vms/guestAgentUpdates.ts +++ b/web/services/vms/guestAgentUpdates.ts @@ -24,7 +24,8 @@ import { * - `install` (the devbox bake): installs the Dockerfile's pins exactly, * checking each download against the sha256 pinned beside the version. It * never reads release metadata. - * - `update` (a machine opted into "latest", started detached on attach): for + * - `update` (a machine opted into "latest", started detached on attach, after + * a just-resumed clone's resume window and at idle priority): for * each agent, installs the newest x.y.z release that has been public for at * least GUEST_AGENT_MIN_RELEASE_AGE_SECONDS and is not above the repository's * latest release, checking the download against the sha256 digest GitHub's @@ -540,22 +541,90 @@ export function guestAgentInstallCommand( return guestAgentUpdaterCommand(configDir, { ...options, pins }, "install"); } +/** + * How long after a clone resumes the updater stays off the machine. A clone's + * disk is slow for its first minutes, and the attach that starts the updater + * arrives seconds after resume, while the cmux-tui daemon is still starting: + * a full update's downloads and unpacks there stalled the daemon's journal + * commits past its readiness timeout, and the Mac saw the daemon port refuse + * (https://github.com/manaflow-ai/cmux/issues/20438). Equal to cmux-devbox-boot's + * HOUSEKEEPING_DELAY, the window the boot supervisor keeps systemd timers off + * the same path; vm-agent-updates.test.ts pins the equality. + */ +export const GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS = 10 * 60; + +/** A wake this much later than the requested wait is a snapshot resume, not scheduling delay. */ +const GUEST_AGENT_UPDATES_RESUME_JUMP_SLACK_SECONDS = 30; + +/** Written by cmux-devbox-boot when it binds a resumed clone (TEMPLATE_RUN_DIR). */ +export const GUEST_CLONE_STARTED_MARKER = "/run/cmux/clone-started"; + export type GuestAgentUpdatesPaths = { readonly configDir: string; readonly log: string; readonly updater: GuestAgentUpdaterOptions; + readonly cloneStartedMarker: string; + readonly resumeWindowSeconds: number; }; const GUEST_AGENT_UPDATES_PATHS: GuestAgentUpdatesPaths = { configDir: "/etc/cmux", log: GUEST_AGENT_UPDATES_LOG, updater: GUEST_AGENT_UPDATER_DEFAULTS, + cloneStartedMarker: GUEST_CLONE_STARTED_MARKER, + resumeWindowSeconds: GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, }; +/** + * The detached updater run: wait out the resume window, then run the updater + * at low priority (nice 19, plus ionice's idle class when ionice is present; + * only BFQ honors that class, and -t runs the updater even if it cannot be + * set; the window is the real protection). The window is measured from the clone + * marker's mtime and re-read after every wait. Two clock hazards are handled: + * * A fork or checkpoint taken while this waits resumes with its clocks + * jumped, so the sleep returns at once, possibly before the fork's boot + * supervisor rewrites the marker. A wake far past the requested wait is + * therefore read as a resume and starts a fresh window from the wake. + * * A marker dated in the future (the clock stepped back after it was + * written) counts as written when it was first seen, and keeps counting + * that way after the clock passes its date, so the wait is never longer + * than one window. A window start later than now (the clock stepped back + * during the wait) is pulled back to now for the same reason. + * A machine with no marker (not a clone, or rebooted since) or an expired + * window runs at once. + */ +export function guestAgentUpdaterLaunchScript(paths: GuestAgentUpdatesPaths = GUEST_AGENT_UPDATES_PATHS): string { + const marker = shellQuote(paths.cloneStartedMarker); + const window = paths.resumeWindowSeconds; + return [ + `since=0`, + `ahead=""`, + `while :; do`, + `now=$(date +%s)`, + `[ "$since" -le "$now" ] || since=$now`, + `started=$since`, + `if [ -e ${marker} ] && seen=$(date -r ${marker} +%s 2>/dev/null); then`, + `if [ "$seen" -gt "$now" ] || [ "$seen" = "$ahead" ]; then ahead=$seen; [ "$since" -gt 0 ] || since=$now; seen=$since; fi`, + `if [ "$seen" -gt "$started" ]; then started=$seen; fi`, + `fi`, + `left=$((started + ${window} - now))`, + `[ "$left" -gt 0 ] || break`, + `echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) update: waiting \${left}s for the resumed machine to settle"`, + `sleep "$left"`, + `woke=$(date +%s)`, + `if [ $((woke - now)) -gt $((left + ${GUEST_AGENT_UPDATES_RESUME_JUMP_SLACK_SECONDS})) ]; then since=$woke; fi`, + `done`, + `io=""`, + `if command -v ionice >/dev/null 2>&1; then io="ionice -t -c 3"; fi`, + `exec nice -n 19 $io ${guestAgentUpdaterCommand(paths.configDir, paths.updater)}`, + ].join("\n"); +} + /** * The root shell script behind {@link guestAgentUpdatesCommand}: record the * setting atomically and, for "latest", start the updater detached (its own * session, no stdin, output appended to the log) so the script returns at once. + * The detached run is {@link guestAgentUpdaterLaunchScript}. */ export function guestAgentUpdatesScript( setting: VmAgentUpdatesSetting, @@ -570,7 +639,7 @@ export function guestAgentUpdatesScript( `mv -f "$tmp" ${shellQuote(`${configDir}/agent-updates`)}`, ]; const launch = setting === "latest" - ? [`(setsid nohup ${guestAgentUpdaterCommand(configDir, paths.updater)} >${shellQuote(paths.log)} 2>&1 &)`] + ? [`(setsid nohup sh -c ${shellQuote(guestAgentUpdaterLaunchScript(paths))} >${shellQuote(paths.log)} 2>&1 &)`] : []; return [...record, ...launch].join(" && "); } diff --git a/web/services/vms/images/devbox/README.md b/web/services/vms/images/devbox/README.md index 31102cb4e6e2..8191384320cd 100644 --- a/web/services/vms/images/devbox/README.md +++ b/web/services/vms/images/devbox/README.md @@ -51,7 +51,11 @@ A machine can instead keep its agents current: with `agentUpdates: "latest"` (New Machine's "Keep coding agents up to date", checked by default in the sheet, `cmux vm agent-updates latest`, or `PUT /api/vm/{id}/agent-updates`), create and each attach start a detached updater -(`web/services/vms/guestAgentUpdates.ts`). It never uses npm: for every agent +(`web/services/vms/guestAgentUpdates.ts`). On a clone that resumed less than +10 minutes ago (the boot supervisor's `HOUSEKEEPING_DELAY`) it waits out that +window so it never competes with the daemon's start. It runs at `nice 19`, +plus the idle I/O class when `ionice` is present (only the BFQ scheduler +honors that class). It never uses npm: for every agent it reads the tool's GitHub releases (`web/services/vms/images/agents.ts`) and installs the newest x.y.z release that has been public for 3 days and is not above the latest release, after checking the download's sha256, at most once a diff --git a/web/tests/vm-agent-updates.test.ts b/web/tests/vm-agent-updates.test.ts index c9ebd0dfb494..f26ee710d991 100644 --- a/web/tests/vm-agent-updates.test.ts +++ b/web/tests/vm-agent-updates.test.ts @@ -15,10 +15,12 @@ import { rmSync, statSync, symlinkSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { AGENT_PIN_ARGS, devboxAgentPins } from "../scripts/devbox-image-common"; import { runChild } from "./helpers/run-child"; @@ -26,12 +28,16 @@ import { parseAgentUpdatesBody, parseCreateAgentUpdates } from "../services/vms/ import { GUEST_AGENTS, GUEST_AGENT_UPDATES_LOG, + GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, GUEST_AGENT_UPDATER_DEFAULTS, + GUEST_CLONE_STARTED_MARKER, guestAgentInstallCommand, guestAgentUpdaterCommand, + guestAgentUpdaterLaunchScript, guestAgentUpdatesCommand, guestAgentUpdatesScript, type GuestAgentUpdaterOptions, + type GuestAgentUpdatesPaths, } from "../services/vms/guestAgentUpdates"; import { GUEST_AGENT_UPDATE_DOMAINS, type GuestAgent } from "../services/vms/images/agents"; import { CMUX_REQUIRED_DOMAINS } from "../services/vms/networkPolicy"; @@ -288,6 +294,84 @@ function state(g: Guest): { checkedAt: string; ok: boolean; versions: Record `${jump}\n`).join("")); + const calls = path.join(g.github, "calls.log"); + const marker = path.join(g.root, "clone-started"); + const rewrite = path.join(dir, "rewrite"); + const realDate = existsSync("/bin/date") ? "/bin/date" : "/usr/bin/date"; + const epoch = 1_800_000_000; + if (opts.rewriteMarkerAt !== undefined) { + writeFileSync(rewrite, ""); + utimesSync(rewrite, epoch + opts.rewriteMarkerAt, epoch + opts.rewriteMarkerAt); + } + writeFileSync(path.join(dir, "date"), [ + "#!/bin/sh", + `if [ "$#" = 1 ] && [ "$1" = "+%s" ]; then echo $(( ${epoch} + $(cat '${dir}/offset') )); else exec ${realDate} "$@"; fi`, + "", + ].join("\n")); + writeFileSync(path.join(dir, "sleep"), [ + "#!/bin/sh", + `fetched=0; [ -f '${calls}' ] && fetched=$(grep -c . '${calls}')`, + `echo "$1 $fetched" >> '${dir}/sleeps'`, + `jump=0; if [ -s '${dir}/jumps' ]; then jump=$(head -n 1 '${dir}/jumps'); tail -n +2 '${dir}/jumps' > '${dir}/jumps.next'; mv '${dir}/jumps.next' '${dir}/jumps'; fi`, + `echo $(( $(cat '${dir}/offset') + $1 + jump )) > '${dir}/offset'`, + `if [ -f '${rewrite}' ]; then touch -r '${rewrite}' '${marker}'; rm -f '${rewrite}'; fi`, + "", + ].join("\n")); + chmodSync(path.join(dir, "date"), 0o755); + chmodSync(path.join(dir, "sleep"), 0o755); + return { + env: { ...g.env, PATH: `${dir}:${g.env.PATH}` }, + /** A clone marker in `g`'s tree written `offsetSeconds` from the fake clock's start. */ + marker(offsetSeconds: number): string { + writeFileSync(marker, ""); + utimesSync(marker, epoch + offsetSeconds, epoch + offsetSeconds); + return marker; + }, + sleeps(): { seconds: number; fetchedBefore: number }[] { + const file = path.join(dir, "sleeps"); + if (!existsSync(file)) return []; + return readFileSync(file, "utf8").trim().split("\n").map((line) => { + const [seconds, fetchedBefore] = line.split(" ").map(Number); + return { seconds: seconds!, fetchedBefore: fetchedBefore! }; + }); + }, + }; +} + /** What `binary` on PATH runs now: its --version output, through the real links. */ async function runs(g: Guest, binary: string): Promise { const entry = binary === "opencode" ? path.join(g.options.libexecDir, "cmux-opencode-real") : path.join(g.options.binDir, binary); @@ -317,7 +401,12 @@ describe("guest agent updates", () => { expect(image).not.toContain("setsid"); const latest = guestAgentUpdatesCommand("latest"); expect(latest).toContain("latest > \"$tmp\""); - expect(latest).toContain("setsid nohup python3 -c"); + expect(latest).toContain("setsid nohup sh -c"); + expect(latest).toContain("python3 -c"); + // Off the daemon's start on a resumed clone, and at idle priority. + expect(latest).toContain(GUEST_CLONE_STARTED_MARKER); + expect(latest).toContain("nice -n 19"); + expect(latest).toContain("ionice -t -c 3"); expect(latest).toContain(GUEST_AGENT_UPDATES_LOG); // Root runs it directly; the work user goes through passwordless sudo. expect(latest.startsWith(`if [ "$(id -u)" = 0 ]; then sh -c `)).toBe(true); @@ -331,13 +420,8 @@ describe("guest agent updates", () => { const g = await guest(null); publish(g, "codex", "2.0.0"); const log = path.join(g.root, "updates.log"); - const paths = { configDir: g.configDir, log, updater: g.options }; - // setsid is util-linux; stand in for it where the test host has none (macOS). - const shims = path.join(g.root, "shims"); - mkdirSync(shims); - writeFileSync(path.join(shims, "setsid"), "#!/bin/sh\nexec \"$@\"\n"); - chmodSync(path.join(shims, "setsid"), 0o755); - const env = { ...g.env, PATH: `${g.env.PATH}:${shims}` }; + const paths = launchPaths(g, log); + const env = shimmedEnv(g); const recorded = await runChild("sh", ["-c", guestAgentUpdatesScript("image", paths)], { env }); expect(recorded.status).toBe(0); expect(readFileSync(path.join(g.configDir, "agent-updates"), "utf8")).toBe("image\n"); @@ -352,6 +436,108 @@ describe("guest agent updates", () => { expect(readFileSync(log, "utf8")).toContain("installing codex 2.0.0"); }); + test("the resume window equals the boot supervisor's housekeeping delay", () => { + const boot = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../services/vms/images/devbox/cmux-devbox-boot"), "utf8"); + const delay = /^HOUSEKEEPING_DELAY=(\d+)min$/m.exec(boot); + expect(delay).not.toBeNull(); + expect(Number(delay![1]) * 60).toBe(GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS); + expect(boot).toContain(': > "$TEMPLATE_RUN_DIR/clone-started"'); + expect(GUEST_CLONE_STARTED_MARKER).toBe("/run/cmux/clone-started"); + }); + + test("a just-resumed clone waits out its resume window before fetching anything", async () => { + const g = await guest(); + publish(g, "codex", "2.0.0"); + const clock = fakeClock(g); + const marker = clock.marker(0); + const result = await runChild("sh", ["-c", guestAgentUpdaterLaunchScript({ ...launchPaths(g, ""), cloneStartedMarker: marker })], { env: clock.env }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`update: waiting ${GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS}s for the resumed machine to settle`); + const sleeps = clock.sleeps(); + expect(sleeps).toEqual([{ seconds: GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, fetchedBefore: 0 }]); + expect(state(g)).toMatchObject({ ok: true, versions: { codex: "2.0.0" } }); + }); + + test("a resume during the wait (the clocks jump) starts a fresh window", async () => { + const g = await guest(); + publish(g, "codex", "2.0.0"); + const clock = fakeClock(g, { jumps: [3600] }); + const marker = clock.marker(0); + const result = await runChild("sh", ["-c", guestAgentUpdaterLaunchScript({ ...launchPaths(g, ""), cloneStartedMarker: marker })], { env: clock.env }); + expect(result.status).toBe(0); + const sleeps = clock.sleeps(); + expect(sleeps).toEqual([ + { seconds: GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, fetchedBefore: 0 }, + { seconds: GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, fetchedBefore: 0 }, + ]); + expect(state(g)).toMatchObject({ ok: true, versions: { codex: "2.0.0" } }); + }); + + test("a marker dated in the future waits one window, not until the clock catches up", async () => { + const g = await guest(); + publish(g, "codex", "2.0.0"); + const clock = fakeClock(g); + const marker = clock.marker(6 * 3600); + const result = await runChild("sh", ["-c", guestAgentUpdaterLaunchScript({ ...launchPaths(g, ""), cloneStartedMarker: marker })], { env: clock.env }); + expect(result.status).toBe(0); + expect(clock.sleeps()).toEqual([{ seconds: GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, fetchedBefore: 0 }]); + expect(state(g)).toMatchObject({ ok: true, versions: { codex: "2.0.0" } }); + }); + + test("a marker less than a window in the future still waits only one window", async () => { + const g = await guest(); + publish(g, "codex", "2.0.0"); + const clock = fakeClock(g); + const marker = clock.marker(300); + const result = await runChild("sh", ["-c", guestAgentUpdaterLaunchScript({ ...launchPaths(g, ""), cloneStartedMarker: marker })], { env: clock.env }); + expect(result.status).toBe(0); + expect(clock.sleeps()).toEqual([{ seconds: GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, fetchedBefore: 0 }]); + expect(state(g)).toMatchObject({ ok: true, versions: { codex: "2.0.0" } }); + }); + + test("the clock stepping back after a resume does not stretch the wait", async () => { + const g = await guest(); + publish(g, "codex", "2.0.0"); + // A resume jumps the clock an hour mid-wait, then it steps back an hour. + const clock = fakeClock(g, { jumps: [3600, -3600] }); + const marker = clock.marker(0); + const result = await runChild("sh", ["-c", guestAgentUpdaterLaunchScript({ ...launchPaths(g, ""), cloneStartedMarker: marker })], { env: clock.env }); + expect(result.status).toBe(0); + const window = { seconds: GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, fetchedBefore: 0 }; + expect(clock.sleeps()).toEqual([window, window, window]); + expect(state(g)).toMatchObject({ ok: true, versions: { codex: "2.0.0" } }); + }); + + test("a marker rewritten during the wait (a fork bound) starts its window over", async () => { + const g = await guest(); + publish(g, "codex", "2.0.0"); + // Halfway through the first wait a fork's boot supervisor rewrites the marker. + const clock = fakeClock(g, { rewriteMarkerAt: 300 }); + const marker = clock.marker(0); + const result = await runChild("sh", ["-c", guestAgentUpdaterLaunchScript({ ...launchPaths(g, ""), cloneStartedMarker: marker })], { env: clock.env }); + expect(result.status).toBe(0); + expect(clock.sleeps()).toEqual([ + { seconds: GUEST_AGENT_UPDATES_RESUME_WINDOW_SECONDS, fetchedBefore: 0 }, + { seconds: 300, fetchedBefore: 0 }, + ]); + expect(state(g)).toMatchObject({ ok: true, versions: { codex: "2.0.0" } }); + }); + + test("a clone resumed longer ago than the window, or no clone at all, updates at once", async () => { + for (const age of [-3600, null]) { + const g = await guest(); + publish(g, "codex", "2.0.0"); + const clock = fakeClock(g); + const paths = launchPaths(g, ""); + const marker = age === null ? paths.cloneStartedMarker : clock.marker(age); + const result = await runChild("sh", ["-c", guestAgentUpdaterLaunchScript({ ...paths, cloneStartedMarker: marker })], { env: clock.env }); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("waiting"); + expect(clock.sleeps()).toEqual([]); + expect(state(g)).toMatchObject({ ok: true, versions: { codex: "2.0.0" } }); + } + }); + test("installs only the agents behind the channel, verified, and keeps the bake's links", async () => { return (async () => { const g = await guest(); From 150b81d06f9d0d3a4786820558865e5a8fc872b0 Mon Sep 17 00:00:00 2001 From: Neha Chaudhari Date: Wed, 14 Oct 2026 05:00:25 -0400 Subject: [PATCH 033/183] Run a custom ui.newWorkspace.action from Cmd-N and the controls that share it (#20287) * Name the local-only New Workspace entry points apart from the built-in action * Add regressions for Cmd+N ignoring the configured new-workspace action * Run a custom ui.newWorkspace.action from Cmd+N and the controls that share it * fix: keep local-only workspace creation off remote tmux Pass the local-only intent through workspace creation so the built-in action cannot route Cmd-N and related controls to a selected remote-tmux mirror. Add a focused regression covering local creation on an active mirror. Co-authored-by: Neha Chaudhari <110523786+nebullii@users.noreply.github.com> --------- Co-authored-by: Austin Wang --- Sources/AppDelegate.swift | 37 +++-- Sources/ContentView.swift | 6 +- Sources/GhosttyHostActionHandler.swift | 2 +- Sources/Update/UpdateTitlebarAccessory.swift | 2 +- Sources/WindowDecorationsController.swift | 2 +- Sources/Workspace.swift | 2 +- Sources/cmuxApp.swift | 2 +- .../RemoteTmuxNewWorkspaceRoutingTests.swift | 40 ++++++ ...ebarEmptyAreaNewWorkspaceActionTests.swift | 134 ++++++++++++++++++ cmuxTests/SidebarNewLocalWorkspaceTests.swift | 2 +- 10 files changed, 212 insertions(+), 17 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ae1fefe64976..a3b087ddd313 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8553,18 +8553,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent event: NSEvent? = nil, placementOverride: WorkspacePlacement? = nil, debugSource: String = "newWorkspace", - skipConfiguredAction: Bool = false + skipConfiguredAction: Bool = false, + localOnly: Bool = false ) -> Bool { // This is the context-following path used by the ordinary New Workspace // controls. On an active remote-tmux mirror it follows the routing toggle; // `performNewLocalWorkspaceAction` is the explicit local escape hatch. + // + // `localOnly` is Cmd+N and the controls that share it: they never start + // a device or Cloud workspace from the selection, but a custom + // `ui.newWorkspace.action` is still what they create. `skipConfiguredAction` + // is the built-in action picked by name, which must not run the + // configured one in its place. + let staysLocal = skipConfiguredAction || localOnly let context = preferredTabManager.flatMap { mainWindowContext(for: $0) } ?? preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) let manager = context?.tabManager ?? preferredTabManager - if !skipConfiguredAction, let manager, let machine = manager.selectedWorkspace?.deviceMachineForNewWorkspace { + if !staysLocal, let manager, let machine = manager.selectedWorkspace?.deviceMachineForNewWorkspace { return deviceWorkspaceCreationCoordinator?.start(on: machine, in: manager) ?? false } - if !skipConfiguredAction, let manager, + if !staysLocal, let manager, let vmID = manager.selectedWorkspace?.cloudVMID, !vmID.isEmpty { // Once this intent targets a VM, an unavailable or pending cloud @@ -8577,8 +8585,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent event: event, placementOverride: placementOverride, debugSource: debugSource, - skipConfiguredAction: skipConfiguredAction, - inheritWorkingDirectoryOverride: skipConfiguredAction + skipConfiguredAction: staysLocal, + runsCustomConfiguredAction: localOnly && !skipConfiguredAction, + forceLocal: localOnly, + inheritWorkingDirectoryOverride: staysLocal ? manager.map { shouldInheritWorkingDirectoryForLocalWorkspace(in: $0) } : nil ) @@ -8853,9 +8863,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focusInitialBrowserAddressBarOnCreate: Bool = true, createdWorkspaceHandler: ((Workspace) -> Void)? = nil, skipConfiguredAction: Bool = false, + runsCustomConfiguredAction: Bool = false, forceLocal: Bool = false, inheritWorkingDirectoryOverride: Bool? = nil ) -> Bool { + // `runsCustomConfiguredAction` narrows `skipConfiguredAction`: the + // caller keeps its own plain workspace when the configured action is + // the built-in default, and yields only to a custom one. + let runsConfiguredAction: (MainWindowContext?) -> Bool = { context in + guard skipConfiguredAction else { return true } + guard runsCustomConfiguredAction, + let action = context?.cmuxConfigStore?.resolvedNewWorkspaceAction() else { return false } + if case .builtIn(.newWorkspace) = action.action { return false } + return true + } let preferredContext = preferredTabManager.flatMap { mainWindowContext(for: $0) } let livePreferredContext: MainWindowContext? = { guard let preferredContext else { return nil } @@ -8881,7 +8902,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let initialWorkspace = context.tabManager.selectedWorkspace switch initialSurface { case .terminal: - if !skipConfiguredAction { + if runsConfiguredAction(context) { _ = executeConfiguredNewWorkspaceActionIfAvailable( in: context, debugSource: debugSource, @@ -8930,7 +8951,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // workspace layout) keeps its existing meaning on a selected mirror. // The built-in `.newWorkspace` action is the default and deliberately // continues through the remote route below. - let configuredNewWorkspaceAction = initialSurface == .terminal && !skipConfiguredAction + let configuredNewWorkspaceAction = initialSurface == .terminal && runsConfiguredAction(context) ? context?.cmuxConfigStore?.resolvedNewWorkspaceAction() : nil let hasCustomConfiguredNewWorkspaceAction: Bool = { @@ -15712,7 +15733,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent performNewWorkspaceAction( event: event, debugSource: "shortcut.cmdN", - skipConfiguredAction: true + localOnly: true ) return true } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 644e063931da..89390ab93b9b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1713,7 +1713,7 @@ struct ContentView: View { AppDelegate.shared?.performNewWorkspaceAction( tabManager: tabManager, debugSource: "titlebar.hiddenNewWorkspace", - skipConfiguredAction: true + localOnly: true ) }, observedWindowReference: observedWindowReference, @@ -2066,7 +2066,7 @@ struct ContentView: View { AppDelegate.shared?.performNewWorkspaceAction( tabManager: tabManager, debugSource: "titlebar.fullscreenNewWorkspace", - skipConfiguredAction: true + localOnly: true ) }, onFocusHistoryBack: { @@ -8958,7 +8958,7 @@ struct ContentView: View { AppDelegate.shared?.performNewWorkspaceAction( tabManager: tabManager, debugSource: "palette.newWorkspace", - skipConfiguredAction: true + localOnly: true ) } registry.register(commandId: "palette.newBrowserWorkspace") { diff --git a/Sources/GhosttyHostActionHandler.swift b/Sources/GhosttyHostActionHandler.swift index c39741b39644..242a20495b10 100644 --- a/Sources/GhosttyHostActionHandler.swift +++ b/Sources/GhosttyHostActionHandler.swift @@ -29,7 +29,7 @@ struct GhosttyHostActionHandler { return app.performNewWorkspaceAction( tabManager: context.manager, debugSource: "ghostty.new_tab", - skipConfiguredAction: true + localOnly: true ) case .selectWorkspace(let index): guard let manager = context.manager else { break } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 47cfd3f898b8..f1f1eb5ef67f 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1994,7 +1994,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont _ = appDelegate.performNewWorkspaceAction( tabManager: context.tabManager, debugSource: "titlebar.accessoryNewWorkspace", - skipConfiguredAction: true + localOnly: true ) } let focusHistoryBack = { diff --git a/Sources/WindowDecorationsController.swift b/Sources/WindowDecorationsController.swift index 134f7d8fe0b8..6fd8528569bd 100644 --- a/Sources/WindowDecorationsController.swift +++ b/Sources/WindowDecorationsController.swift @@ -355,7 +355,7 @@ final class WindowDecorationsController { _ = appDelegate.performNewWorkspaceAction( tabManager: context.tabManager, debugSource: "titlebar.minimalSidebarControl", - skipConfiguredAction: true + localOnly: true ) case .newWorkspaceMenu: if let anchorView { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d08774c5ab86..0946bef99ca6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -15285,7 +15285,7 @@ extension Workspace: BonsplitDelegate { _ = AppDelegate.shared?.performNewWorkspaceAction( tabManager: owningTabManager, debugSource: "surfaceTabBar.newWorkspace", - skipConfiguredAction: true + localOnly: true ) case .newAgentChat: performSurfaceTabBarNewAgentChatAction(presentingWindow: presentingWindow) case .cloudVM: diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0c1248bfd3e2..6becaeafaafa 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -891,7 +891,7 @@ struct cmuxApp: App { appDelegate.performNewWorkspaceAction( tabManager: activeTabManager, debugSource: "menu.newWorkspace", - skipConfiguredAction: true + localOnly: true ) } else { activeTabManager.addWorkspaceIfActive() diff --git a/cmuxTests/RemoteTmuxNewWorkspaceRoutingTests.swift b/cmuxTests/RemoteTmuxNewWorkspaceRoutingTests.swift index 2a8a984ae31a..873a858ba187 100644 --- a/cmuxTests/RemoteTmuxNewWorkspaceRoutingTests.swift +++ b/cmuxTests/RemoteTmuxNewWorkspaceRoutingTests.swift @@ -140,6 +140,46 @@ struct RemoteTmuxNewWorkspaceRoutingTests { } } + /// Plain New Workspace stays local on an active mirror when its caller is + /// marked local-only, even though the built-in action remains available. + @Test func localOnlyNewWorkspaceOnActiveMirrorCreatesLocalWorkspace() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + _ = NSApplication.shared + let appDelegate = try #require(AppDelegate.shared) + let controller = appDelegate.remoteTmuxController + let restoreRouting = RemoteTmuxRoutingFixture.enableNewWorkspaceRouting() + defer { restoreRouting() } + let restoreSSH = RemoteTmuxRoutingFixture.pinStubSSH("/usr/bin/false") + defer { restoreSSH() } + let manager = TabManager() + let mirrorWorkspace = try mirrorSelectedSession(controller: controller, into: manager) + let (windowId, window) = RemoteTmuxRoutingFixture.registerWindowedContext( + appDelegate: appDelegate, manager: manager + ) + defer { + window.close() + manager.window = nil + if controller.sessionMirror(host: host, sessionName: "esc") != nil { + controller.detach(host: host, sessionName: "esc") + } + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + } + + #expect(manager.selectedTab?.id == mirrorWorkspace.id) + let tabsBefore = Set(manager.tabs.map(\.id)) + #expect(appDelegate.performNewWorkspaceAction( + tabManager: manager, + debugSource: "test.localOnlyNewWorkspace", + localOnly: true + )) + + let createdIds = Set(manager.tabs.map(\.id)).subtracting(tabsBefore) + #expect(createdIds.count == 1) + let created = try #require(manager.tabs.first { createdIds.contains($0.id) }) + #expect(!created.isRemoteTmuxMirror) + } + } + /// A configured New Workspace action remains an explicit override on a /// mirror. Only the built-in default action is allowed to fall through to /// remote tmux routing. diff --git a/cmuxTests/SidebarEmptyAreaNewWorkspaceActionTests.swift b/cmuxTests/SidebarEmptyAreaNewWorkspaceActionTests.swift index 5c93cf02a641..ca9f80ca3476 100644 --- a/cmuxTests/SidebarEmptyAreaNewWorkspaceActionTests.swift +++ b/cmuxTests/SidebarEmptyAreaNewWorkspaceActionTests.swift @@ -217,3 +217,137 @@ struct SidebarEmptyAreaNewWorkspaceActionTests { return true } } + +// MARK: - Cmd+N and the controls that share it + +/// Cmd+N, File → New Workspace, the titlebar `+`, the command palette and +/// Ghostty's `new_tab` all create a local workspace, whatever is selected. +/// Staying local must not also drop the user's `ui.newWorkspace.action`: +/// since 0.65.1 they opened a blank terminal with one configured. +/// https://github.com/manaflow-ai/cmux/issues/20268 +extension SidebarEmptyAreaNewWorkspaceActionTests { + private static let configuredLayoutJSON = """ + { + "actions": { + "cmd-n-layout": { + "type": "workspace", + "title": "Cmd N Layout", + "workspace": { "name": "CmdNLayout" } + } + }, + "ui": { "newWorkspace": { "action": "cmd-n-layout" } } + } + """ + + @Test func localOnlyNewWorkspaceAppliesConfiguredNewWorkspaceLayout() throws { + let appDelegate = try #require(AppDelegate.shared) + let fixture = try ConfigFixture(globalJSON: Self.configuredLayoutJSON) + defer { fixture.cleanUp() } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + let manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) + let context = try #require( + appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + ) + context.cmuxConfigStore = fixture.store + let knownIds = Set(manager.tabs.map(\.id)) + + #expect(appDelegate.performNewWorkspaceAction( + tabManager: manager, + debugSource: "test.cmdN.configuredAction", + localOnly: true + )) + let created = try createdWorkspace(in: manager, knownIds: knownIds) + waitUntil { created.customTitle != nil } + + #expect( + created.customTitle == "CmdNLayout", + Comment( + rawValue: "Cmd+N must honor ui.newWorkspace.action, got title " + + "\(created.customTitle ?? "")" + ) + ) + } + + /// A Cloud workspace being selected must not send Cmd+N to the Cloud, + /// with or without a configured action. + @Test func localOnlyNewWorkspaceWithConfiguredActionStaysOffTheSelectedCloudMachine() throws { + let appDelegate = try #require(AppDelegate.shared) + let fixture = try ConfigFixture(globalJSON: Self.configuredLayoutJSON) + defer { fixture.cleanUp() } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + let manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) + let context = try #require( + appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + ) + context.cmuxConfigStore = fixture.store + let selected = try #require(manager.selectedWorkspace) + selected.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "cloud-machine", isBase: false) + let knownIds = Set(manager.tabs.map(\.id)) + + #expect(appDelegate.performNewWorkspaceAction( + tabManager: manager, + debugSource: "test.cmdN.configuredAction.cloudSelected", + localOnly: true + )) + let created = try createdWorkspace(in: manager, knownIds: knownIds) + waitUntil { created.customTitle != nil } + + #expect(created.customTitle == "CmdNLayout") + #expect(created.cloudVMID == nil) + } + + @Test func localOnlyNewWorkspaceWithoutConfiguredActionCreatesPlainWorkspace() throws { + let appDelegate = try #require(AppDelegate.shared) + let fixture = try ConfigFixture(globalJSON: "{}") + defer { fixture.cleanUp() } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + let manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) + let context = try #require( + appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + ) + context.cmuxConfigStore = fixture.store + let knownIds = Set(manager.tabs.map(\.id)) + + #expect(appDelegate.performNewWorkspaceAction( + tabManager: manager, + debugSource: "test.cmdN.noConfiguredAction", + localOnly: true + )) + let created = try createdWorkspace(in: manager, knownIds: knownIds) + + #expect(created.customTitle == nil) + #expect(created.id == manager.selectedTabId) + } + + /// Picking the built-in New Workspace by name, as the `+` button's menu + /// does, means the plain workspace and not the configured one. + @Test func builtInNewWorkspaceByNameSkipsTheConfiguredAction() throws { + let appDelegate = try #require(AppDelegate.shared) + let fixture = try ConfigFixture(globalJSON: Self.configuredLayoutJSON) + defer { fixture.cleanUp() } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + let manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) + let context = try #require( + appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + ) + context.cmuxConfigStore = fixture.store + let knownIds = Set(manager.tabs.map(\.id)) + + #expect(appDelegate.performNewWorkspaceAction( + tabManager: manager, + debugSource: "test.contextMenu.builtInNewWorkspace", + skipConfiguredAction: true + )) + let created = try createdWorkspace(in: manager, knownIds: knownIds) + + #expect(created.customTitle == nil) + } +} diff --git a/cmuxTests/SidebarNewLocalWorkspaceTests.swift b/cmuxTests/SidebarNewLocalWorkspaceTests.swift index e175c8118859..1a0cee798171 100644 --- a/cmuxTests/SidebarNewLocalWorkspaceTests.swift +++ b/cmuxTests/SidebarNewLocalWorkspaceTests.swift @@ -180,7 +180,7 @@ struct SidebarNewLocalWorkspaceTests { #expect(fixture.app.performNewWorkspaceAction( tabManager: fixture.manager, debugSource: "test.context.cmdN", - skipConfiguredAction: true + localOnly: true )) let created = try #require(fixture.manager.selectedWorkspace) From 4158d1402ecc2c69b5dfe19c6e0017106ea9e415 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:03:22 -0700 Subject: [PATCH 034/183] Realtime Cloud machines list through the presence vms sync collection (#15568) * presence: backend-published vms sync collection Add the team-wide `vms` sync collection to the presence Worker so the Mac machines list can subscribe instead of polling. The web backend publishes upsert/delete/replace ops to POST /v1/sync/vms, authenticated only by the service secret VMS_PUBLISHER_SECRET (cron writers hold no user bearer); the TeamPresence DO applies them in one storage transaction and broadcasts sync deltas to `vms` subscribers. Ops carry the row's updated_at as sourceUpdatedAtMs, older ops are rejected, and a replace tombstones ids missing from the observed list at the observation time. Tombstones keep the source clock (additive payload option on tombstoneRecord) and are GC'd by the existing alarm. No new DO migration. Co-Authored-By: Claude Fable 5.1 * web: publish Cloud machine list changes to the presence vms collection Add services/vms/presencePublisher.ts: a decorator around the live VM repository that, after each list-relevant write commits (create running/ failed/abandoned, cleanup resolved, provider-observed status, paused resume, display name, destroyed, provider metadata), re-reads the row by id and publishes an upsert (or a delete once destroyed) to POST /v1/sync/vms on the presence Worker, best-effort after the response with a 750 ms timeout and never failing the mutation. The DO name is the list scope (ownerTeamId). GET /api/vm publishes a full replace of the rows it just read, stamped with the observation time, so the collection converges without a backfill job. Managed publication slug assignment and account deletion (personal hard delete, shared-team creator rewrite) publish too. VmEntry gains updatedAt as the freshness clock. New env CMUX_PRESENCE_VMS_PUBLISHER_SECRET (must equal the Worker's VMS_PUBLISHER_SECRET); unset skips publication. Co-Authored-By: Claude Fable 5.1 * presence: flag unbackfilled vms snapshots Snapshot frames for the vms collection carry backfilled: false until the first replace has landed in the DO, so a client applies an early snapshot upsert-only instead of dropping machines it lacks. Other collections are unchanged. Co-Authored-By: Claude Fable 5.1 * web: await vms sync publish outside a request scope Cron and reconcile writers run with no Next.js request scope, where after() throws and detached work is frozen with the Vercel function. The publisher now tries after() and, when it is unavailable, awaits the bounded publish inline before the repository write resolves; request-scoped writers keep the after() path. Tests prove the cron path awaits and never rejects. Co-Authored-By: Claude Fable 5.1 * cloud: share the VM list entry decoder and make VMSummary Equatable VMClient.decodeListItem is the one parser for a GET /api/vm entry, so the presence vms sync consumer decodes the same shape without a second copy. VMSummary and VMBaseSummary compare by value so sync records can. Co-Authored-By: Claude Fable 5.1 * devices: presence hello takes a collection list and parses vms frames DevicePresenceSubscriber and DevicePresenceFrame.syncHello take the sync collections to request (default [.devices], so the device directory is unchanged). sync.snapshot and sync.delta frames for the vms collection decode into VMSyncRecord through VMClient.decodeListItem; snapshot frames expose backfilled, snapshotRev and epoch, deltas expose rev, tombstones carry no summary, and a live record the parser rejects is dropped rather than turned into a delete. Co-Authored-By: Claude Fable 5.1 * cloud: machines panel merges realtime vms sync changes CloudVMSyncSubscriber owns one presence socket per visible machines panel with a vms-only hello from the last applied cursor and epoch, the same X-Cmux-Team-Id header VMClient sends, DeviceDirectory's reconnect backoff and a token source that fails closed on an account or team change. MachinesPanelViewModel starts it with polling and stops it with polling, fences frames on the refresh generation and pin scope, and merges them with applyVMChanges: upserts replace rows in place or insert in createdAt-descending order, tombstones remove, a backfilled snapshot is the complete fleet and an unbackfilled one never deletes. A REST list still runs on show, wake, activation, online and every stream (re)connect and remains the only source of limits; the routine poll stretches from 45 s to 5 minutes while the stream is connected. Co-Authored-By: Claude Fable 5.1 * cloud: a retiring stream stop does not re-arm the poll loop Co-Authored-By: Claude Fable 5.1 * cloud: run the vms stream tests and cover list-only fields on a live update The stream merge tests were XCTest methods inside a Swift Testing struct, so nothing ran them. Move them into their own XCTestCase and add a failing case: a streamed upsert drops the creator name, pool claim, agent updates and free-access expiry that only the REST list carries. Co-Authored-By: Claude Opus 5.5 * cloud: a live machine update keeps the list-only fields of its row A vms stream record carries the row's own columns. Keep the creator name, pool claim, reservation, agent updates and free-access expiry the REST list supplied until the next list read, and name a new row's creator from the record's createdByUserId. Co-Authored-By: Claude Opus 5.5 * web: account deletion never fails on machines-list publication Build the vms tombstones after the deletion commits and contain any error, and give the publisher test fixture main's new cloud_vms columns. Co-Authored-By: Claude Opus 5.5 * web: track main's restart capability; wait on a signal, not a sleep, in the publisher test Co-Authored-By: Claude Opus 5.5 * cloud: make the create-attach endpoint Equatable so VMSummary stays Equatable Main added createAttach to VMSummary; the stream merge compares summaries. Co-Authored-By: Claude Opus 5.5 * presence: cover a replace racing a create that committed after the list read Co-Authored-By: Claude Opus 5.5 * presence: a replace keeps a record written just before the list was observed The list read can run before a concurrent write commits; tombstoning that record at the observation time hid the machine from the stream until its row changed again. Co-Authored-By: Claude Opus 5.5 * web: give the max-memory workflow fixtures an updatedAt vmEntryFromRow now reports updatedAt for the machines-list stream. Co-Authored-By: Claude Opus 5.5 * cloud: a streamed machine update honors a pending rename Co-Authored-By: Claude Opus 5.5 * web: the publisher's cron-path tests pass their own request-less after() Other suites mock next/server process-wide, so in a full run after() no longer threw and the tests saw the request-scoped path. Co-Authored-By: Claude Opus 5.5 * cloud: the vms stream must subscribe to the team presence object Main scoped every presence subscribe to the account (My Devices), which the machines stream shares, so it now listens on the per-user object while the web backend publishes vms into the team's. Add an explicit stream to the subscriber and a failing test for the machines stream's scope. Co-Authored-By: Claude Opus 5.5 * cloud: subscribe the machines stream to the team presence object Co-Authored-By: Claude Opus 5.5 * cloud: cover a REST read overwriting a newer stream change Co-Authored-By: Claude Opus 5.5 * cloud: replay stream changes that land during a REST read A list read that started before a stream change used to replace the rows with its older page, so a streamed delete came back and an update was undone until the next event or poll. Record stream changes while a read is in flight and replay them on top of its page. Co-Authored-By: Claude Opus 5.5 * cloud: cover a malformed vms snapshot replacing the fleet Co-Authored-By: Claude Opus 5.5 * cloud: a malformed vms snapshot never replaces the fleet A snapshot is accepted only with a records array whose every record decodes. Otherwise the subscriber drops it, keeps its cursor and resubscribes, and the REST list stays the authority. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Austin Wang <38676809+austinywang@users.noreply.github.com> --- .../VMClient/VMClient+CloudActivation.swift | 93 +++-- .../Sources/CmuxCloud/VMClient/VMClient.swift | 16 +- Sources/Cloud/CloudVMSyncSubscriber.swift | 238 ++++++++++++ Sources/Cloud/MachinesPanelView.swift | 1 + .../MachinesPanelViewModel+Refresh.swift | 15 +- Sources/Cloud/MachinesPanelViewModel.swift | 197 +++++++++- Sources/Devices/DeviceDirectory.swift | 4 +- Sources/Devices/DevicePresenceFrame.swift | 105 ++++- .../Devices/DevicePresenceSubscriber.swift | 50 ++- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/DevicePresenceWireTests.swift | 135 +++++++ cmuxTests/MachinesPanelModelTests.swift | 159 ++++++++ docs/presence-service.md | 48 +++ web/.env.example | 3 + web/app/api/account/route.ts | 43 ++- web/app/api/vm/route.ts | 8 + web/app/env.ts | 7 + .../vm-publications/managedRepository.ts | 20 +- web/services/vms/presencePublisher.ts | 329 ++++++++++++++++ web/services/vms/workflows.ts | 10 +- web/tests/vm-max-memory-workflow.test.ts | 4 + web/tests/vm-presence-publisher.test.ts | 322 ++++++++++++++++ workers/presence/README.md | 36 +- workers/presence/scripts/deploy-dev.sh | 13 +- workers/presence/src/do.ts | 65 +++- workers/presence/src/index.ts | 24 ++ workers/presence/src/sync.ts | 5 + workers/presence/src/syncStorage.ts | 17 +- workers/presence/src/syncVms.ts | 360 ++++++++++++++++++ workers/presence/src/validate.ts | 24 +- workers/presence/test/syncVms.test.ts | 344 +++++++++++++++++ workers/presence/wrangler.dev.toml | 2 + workers/presence/wrangler.toml | 2 + 33 files changed, 2602 insertions(+), 101 deletions(-) create mode 100644 Sources/Cloud/CloudVMSyncSubscriber.swift create mode 100644 web/services/vms/presencePublisher.ts create mode 100644 web/tests/vm-presence-publisher.test.ts create mode 100644 workers/presence/src/syncVms.ts create mode 100644 workers/presence/test/syncVms.test.ts diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+CloudActivation.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+CloudActivation.swift index 8356d37868b0..b5f4cbc4944d 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+CloudActivation.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+CloudActivation.swift @@ -48,43 +48,8 @@ extension VMClient { resourcePool: CloudVMResourcePool(limits: rawLimits) ) } - let vms = try items.enumerated().map { index, dict -> VMSummary in - guard let id = dict["id"] as? String, !id.isEmpty else { - throw VMClientError.malformedResponse("Cloud VM list response was missing required fields for item \(index).") - } - guard let provider = dict["provider"] as? String, !provider.isEmpty else { - throw VMClientError.malformedResponse("Cloud VM list response was missing required fields for item \(index).") - } - guard let image = dict["image"] as? String, !image.isEmpty else { - throw VMClientError.malformedResponse("Cloud VM list response was missing required fields for item \(index).") - } - let rawStatus = (dict["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let displayStatus = rawStatus.flatMap { $0.isEmpty ? nil : $0 } ?? "unknown" - let createdAt = (dict["createdAt"] as? Int64) - ?? Int64((dict["createdAt"] as? Double) ?? 0) - var summary = VMSummary(id: id, provider: provider, status: displayStatus, image: image, createdAt: createdAt, base: decodeBaseSummary(dict["base"])) - summary.kind = Self.decodeKind(dict["kind"]) - summary.capabilities = VMCapabilities(vmResponse: dict) - if let label = dict["displayName"] as? String, !label.isEmpty { - summary.displayName = label - } - summary.slug = (dict["slug"] as? String).flatMap { $0.isEmpty ? nil : $0 } - summary.createdBy = VMCreator(vmResponse: dict) - summary.agentUpdates = CloudAgentUpdates(wireValue: dict["agentUpdates"]) - // `resources` is the pool claim used by limits.used*. A - // legacy row may conservatively claim the provider maximum; - // only the explicit marker is safe as the live grow-only - // shape. Native menus use guest stats when that marker is - // absent, while the CLI fails closed until reconciliation. - summary.resourcePoolClaim = Self.decodeResourceReservation(dict["resources"]) - summary.resourceReservation = Self.decodeResourceReservation(dict["resourceReservation"]) - summary.freeAccessExpiresAt = Self.epochMilliseconds(dict["freeAccessExpiresAt"]) - if let address = dict["address"] as? [String: Any] { - summary.addressIPv4 = (address["ipv4"] as? String).flatMap { $0.isEmpty ? nil : $0 } - summary.addressIPv6 = (address["ipv6"] as? String).flatMap { $0.isEmpty ? nil : $0 } - } - summary.cmuxTuiContract = (dict["cmuxTuiContract"] as? String).flatMap { $0.isEmpty ? nil : $0 } - return summary + let vms = try items.enumerated().map { index, dict in + try Self.decodeListItem(dict, index: index) } machineCache.record(hasAnyMachine: !vms.isEmpty) // Background discovery also reads resource stats. Register its @@ -102,6 +67,60 @@ extension VMClient { } } + /// Decodes one `GET /api/vm` list entry into a ``VMSummary``. + /// + /// The presence worker's `vms` sync collection carries the same entry + /// shape (`workers/presence/src/syncVms.ts` `VmRecord`), so the Mac list's + /// realtime consumer decodes its records here too; fields only the REST + /// list serves stay nil when absent. + /// + /// - Parameters: + /// - dict: The decoded JSON object of one entry. + /// - index: The entry's position, named in the error for a malformed entry. + /// - Throws: ``VMClientError/malformedResponse(_:)`` when `id`, `provider` or `image` is missing. + public static func decodeListItem(_ dict: [String: Any], index: Int = 0) throws -> VMSummary { + guard let id = dict["id"] as? String, !id.isEmpty else { + throw VMClientError.malformedResponse("Cloud VM list response was missing required fields for item \(index).") + } + guard let provider = dict["provider"] as? String, !provider.isEmpty else { + throw VMClientError.malformedResponse("Cloud VM list response was missing required fields for item \(index).") + } + guard let image = dict["image"] as? String, !image.isEmpty else { + throw VMClientError.malformedResponse("Cloud VM list response was missing required fields for item \(index).") + } + let rawStatus = (dict["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayStatus = rawStatus.flatMap { $0.isEmpty ? nil : $0 } ?? "unknown" + let createdAt = (dict["createdAt"] as? Int64) + ?? Int64((dict["createdAt"] as? Double) ?? 0) + var summary = VMSummary(id: id, provider: provider, status: displayStatus, image: image, createdAt: createdAt, base: Self.decodeBaseSummary(dict["base"])) + summary.kind = Self.decodeKind(dict["kind"]) + summary.capabilities = VMCapabilities(vmResponse: dict) + if let label = dict["displayName"] as? String, !label.isEmpty { + summary.displayName = label + } + summary.slug = (dict["slug"] as? String).flatMap { $0.isEmpty ? nil : $0 } + // The `vms` stream record names the creator by id only. + summary.createdBy = VMCreator(vmResponse: dict) + ?? (dict["createdByUserId"] as? String) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap { $0.isEmpty ? nil : VMCreator(userId: $0) } + summary.agentUpdates = CloudAgentUpdates(wireValue: dict["agentUpdates"]) + // `resources` is the pool claim used by limits.used*. A + // legacy row may conservatively claim the provider maximum; + // only the explicit marker is safe as the live grow-only + // shape. Native menus use guest stats when that marker is + // absent, while the CLI fails closed until reconciliation. + summary.resourcePoolClaim = Self.decodeResourceReservation(dict["resources"]) + summary.resourceReservation = Self.decodeResourceReservation(dict["resourceReservation"]) + summary.freeAccessExpiresAt = Self.epochMilliseconds(dict["freeAccessExpiresAt"]) + if let address = dict["address"] as? [String: Any] { + summary.addressIPv4 = (address["ipv4"] as? String).flatMap { $0.isEmpty ? nil : $0 } + summary.addressIPv6 = (address["ipv6"] as? String).flatMap { $0.isEmpty ? nil : $0 } + } + summary.cmuxTuiContract = (dict["cmuxTuiContract"] as? String).flatMap { $0.isEmpty ? nil : $0 } + return summary + } + /// Enroll (or refresh) this Mac's WireGuard tunnel into the user's private /// Cloud VM network. Idempotent per device: safe to call on every launch. /// The server never sees a private key — only `clientPublicKey` travels. diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift index 284720dec94d..4b9df683908c 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift @@ -293,7 +293,7 @@ private func limitedSingleLine(_ value: String, maxCharacters: Int = 1200) -> St return String(singleLine[.. 0 ? serverCreatedAt : Int64(Date().timeIntervalSince1970 * 1000) let rawStatus = (obj["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let displayStatus = rawStatus.flatMap { $0.isEmpty ? nil : $0 } ?? "running" - var summary = VMSummary(id: id, provider: providerValue, status: displayStatus, image: imageValue, createdAt: createdAt, base: decodeBaseSummary(obj["base"])) + var summary = VMSummary(id: id, provider: providerValue, status: displayStatus, image: imageValue, createdAt: createdAt, base: Self.decodeBaseSummary(obj["base"])) summary.kind = Self.decodeKind(obj["kind"]) summary.capabilities = VMCapabilities(vmResponse: obj) machineCache.record(hasAnyMachine: true) @@ -1818,7 +1818,7 @@ public actor VMClient { let createdAt = (obj["createdAt"] as? Int64) ?? Int64((obj["createdAt"] as? Double) ?? 0) let rawStatus = (obj["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let displayStatus = rawStatus.flatMap { $0.isEmpty ? nil : $0 } ?? "unknown" - var summary = VMSummary(id: id, provider: provider, status: displayStatus, image: image, createdAt: createdAt, base: decodeBaseSummary(obj["base"])) + var summary = VMSummary(id: id, provider: provider, status: displayStatus, image: image, createdAt: createdAt, base: Self.decodeBaseSummary(obj["base"])) summary.kind = Self.decodeKind(obj["kind"]) summary.capabilities = VMCapabilities(vmResponse: obj) if let label = obj["displayName"] as? String, !label.isEmpty { @@ -2410,7 +2410,7 @@ public actor VMClient { } } - func decodeBaseSummary(_ raw: Any?) -> VMBaseSummary? { + static func decodeBaseSummary(_ raw: Any?) -> VMBaseSummary? { guard let obj = raw as? [String: Any] else { return nil } guard let id = obj["id"] as? String, !id.isEmpty else { return nil } let rawName = (obj["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/Cloud/CloudVMSyncSubscriber.swift b/Sources/Cloud/CloudVMSyncSubscriber.swift new file mode 100644 index 000000000000..463fe8e2b458 --- /dev/null +++ b/Sources/Cloud/CloudVMSyncSubscriber.swift @@ -0,0 +1,238 @@ +import CmuxAuthRuntime +import CmuxCloud +import Foundation +import OSLog + +nonisolated private let cloudVMSyncLog = Logger(subsystem: "dev.cmux", category: "cloud-vm-sync") + +/// Keeps one machines panel subscribed to its team's `vms` sync collection on +/// the presence worker (`workers/presence/src/syncVms.ts`), so a machine +/// created, renamed, resumed or destroyed anywhere lands in the list within a +/// round trip instead of on the next REST poll. +/// +/// Owns its own presence socket: ``DeviceDirectory`` exists only while device +/// discovery is on, and the Cloud list must stay live without it. The hello +/// asks for `vms` from the last cursor this instance applied (0/0 on the first +/// connect and after every scope change), so a reconnect after the server's +/// 15-minute deadline catches up with deltas. Every socket presents the same +/// team header ``VMClient`` sends (`X-Cmux-Team-Id` = `auth.resolvedTeamID`, +/// none for a personal account without teams), and its token source fails +/// closed once the account or team changes, at which point the owner calls +/// ``start()`` again and the subscriber resubscribes under the new scope. +/// +/// Reconnect and backoff mirror ``DeviceDirectory``: `[1, 2, 5, 10, 30]` s +/// after a failure, at once after a clean close. Cloud availability and the +/// panel's lifecycle are the owner's decision (``MachinesPanelViewModel`` +/// starts this with polling and stops it with polling). +@MainActor +final class CloudVMSyncSubscriber { + /// What the owner receives, on the main actor, in stream order. + enum Event: Equatable { + /// A socket delivered its first frame; the owner re-reads the REST + /// list because changes during the gap are not replayed in full. + case connected + /// A complete `vms` snapshot. `backfilled` false means the page set is + /// partial and must never delete a row. + case snapshot(records: [VMSyncRecord], backfilled: Bool) + case delta(records: [VMSyncRecord]) + /// The socket ended or failed after `.connected`. + case disconnected + } + + enum State: Equatable, Sendable { + case stopped + case connecting + case live + case retrying(attempt: Int) + } + + /// The account and team one socket is bound to. + struct Scope: Equatable, Sendable { + let identity: AuthenticatedSessionIdentity + let teamID: String? + } + + typealias SubscriberFactory = @Sendable ( + URL, + [DevicePresenceFrame.SyncCollection], + @escaping @Sendable () async throws -> DevicePresenceSubscriber.Credentials? + ) -> DevicePresenceSubscriber + + /// The production subscriber. The machine list lives on the team's + /// presence object, which the worker publishes into. + nonisolated static let defaultSubscriber: SubscriberFactory = { url, collections, credentials in + DevicePresenceSubscriber(serviceBaseURL: url, stream: .team, collections: collections, credentials: credentials) + } + + nonisolated static let reconnectDelays: [Duration] = DeviceDirectory.reconnectDelays + + private(set) var state: State = .stopped + private(set) var scope: Scope? + /// The reconnect cursor: the `vms` head this instance last applied. + private(set) var cursor = 0 + private(set) var epoch = 0 + + private let auth: AuthCoordinator + private let serviceURL: @MainActor @Sendable () -> URL? + private let makeSubscriber: SubscriberFactory + private let clock: any Clock + private var onEvent: @MainActor (Event) -> Void = { _ in } + private var loopTask: Task? + private var pendingSnapshot: [VMSyncRecord] = [] + + init( + auth: AuthCoordinator, + serviceURL: @escaping @MainActor @Sendable () -> URL? = { PresenceHeartbeatClient.resolvedServiceURL() }, + makeSubscriber: @escaping SubscriberFactory = CloudVMSyncSubscriber.defaultSubscriber, + clock: any Clock = ContinuousClock() + ) { + self.auth = auth + self.serviceURL = serviceURL + self.makeSubscriber = makeSubscriber + self.clock = clock + } + + deinit { + // The loop holds this instance weakly, so an owner that is released + // without stop() still ends the socket here. + loopTask?.cancel() + } + + /// Installs the owner's event handler; events are delivered on the main actor. + func setEventHandler(_ handler: @escaping @MainActor (Event) -> Void) { + onEvent = handler + } + + var isRunning: Bool { loopTask != nil } + + /// The scope a socket opened now would bind to; nil while signed out. + func currentScope() -> Scope? { + guard let identity = auth.authenticatedSessionIdentity else { return nil } + return Scope(identity: identity, teamID: auth.resolvedTeamID) + } + + /// Subscribes under the current account and team. A running subscription + /// under the same scope is kept (its cursor stays valid); a different scope + /// or a signed-out account tears the socket down first, and a fresh scope + /// starts from cursor 0 because cursors are per team. + func start() { + let next = currentScope() + if loopTask != nil, next == scope { return } + stop() + guard let next else { return } + scope = next + cursor = 0 + epoch = 0 + let clock = clock + loopTask = Task { [weak self] in + var failures = 0 + while !Task.isCancelled { + var connected = false + do { + let frames: AsyncThrowingStream + do { + // Retain the owner only while opening the socket, never + // across the receive loop, so deinit can end the task. + guard let owner = self else { return } + frames = try await owner.openSocket(scope: next) + } + for try await frame in frames { + guard !Task.isCancelled, let owner = self else { return } + if !connected { + connected = true + failures = 0 + owner.streamDidConnect() + } + try owner.apply(frame) + } + // A clean close is the server's token deadline: resubscribe at once. + failures = 0 + } catch is CancellationError { + return + } catch { + failures += 1 + cloudVMSyncLog.error("vms subscribe failed: \(String(describing: error), privacy: .private)") + } + guard !Task.isCancelled, let owner = self else { return } + owner.streamDidEnd(wasConnected: connected, failures: failures) + let delay = Self.reconnectDelays[min(max(failures - 1, 0), Self.reconnectDelays.count - 1)] + // Bounded, cancellable backoff between subscribe attempts; the + // cancellation is wired to stop() through the owning task. + guard (try? await clock.sleep(for: failures == 0 ? .zero : delay)) != nil else { return } + } + } + } + + func stop() { + let wasLive = state == .live + loopTask?.cancel() + loopTask = nil + scope = nil + pendingSnapshot = [] + cursor = 0 + epoch = 0 + state = .stopped + if wasLive { onEvent(.disconnected) } + } + + private func openSocket(scope: Scope) async throws -> AsyncThrowingStream { + // Configuration can arrive after startup. A missing endpoint follows + // the same cancellable recovery as a failed connection. + guard let url = serviceURL() else { + throw DevicePresenceSubscriber.SubscribeError.invalidServiceURL + } + state = .connecting + pendingSnapshot = [] + let tokens = HiveAccountTokenSource(auth: auth, identity: scope.identity, teamID: scope.teamID) + let teamID = scope.teamID + let collections = [DevicePresenceFrame.SyncCollection( + name: DevicePresenceFrame.vmsCollection, cursor: cursor, epoch: epoch + )] + let subscriber = makeSubscriber(url, collections) { + // The same header rule as VMClient: the resolved team id, omitted + // for a personal account; the token source fails closed on a switch. + let session = try await tokens.session() + return DevicePresenceSubscriber.Credentials(accessToken: session.accessToken, teamID: teamID) + } + return try await subscriber.subscribe() + } + + private func streamDidConnect() { + state = .live + onEvent(.connected) + } + + private func streamDidEnd(wasConnected: Bool, failures: Int) { + state = .retrying(attempt: failures) + if wasConnected { onEvent(.disconnected) } + } + + /// A `vms` snapshot failed validation; the receive loop resubscribes. + struct MalformedSnapshot: Error {} + + /// Reduce one frame: buffer snapshot pages until `complete`, advance the + /// cursor only for frames the owner receives, end the session on a + /// malformed snapshot, ignore everything else. + func apply(_ frame: DevicePresenceFrame) throws { + switch frame { + case .vmsSnapshot(let records, let complete, let backfilled, let snapshotRev, let epoch): + pendingSnapshot.append(contentsOf: records) + guard complete else { return } + let all = pendingSnapshot + pendingSnapshot = [] + cursor = snapshotRev + self.epoch = epoch + onEvent(.snapshot(records: all, backfilled: backfilled)) + case .vmsDelta(let records, let rev): + cursor = max(cursor, rev) + onEvent(.delta(records: records)) + case .vmsMalformed: + // Drop the partial snapshot and keep the cursor: the stream + // resubscribes and the REST list stays the authority meanwhile. + pendingSnapshot = [] + throw MalformedSnapshot() + case .snapshot, .online, .offline, .seen, .routes, .syncSnapshot, .syncDelta, .ignored: + return + } + } +} diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index d0c225c848c9..e88eb38bd31f 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -65,6 +65,7 @@ struct MachinesPanelView: View { ) _viewModel = StateObject(wrappedValue: viewModel ?? MachinesPanelViewModel( machinePinStore: machinePinStore, + vmSync: AppDelegate.shared?.auth.map { CloudVMSyncSubscriber(auth: $0.coordinator) }, localWorkspacesProvider: { [weak tabManager] in guard let tabManager else { return [] } return tabManager.tabs.map { diff --git a/Sources/Cloud/MachinesPanelViewModel+Refresh.swift b/Sources/Cloud/MachinesPanelViewModel+Refresh.swift index 02ea67256de3..ee76c32d3f5b 100644 --- a/Sources/Cloud/MachinesPanelViewModel+Refresh.swift +++ b/Sources/Cloud/MachinesPanelViewModel+Refresh.swift @@ -66,6 +66,7 @@ extension MachinesPanelViewModel { let scope = machinePinStore?.scopeIdentifier // The New Machine sheet's cache reuses this read for its plan and count. let sheetCacheScope = NewMachineSheetDataCache.shared?.scopeForIngest + beginRestReadStreamCapture() refreshTask = Task { [weak self] in // Only the last read in flight ends loading; a retired or chained one must not. defer { self?.clearListLoadingIfIdle() } @@ -77,6 +78,8 @@ extension MachinesPanelViewModel { NewMachineSheetDataCache.shared?.ingest(page: page, scope: sheetCacheScope) } self.applyRefreshResult(result, generation: generation, scope: scope) + // A failed read applied nothing, so there is nothing to replay onto. + self.streamChangesDuringRestRead = nil self.refreshTask = nil if self.refreshRequestedWhileLoading { let isRecovery = self.refreshRequestedWhileLoadingIsRecovery @@ -94,10 +97,20 @@ extension MachinesPanelViewModel { guard isCloudEnabled() else { pausePolling(); return } // Showing the panel or returning online is a recovery; polls are not. recoverList() + // The stream (re)subscribes under the current team; a same-scope + // socket that is already open keeps its cursor. + startVMSync() guard pollTask == nil else { return } + armPollLoop() + } + + /// The routine poll loop. The interval is read before every sleep so a + /// stream connect or disconnect (which re-arms the loop) takes effect at once. + func armPollLoop() { pollTask = Task { [weak self, pollingClock] in while !Task.isCancelled { - do { try await pollingClock.sleep(for: Self.pollInterval) } catch { return } + guard let interval = self?.routinePollInterval else { return } + do { try await pollingClock.sleep(for: interval) } catch { return } guard !Task.isCancelled, let self else { return } self.refresh(routinePoll: true) } diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index e40a25cad4d8..104d800365b5 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -6,7 +6,8 @@ import AppKit import Foundation import SwiftUI -/// Loads the visible fleet for the Machines tab; mutations use CloudVMActionLauncher. +/// Loads the visible fleet for the Machines tab and merges changes pushed by the presence +/// `vms` sync collection (``CloudVMSyncSubscriber``); mutations use CloudVMActionLauncher. @MainActor final class MachinesPanelViewModel: ObservableObject { @Published private(set) var machines: [MachineSnapshot] = [] @@ -124,6 +125,17 @@ final class MachinesPanelViewModel: ObservableObject { var refreshTask: Task? var statsID: UUID? let client: VMClient? + /// The realtime `vms` stream for this panel; nil when the composition root + /// has no auth coordinator (tests), in which case the list only polls. + let vmSync: CloudVMSyncSubscriber? + /// True while the `vms` socket is delivering frames. The routine poll + /// stretches to ``streamingPollInterval`` because the stream carries + /// changes; the poll then only covers `limits` and anything the stream lost. + private(set) var isVMSyncConnected = false + /// The refresh generation and pin scope the running stream was started + /// under; a frame from an older start is dropped like a stale list read. + var vmSyncGeneration: UInt64 = 0 + var vmSyncScope: String? let isCloudEnabled: @MainActor () -> Bool let pollingClock: any Clock /// Posts `NSWorkspace.didWakeNotification`; injectable for tests. @@ -200,6 +212,7 @@ final class MachinesPanelViewModel: ObservableObject { machinePinStore: CloudMachinePinStore? = nil, resourceStats: VMResourceStatsStore? = nil, client: VMClient? = nil, + vmSync: CloudVMSyncSubscriber? = nil, pollingClock: any Clock = ContinuousClock(), wakeNotificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter, lifecycleNotificationCenter: NotificationCenter = .default, @@ -216,6 +229,7 @@ final class MachinesPanelViewModel: ObservableObject { ) { let networkClient = client ?? VMClient.shared self.client = networkClient + self.vmSync = vmSync self.pollingClock = pollingClock self.wakeNotificationCenter = wakeNotificationCenter self.lifecycleNotificationCenter = lifecycleNotificationCenter @@ -283,6 +297,9 @@ final class MachinesPanelViewModel: ObservableObject { MainActor.assumeIsolated { self?.readUnreadTerminalIDs() } } readUnreadTerminalIDs() + vmSync?.setEventHandler { [weak self] event in + self?.vmSyncDidEmit(event) + } if let networkClient { networkTask = Task { [weak self, networkClient] in let changes = await networkClient.networkChanges() @@ -477,12 +494,23 @@ final class MachinesPanelViewModel: ObservableObject { beginOptimisticRename(id: id, label: label) } static let pollInterval: Duration = .seconds(45) + /// The routine cadence while the `vms` stream is connected: the stream + /// carries machine changes, so the poll only reconciles `limits` and any + /// change the stream lost. + static let streamingPollInterval: Duration = .seconds(300) + /// The cadence the poll loop sleeps for next; re-read on every iteration. + var routinePollInterval: Duration { + isVMSyncConnected ? Self.streamingPollInterval : Self.pollInterval + } static let initialTransientFailureLimit = 3 /// A refresh asked for while one is in flight runs again afterwards: a create that lands /// mid-poll must still replace its pending row with the real machine now, not on the next 45 s sweep. var refreshRequestedWhileLoading = false /// A queued automatic refresh promotes the current request to recovery presentation and keeps that intent for the follow-up read. var refreshRequestedWhileLoadingIsRecovery = false + /// Stream changes that arrived while a REST read was in flight, replayed on + /// top of that read's page; nil while no read is in flight. + var streamChangesDuringRestRead: [(upserts: [VMSummary], deletions: Set, authoritative: Bool)]? /// Invalidates refresh completions when the Cloud gate closes. A cancelled URLSession task may /// still resume on the main actor, so cancellation alone is not enough to prevent stale rows or follow-up work. var refreshGeneration: UInt64 = 0 @@ -603,11 +631,13 @@ final class MachinesPanelViewModel: ObservableObject { } func pausePolling() { + stopVMSync() pollTask?.cancel(); pollTask = nil refreshTask?.cancel(); refreshTask = nil refreshRequestedWhileLoading = false refreshRequestedWhileLoadingIsRecovery = false refreshGeneration &+= 1 + streamChangesDuringRestRead = nil isLoading = false isRenamingMachine = false optimisticLabels.removeAll() @@ -659,6 +689,14 @@ final class MachinesPanelViewModel: ObservableObject { machineIndexByID = Dictionary(uniqueKeysWithValues: snapshots.enumerated().map { ($0.element.id, $0.offset) }) machines = snapshots lastLimits = page.limits + // The page was read before any stream change recorded since it + // started; replay those so a newer change is never undone. + let streamed = streamChangesDuringRestRead ?? [] + streamChangesDuringRestRead = nil + for change in streamed { + applyVMChanges(upserts: change.upserts, deletions: change.deletions, authoritative: change.authoritative) + } + snapshots = machines scheduleFreeAccessTransition() refreshStats() refreshUsage() @@ -708,4 +746,161 @@ final class MachinesPanelViewModel: ObservableObject { cmuxDebugLog("cloud.machines.list settled count=\(machines.count) problem=\(String(describing: listProblem))") #endif } + + // MARK: - Realtime `vms` stream + + /// Starts (or keeps) the stream under the current account and team, fenced + /// to this refresh generation and pin scope. Polling owns the lifecycle: + /// the stream lives exactly while the panel polls. + func startVMSync() { + guard let vmSync else { return } + vmSyncGeneration = refreshGeneration + vmSyncScope = machinePinStore?.scopeIdentifier + vmSync.start() + } + + func stopVMSync() { + // Cleared first so the stop's own `.disconnected` event finds nothing + // to change and does not re-arm a poll loop the caller is retiring. + isVMSyncConnected = false + vmSync?.stop() + } + + /// Starts recording stream changes for the REST read about to begin. + func beginRestReadStreamCapture() { + streamChangesDuringRestRead = [] + } + + private func vmSyncDidEmit(_ event: CloudVMSyncSubscriber.Event) { + guard vmSyncGeneration == refreshGeneration, vmSyncScope == machinePinStore?.scopeIdentifier, + isCloudEnabled() else { return } + switch event { + case .connected: + setVMSyncConnected(true) + // Changes during the gap are not replayed in full, and the REST + // list is the only source of `limits`: read it on every (re)connect. + refresh(routinePoll: true) + case .disconnected: + setVMSyncConnected(false) + case .snapshot(let records, let backfilled): + // An unbackfilled snapshot holds only rows written since the + // collection shipped: it may add and update, never remove. + applyVMChanges( + upserts: records.compactMap(\.summary), + deletions: backfilled ? Set(records.filter(\.deleted).map(\.id)) : [], + authoritative: backfilled + ) + case .delta(let records): + applyVMChanges( + upserts: records.compactMap(\.summary), + deletions: Set(records.filter(\.deleted).map(\.id)), + authoritative: false + ) + } + } + + private func setVMSyncConnected(_ connected: Bool) { + guard isVMSyncConnected != connected else { return } + isVMSyncConnected = connected + // The routine cadence changed: re-arm the sleeping poll so the new + // interval applies now instead of after the old one elapses. + guard pollTask != nil else { return } + pollTask?.cancel(); pollTask = nil + armPollLoop() + } + + /// Merges pushed machine changes into the rows in place. `upserts` replace + /// their row by id or insert in createdAt-descending order (the list + /// endpoint's order); `deletions` remove rows; `authoritative` means + /// `upserts` is the complete fleet, so every other row is removed too. + /// Rows rebuild through ``MachineSnapshotBuilder`` with the last list's + /// free-access window, the shared stats store and the last usage, then the + /// same post-list reconcile runs as after a REST read. `limits` never + /// change here; only ``refresh(routinePoll:)`` carries them. + /// A `vms` stream record carries the row's own columns only; the creator's + /// name, the pool claim, agent updates and the free-access expiry come + /// from the REST list. Keep the existing row's values for those until the + /// next list read replaces them. + static func keepingListOnlyFields(of existing: MachineSnapshot, in streamed: MachineSnapshot, streamed summary: VMSummary) -> MachineSnapshot { + let createdBy: VMCreator? = if let creator = streamed.createdBy, creator.userId != existing.createdBy?.userId { + creator + } else { + existing.createdBy ?? streamed.createdBy + } + var next = MachineSnapshot( + id: streamed.id, + provider: streamed.provider, + image: streamed.image, + isDesktop: streamed.isDesktop, + capabilities: streamed.capabilities, + activity: streamed.activity, + createdAt: streamed.createdAt, + createdBy: createdBy, + label: streamed.label, + slug: streamed.slug, + freeAccess: summary.freeAccessExpiresAt == nil ? existing.freeAccess : streamed.freeAccess, + stats: streamed.stats, + resourceReservation: streamed.resourceReservation ?? existing.resourceReservation, + resourcePoolClaim: streamed.resourcePoolClaim ?? existing.resourcePoolClaim, + usesResourcePool: streamed.usesResourcePool, + usage: streamed.usage, + privateAddress: streamed.privateAddress, + isPinned: streamed.isPinned + ) + next.agentUpdates = streamed.agentUpdates ?? existing.agentUpdates + return next + } + + func applyVMChanges(upserts: [VMSummary], deletions: Set, authoritative: Bool) { + guard isCloudEnabled() else { return } + streamChangesDuringRestRead?.append((upserts, deletions, authoritative)) + let previous = resourceStats?.snapshot ?? [:] + var rows = machines + var index = machineIndexByID + var upsertedIDs = Set() + for summary in upserts { + var snapshot = MachineSnapshotBuilder.snapshot( + from: summary, + freeAccessWindowDays: freeAccessWindowDays, + previousStats: previous[summary.id] + ) + snapshot.usage = usageByMachineID[summary.id] + upsertedIDs.insert(summary.id) + if let position = index[summary.id], rows.indices.contains(position), rows[position].id == summary.id { + rows[position] = Self.keepingListOnlyFields(of: rows[position], in: snapshot, streamed: summary) + } else { + let createdAt = snapshot.createdAt ?? .distantPast + let position = rows.firstIndex { ($0.createdAt ?? .distantPast) < createdAt } ?? rows.endIndex + rows.insert(snapshot, at: position) + index = Dictionary(uniqueKeysWithValues: rows.enumerated().map { ($0.element.id, $0.offset) }) + } + } + // Same as a list read: a streamed label that matches a pending rename + // settles it; until then the pending label wins over the record's. + reconcileOptimisticLabels(with: rows.filter { upsertedIDs.contains($0.id) }) + rows = applyingOptimisticLabels(to: rows) + var removed = deletions + if authoritative { + removed.formUnion(rows.map(\.id).filter { !upsertedIDs.contains($0) }) + } + if !removed.isEmpty { + rows.removeAll { removed.contains($0.id) } + } + machineIndexByID = Dictionary(uniqueKeysWithValues: rows.enumerated().map { ($0.element.id, $0.offset) }) + machines = rows + if authoritative { + // A complete fleet prunes pins the same way a list read does; a + // partial change only remembers order (readCatalog below). + machinePinStore?.reconcile(machineIDs: MachineSnapshotBuilder.includingCatalogMachines(rows, catalog: scopedCatalogSnapshot()).map(\.id)) + hasLoadedOnce = true + } + scheduleFreeAccessTransition() + refreshStats() + refreshUsage() + readCatalog() + plan = MachineSnapshotBuilder.planSnapshot(activeCount: rows.count, limits: lastLimits, machines: rows) + #if DEBUG + cmuxDebugLog("cloud.machines.list stream upserts=\(upserts.count) deletions=\(deletions.count) authoritative=\(authoritative) count=\(rows.count)") + #endif + } } diff --git a/Sources/Devices/DeviceDirectory.swift b/Sources/Devices/DeviceDirectory.swift index 2a2a50120a73..2eaad2b8ae67 100644 --- a/Sources/Devices/DeviceDirectory.swift +++ b/Sources/Devices/DeviceDirectory.swift @@ -363,7 +363,9 @@ final class DeviceDirectory { owners[cmxCanonicalDeviceID(device.deviceId)] = owner } } - case .ignored: + case .vmsSnapshot, .vmsDelta, .vmsMalformed, .ignored: + // The `vms` collection belongs to CloudVMSyncSubscriber's socket; + // this hello never asks for it. return } remerge() diff --git a/Sources/Devices/DevicePresenceFrame.swift b/Sources/Devices/DevicePresenceFrame.swift index 228e072ffb0b..8d04b99b3789 100644 --- a/Sources/Devices/DevicePresenceFrame.swift +++ b/Sources/Devices/DevicePresenceFrame.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CmuxCloud import CmuxSurfaceCatalogModel import Foundation @@ -91,6 +92,15 @@ struct DeviceSyncRecord: Equatable, Sendable { let device: DeviceSyncDeviceRecord? } +/// One `vms` sync record (`VmRecord` in `workers/presence/src/syncVms.ts`): +/// the same entry shape `GET /api/vm` lists, decoded by the list parser, or a +/// tombstone (`deleted`) carrying no summary. +struct VMSyncRecord: Equatable, Sendable { + let id: String + let deleted: Bool + let summary: VMSummary? +} + /// One message from the presence subscribe socket. Presence frames mirror /// `PresenceEvent` in core.ts; sync frames mirror `sync.ts`. Anything this /// build does not understand parses as `.ignored`, so a newer worker never @@ -104,9 +114,37 @@ enum DevicePresenceFrame: Equatable, Sendable { /// A page of the `devices` collection; `complete` ends the page set. case syncSnapshot(records: [DeviceSyncRecord], complete: Bool) case syncDelta(records: [DeviceSyncRecord]) + /// A page of the `vms` collection. `backfilled` false means the worker has + /// never held a full list (only rows written since the collection + /// shipped), so the consumer applies the page set upsert-only. On the + /// `complete` page `snapshotRev` and `epoch` become the reconnect cursor. + case vmsSnapshot(records: [VMSyncRecord], complete: Bool, backfilled: Bool, snapshotRev: Int, epoch: Int) + /// Changed `vms` records; `rev` is the head this frame brings the cursor to. + case vmsDelta(records: [VMSyncRecord], rev: Int) + /// A `vms` snapshot frame that failed validation (no `records` array, or a + /// live record the list parser rejects). It must not replace the fleet. + case vmsMalformed case ignored static let devicesCollection = "devices" + static let vmsCollection = "vms" + + /// One collection a `sync.hello` asks for, with the cursor and epoch the + /// client already holds (0/0 for a fresh snapshot). + struct SyncCollection: Equatable, Sendable { + let name: String + let cursor: Int + let epoch: Int + + init(name: String, cursor: Int = 0, epoch: Int = 0) { + self.name = name + self.cursor = cursor + self.epoch = epoch + } + + /// The device directory's subscription: always a fresh snapshot. + static let devices = SyncCollection(name: DevicePresenceFrame.devicesCollection) + } /// Parse one socket message. Throws only for non-JSON payloads. static func parse(_ data: Data) throws -> DevicePresenceFrame { @@ -141,30 +179,75 @@ enum DevicePresenceFrame: Equatable, Sendable { } return .seen(deviceId: deviceId, tag: tag, lastSeenAt: lastSeenAt) case "sync.snapshot", "sync.delta": - guard object["collection"] as? String == devicesCollection else { return .ignored } - let records = ((object["records"] as? [Any]) ?? []).compactMap(Self.syncRecord) - if type == "sync.snapshot" { - return .syncSnapshot(records: records, complete: (object["complete"] as? Bool) ?? false) + let rawRecords = (object["records"] as? [Any]) ?? [] + switch object["collection"] as? String { + case devicesCollection: + let records = rawRecords.compactMap(Self.syncRecord) + if type == "sync.snapshot" { + return .syncSnapshot(records: records, complete: (object["complete"] as? Bool) ?? false) + } + return .syncDelta(records: records) + case vmsCollection: + if type == "sync.snapshot" { + // A snapshot can replace the whole fleet: every record must + // decode, or none of it is applied. + guard let snapshotRecords = object["records"] as? [Any] else { return .vmsMalformed } + let decoded = snapshotRecords.enumerated().map { Self.vmSyncRecord($0.element, index: $0.offset) } + guard !decoded.contains(where: { $0 == nil }) else { return .vmsMalformed } + let records = decoded.compactMap { $0 } + return .vmsSnapshot( + records: records, + complete: (object["complete"] as? Bool) ?? false, + backfilled: (object["backfilled"] as? Bool) ?? false, + snapshotRev: Self.revision(object["snapshotRev"]), + epoch: Self.revision(object["epoch"]) + ) + } + // A delta only adds, updates or tombstones by id; a record that + // fails to decode is skipped and the next list read repairs it. + let records = rawRecords.enumerated().compactMap { Self.vmSyncRecord($0.element, index: $0.offset) } + return .vmsDelta(records: records, rev: Self.revision(object["rev"])) + default: + return .ignored } - return .syncDelta(records: records) default: return .ignored } } - /// The `sync.hello` a client sends after connect to receive the `devices` - /// collection (snapshot first, deltas after) on the same socket. Cursor 0 in - /// epoch 0: the directory keeps no durable cursor, so every connect starts - /// from a fresh snapshot, which is also the protocol's supported resync path. - static func syncHello() -> Data { + /// The `sync.hello` a client sends after connect to receive `collections` + /// (snapshot first, deltas after) on the same socket. The default asks for + /// `devices` from cursor 0 in epoch 0: the directory keeps no durable + /// cursor, so every connect starts from a fresh snapshot, which is also the + /// protocol's supported resync path. The `vms` consumer passes the cursor + /// it last applied so a reconnect catches up with deltas. + static func syncHello(collections: [SyncCollection] = [.devices]) -> Data { let payload: [String: Any] = [ "type": "sync.hello", "protocol": "sync/v1", - "collections": [["name": devicesCollection, "cursor": 0, "epoch": 0]], + "collections": collections.map { ["name": $0.name, "cursor": $0.cursor, "epoch": $0.epoch] }, ] return (try? JSONSerialization.data(withJSONObject: payload)) ?? Data("{}".utf8) } + /// A non-negative integer cursor; anything else reads as 0 (fresh). + private static func revision(_ raw: Any?) -> Int { + guard let number = raw as? NSNumber, number.doubleValue.isFinite, number.doubleValue >= 0 else { return 0 } + return number.intValue + } + + /// A live record whose payload the list parser rejects is dropped (never + /// a tombstone), so one malformed machine cannot delete a row. + private static func vmSyncRecord(_ raw: Any, index: Int) -> VMSyncRecord? { + guard let object = raw as? [String: Any], let id = object["id"] as? String, !id.isEmpty else { return nil } + let deleted = (object["deleted"] as? Bool) ?? false + if deleted { return VMSyncRecord(id: id, deleted: true, summary: nil) } + guard let payload = object["payload"] as? [String: Any], + let summary = try? VMClient.decodeListItem(payload, index: index), + summary.id == id else { return nil } + return VMSyncRecord(id: id, deleted: false, summary: summary) + } + private static func syncRecord(_ raw: Any) -> DeviceSyncRecord? { guard let object = raw as? [String: Any], let id = object["id"] as? String else { return nil } let deleted = (object["deleted"] as? Bool) ?? false diff --git a/Sources/Devices/DevicePresenceSubscriber.swift b/Sources/Devices/DevicePresenceSubscriber.swift index f8be233dc3b9..4b856631e2d9 100644 --- a/Sources/Devices/DevicePresenceSubscriber.swift +++ b/Sources/Devices/DevicePresenceSubscriber.swift @@ -1,18 +1,19 @@ import Foundation -/// Subscribes this Mac to the signed-in user's live device presence -/// (`GET /v1/presence/subscribe?scope=account` on `workers/presence`, -/// WebSocket). The account scope lists only this user's devices, whatever -/// team each one has selected; a worker without it serves the team stream. Every subscribe delivers a +/// Subscribes this Mac to live presence (`GET /v1/presence/subscribe` on +/// `workers/presence`, WebSocket). The `account` stream (`?scope=account`) +/// lists only this user's devices, whatever team each one has selected; the +/// `team` stream is the selected team's object. Every subscribe delivers a /// snapshot first, then online/offline/seen/routes transitions; the same socket -/// also serves the `devices` sync collection after a `sync.hello`, which is -/// where device owners come from. +/// also serves sync collections after a `sync.hello`: `devices` (where device +/// owners come from) for ``DeviceDirectory`` on the account stream, `vms` (the +/// team's Cloud machine list) for ``CloudVMSyncSubscriber`` on the team stream. /// /// One instance is one session: `subscribe()` opens the socket and yields /// parsed frames until the server closes it (streams are deadline-bounded by /// the token, 15 minutes at most) or the transport fails. Reconnect and -/// backoff belong to the owner (``DeviceDirectory``), which also decides when a -/// session should exist at all. +/// backoff belong to the owner, which also decides when a session should +/// exist at all. actor DevicePresenceSubscriber { enum SubscribeError: Error, Equatable { case invalidServiceURL @@ -23,31 +24,47 @@ actor DevicePresenceSubscriber { case keepaliveTimedOut } + /// Which stream the worker serves: `account` is the signed-in user's own + /// devices (My Devices, any team); `team` is the selected team's object, + /// where team collections such as `vms` live. + enum Stream: Equatable, Sendable { + case account + case team + } + struct Credentials: Sendable { let accessToken: String let teamID: String? } private let serviceBaseURL: URL + nonisolated let stream: Stream private let credentials: @Sendable () async throws -> Credentials? private let session: URLSession private let clock: any Clock + /// The sync collections the hello asks for; `[.devices]` for the device + /// directory, `vms` with a cursor for the Cloud machines list. + private let collections: [DevicePresenceFrame.SyncCollection] init( serviceBaseURL: URL, + stream: Stream = .account, session: URLSession = .shared, clock: any Clock = ContinuousClock(), + collections: [DevicePresenceFrame.SyncCollection] = [.devices], credentials: @escaping @Sendable () async throws -> Credentials? ) { self.serviceBaseURL = serviceBaseURL + self.stream = stream self.session = session self.clock = clock + self.collections = collections self.credentials = credentials } /// The WebSocket subscribe URL for a service base URL; nil when the base is /// not http(s)/ws(s). Pure for tests. - static func subscribeURL(serviceBaseURL: URL) -> URL? { + static func subscribeURL(serviceBaseURL: URL, stream: Stream = .account) -> URL? { guard var comps = URLComponents(url: serviceBaseURL, resolvingAgainstBaseURL: false) else { return nil } switch comps.scheme?.lowercased() { case "https": comps.scheme = "wss" @@ -63,13 +80,16 @@ actor DevicePresenceSubscriber { } let basePath = comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path comps.path = basePath + "/v1/presence/subscribe" - comps.queryItems = [URLQueryItem(name: "scope", value: "account")] + switch stream { + case .account: comps.queryItems = [URLQueryItem(name: "scope", value: "account")] + case .team: comps.queryItems = nil + } return comps.url } - /// Opens one subscribe session and sends the `devices` sync hello. + /// Opens one subscribe session and sends the sync hello for `collections`. func subscribe() async throws -> AsyncThrowingStream { - guard let url = Self.subscribeURL(serviceBaseURL: serviceBaseURL) else { + guard let url = Self.subscribeURL(serviceBaseURL: serviceBaseURL, stream: stream) else { throw SubscribeError.invalidServiceURL } guard let credentials = try await credentials() else { @@ -82,9 +102,9 @@ actor DevicePresenceSubscriber { } let task = session.webSocketTask(with: request) task.resume() - // Sent before the first receive so the owner-carrying `devices` - // snapshot arrives beside the presence snapshot instead of trailing it. - let hello = String(decoding: DevicePresenceFrame.syncHello(), as: UTF8.self) + // Sent before the first receive so the collection snapshot arrives + // beside the presence snapshot instead of trailing it. + let hello = String(decoding: DevicePresenceFrame.syncHello(collections: collections), as: UTF8.self) try await task.send(.string(hello)) let clock = clock diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d90e4817e0d6..777666993bd8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1116,6 +1116,7 @@ C7F29A7CD7C6410F87BB8536 /* CloudVMLoadingPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0581DFBCDBEE482A8D4CDB6C /* CloudVMLoadingPanelView.swift */; }; C75740010000000000000001 /* CloudVMMenuItemMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75740010000000000000002 /* CloudVMMenuItemMetricsTests.swift */; }; 2D7F228001C24ABD865AFD66 /* CloudVMStateSnapshotComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFB9C9E7B5D4F39873871A1 /* CloudVMStateSnapshotComparisonTests.swift */; }; + CB40AA1959A518141679F524 /* CloudVMSyncSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746CCE87386AD47A813D5070 /* CloudVMSyncSubscriber.swift */; }; C12093000000000000000002 /* CloudVPNExplicitActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12093000000000000000001 /* CloudVPNExplicitActivationTests.swift */; }; 2CDEFA3DEE2C33025EAC4223 /* CloudVPNSetupModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5EC244BC074AE6469235568 /* CloudVPNSetupModel.swift */; }; 549C9947A81C58B3F6402500 /* CloudVPNSetupPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EB75F0FF57EE985A8E4DD5 /* CloudVPNSetupPanel.swift */; }; @@ -5804,6 +5805,7 @@ 0581DFBCDBEE482A8D4CDB6C /* CloudVMLoadingPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudVMLoadingPanelView.swift; sourceTree = ""; }; C75740010000000000000002 /* CloudVMMenuItemMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudVMMenuItemMetricsTests.swift; sourceTree = ""; }; 7EFB9C9E7B5D4F39873871A1 /* CloudVMStateSnapshotComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudVMStateSnapshotComparisonTests.swift; sourceTree = ""; }; + 746CCE87386AD47A813D5070 /* CloudVMSyncSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudVMSyncSubscriber.swift; sourceTree = ""; }; C12093000000000000000001 /* CloudVPNExplicitActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudVPNExplicitActivationTests.swift; sourceTree = ""; }; D5EC244BC074AE6469235568 /* CloudVPNSetupModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudVPNSetupModel.swift; sourceTree = ""; }; 62EB75F0FF57EE985A8E4DD5 /* CloudVPNSetupPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudVPNSetupPanel.swift; sourceTree = ""; }; @@ -9968,6 +9970,7 @@ 8ACB13AE3FBA6BD011A6EFA5 /* CloudMenuEntry.swift */, A7C0532E8633357781BA4293 /* CloudMenuModel.swift */, 276B8742C711D7E718A5DC5A /* CloudTreePortPresentation.swift */, + 746CCE87386AD47A813D5070 /* CloudVMSyncSubscriber.swift */, FB12D123CA2A96F1DDBA3405 /* CloudTuiManualMirrorSession+PendingReplay.swift */, 8AB2CCE4C103CF610EDCF765 /* CloudTreeCreateAction.swift */, 97320634F8C38643D48FBA01 /* CloudTreeCreateActionBuilder.swift */, @@ -15252,6 +15255,7 @@ CFSF00000000000000000003 /* CloudVMFileExplorerProvider.swift in Sources */, C7F29A7CD7C6410F87BB8537 /* CloudVMLoadingPanel.swift in Sources */, C7F29A7CD7C6410F87BB8536 /* CloudVMLoadingPanelView.swift in Sources */, + CB40AA1959A518141679F524 /* CloudVMSyncSubscriber.swift in Sources */, 2CDEFA3DEE2C33025EAC4223 /* CloudVPNSetupModel.swift in Sources */, 549C9947A81C58B3F6402500 /* CloudVPNSetupPanel.swift in Sources */, C8783B458C963A34D5A93FF3 /* CloudVPNSetupPanelView.swift in Sources */, diff --git a/cmuxTests/DevicePresenceWireTests.swift b/cmuxTests/DevicePresenceWireTests.swift index a398b75a1780..31e3d2ecd050 100644 --- a/cmuxTests/DevicePresenceWireTests.swift +++ b/cmuxTests/DevicePresenceWireTests.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CmuxCloud import CmuxSurfaceCatalogModel import Foundation import Testing @@ -44,6 +45,17 @@ struct DevicePresenceWireTests { #expect(comps.queryItems == [URLQueryItem(name: "scope", value: "account")]) } + @Test("The Cloud machines stream subscribes to the selected team's object, where vms is published") + func cloudMachinesStreamSubscribesToTeamPresence() throws { + let serviceBaseURL = try #require(URL(string: "https://presence.example.test/base/")) + let subscriber = CloudVMSyncSubscriber.defaultSubscriber(serviceBaseURL, [], { nil }) + #expect(subscriber.stream == .team) + let url = try #require(DevicePresenceSubscriber.subscribeURL(serviceBaseURL: serviceBaseURL, stream: .team)) + let comps = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(comps.path == "/base/v1/presence/subscribe") + #expect(comps.queryItems == nil, "no scope=account: the team stream is the worker default") + } + @Test("A snapshot lists every instance and drops only the route entries this build cannot decode") func snapshotKeepsInstancesWithUnknownRoutes() throws { let parsed = try frame([ @@ -147,6 +159,129 @@ struct DevicePresenceWireTests { #expect(collections.first?["epoch"] as? Int == 0) } + @Test("sync.hello carries each requested collection with its own cursor and epoch", arguments: [ + [DevicePresenceFrame.SyncCollection(name: "vms")], + [DevicePresenceFrame.SyncCollection(name: "vms", cursor: 41, epoch: 3)], + [.devices, DevicePresenceFrame.SyncCollection(name: "vms", cursor: 7, epoch: 1)], + ]) + func syncHelloCollections(_ requested: [DevicePresenceFrame.SyncCollection]) throws { + let raw = try JSONSerialization.jsonObject(with: DevicePresenceFrame.syncHello(collections: requested)) + let object = try #require(raw as? [String: Any]) + #expect(object["type"] as? String == "sync.hello") + let collections = try #require(object["collections"] as? [[String: Any]]) + #expect(collections.count == requested.count) + for (sent, wanted) in zip(collections, requested) { + #expect(sent["name"] as? String == wanted.name) + #expect(sent["cursor"] as? Int == wanted.cursor) + #expect(sent["epoch"] as? Int == wanted.epoch) + } + } + + private func vmRecord(_ id: String, status: String = "running", createdAt: Int = 1_700_000_000_000, extra: [String: Any] = [:]) -> [String: Any] { + var payload: [String: Any] = [ + "id": id, "provider": "freestyle", "status": status, "image": "cmux-devbox:devbox-20260828b", + "imageVersion": "devbox-20260828b", "kind": "base", + "capabilities": ["snapshot": true, "restore": true, "fork": false, "exec": true, "stats": true, "ports": true, "desktop": false, "sizing": true, "persistentHome": true, "attachTransports": ["ssh"]], + "createdAt": createdAt, "displayName": NSNull(), "slug": "sleepy-teal-otter", "createdByUserId": "user_a", + "address": ["ipv4": "10.64.0.9", "ipv6": NSNull()], "sourceUpdatedAtMs": createdAt + 5, + ] + for (key, value) in extra { payload[key] = value } + return ["id": id, "rev": 12, "updatedAt": createdAt, "deleted": false, "schemaVersion": 1, "payload": payload] + } + + @Test("A vms snapshot decodes records through the list parser and exposes the backfill flag") + func vmsSnapshot() throws { + let parsed = try frame([ + "type": "sync.snapshot", "collection": "vms", "snapshotRev": 12, "epoch": 2, "complete": true, "backfilled": true, + "records": [ + vmRecord("vm-a", extra: ["displayName": "dev box"]), + ["id": "vm-gone", "rev": 9, "updatedAt": 1, "deleted": true, "schemaVersion": 1, "payload": ["sourceUpdatedAtMs": 1]], + ], + ]) + guard case .vmsSnapshot(let records, let complete, let backfilled, let snapshotRev, let epoch) = parsed else { + Issue.record("expected a vms snapshot, got \(parsed)") + return + } + #expect(complete) + #expect(backfilled) + #expect(snapshotRev == 12) + #expect(epoch == 2) + #expect(records.map(\.id) == ["vm-a", "vm-gone"]) + let summary = try #require(records[0].summary) + #expect(!records[0].deleted) + #expect(summary.id == "vm-a") + #expect(summary.provider == "freestyle") + #expect(summary.status == "running") + #expect(summary.kind == .base) + #expect(summary.displayName == "dev box") + #expect(summary.slug == "sleepy-teal-otter") + #expect(summary.createdAt == 1_700_000_000_000) + #expect(summary.addressIPv4 == "10.64.0.9") + #expect(summary.addressIPv6 == nil) + #expect(summary.capabilities.fork == false) + #expect(summary.capabilities.ssh) + #expect(summary.freeAccessExpiresAt == nil, "plan windows are a REST-only field") + #expect(records[1].deleted) + #expect(records[1].summary == nil) + } + + @Test("A vms snapshot without the backfill flag, or an unfinished page, is never authoritative") + func vmsSnapshotFlags() throws { + let page = try frame(["type": "sync.snapshot", "collection": "vms", "snapshotRev": 3, "epoch": 1, "records": [vmRecord("vm-a")]]) + guard case .vmsSnapshot(let records, let complete, let backfilled, _, _) = page else { + Issue.record("expected a vms snapshot, got \(page)") + return + } + #expect(records.count == 1) + #expect(!complete) + #expect(!backfilled, "an older worker that omits the flag reads as unbackfilled, so nothing is deleted") + let unbackfilled = try frame(["type": "sync.snapshot", "collection": "vms", "complete": true, "backfilled": false, "records": []]) + #expect(unbackfilled == .vmsSnapshot(records: [], complete: true, backfilled: false, snapshotRev: 0, epoch: 0)) + let negative = try frame(["type": "sync.snapshot", "collection": "vms", "complete": true, "snapshotRev": -4, "epoch": "x", "records": []]) + #expect(negative == .vmsSnapshot(records: [], complete: true, backfilled: false, snapshotRev: 0, epoch: 0)) + } + + @Test("A vms snapshot is accepted only when it carries a records array and every record decodes") + func vmsSnapshotMalformed() throws { + // A complete, backfilled snapshot replaces the fleet, so one that lost + // its records or a machine must never read as an empty or shorter list. + let missing = try frame(["type": "sync.snapshot", "collection": "vms", "complete": true, "backfilled": true, "snapshotRev": 5]) + #expect(missing == .vmsMalformed) + let notArray = try frame(["type": "sync.snapshot", "collection": "vms", "complete": true, "backfilled": true, "records": ["x": 1]]) + #expect(notArray == .vmsMalformed) + for broken: [String: Any] in [ + ["id": "vm-broken", "deleted": false, "payload": ["id": "vm-broken", "status": "running"]], + ["id": "vm-mismatch", "deleted": false, "payload": ["id": "other", "provider": "freestyle", "image": "x", "status": "running"]], + ["deleted": false], + ] { + let parsed = try frame([ + "type": "sync.snapshot", "collection": "vms", "complete": true, "backfilled": true, + "records": [vmRecord("vm-a"), broken], + ]) + #expect(parsed == .vmsMalformed, "a live record the list parser rejects poisons the snapshot instead of deleting that machine") + } + } + + @Test("A vms delta carries upserts and tombstones with the head revision") + func vmsDelta() throws { + let parsed = try frame([ + "type": "sync.delta", "collection": "vms", "rev": 44, + "records": [ + vmRecord("vm-b", status: "paused"), + ["id": "vm-a", "rev": 44, "updatedAt": 1, "deleted": true, "schemaVersion": 1, "payload": ["sourceUpdatedAtMs": 1]], + ], + ]) + guard case .vmsDelta(let records, let rev) = parsed else { + Issue.record("expected a vms delta, got \(parsed)") + return + } + #expect(rev == 44) + #expect(records.map(\.id) == ["vm-b", "vm-a"]) + #expect(records[0].summary?.status == "paused") + #expect(records[1] == VMSyncRecord(id: "vm-a", deleted: true, summary: nil)) + #expect(try frame(["type": "sync.delta", "collection": "vms"]) == .vmsDelta(records: [], rev: 0)) + } + @Test("Registry rows decode both instance levels, manual remotes, and lenient timestamps") func registryParse() throws { let data = try JSONSerialization.data(withJSONObject: [ diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index 34eac84da803..0ca31f5fa5e0 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -1398,5 +1398,164 @@ struct MachineUsageReadoutTests { #expect(fact?.contains(line) == true, "compact usage follows the name on the same line") #expect(CloudTreeMachineRowContent(machine: withUsage).toolTip.contains(line), "spend stays available on hover") } +} + +// MARK: - Realtime `vms` stream + +@MainActor +final class MachinesPanelVMStreamTests: XCTestCase { + + /// A panel with no client, no pin store and Cloud enabled: the stream's + /// merge path with nothing to fetch. + private func makeStreamingModel(pollingClock: any Clock = ContinuousClock()) -> MachinesPanelViewModel { + let model = MachinesPanelViewModel( + createCoordinator: MachineCreateCoordinator(notifier: { _ in }, notificationCenter: NotificationCenter()), + pollingClock: pollingClock, + isCloudEnabled: { true }, + catalogProvider: { .empty } + ) + model.localWorkspacesProvider = { [] } + return model + } + + /// Stats-less so a merge never samples the shared client. + private func streamSummary(_ id: String, status: String = "running", createdAt: Int64, label: String? = nil) -> VMSummary { + VMSummary( + id: id, provider: "freestyle", status: status, image: "cmux-devbox:devbox-20260828b", createdAt: createdAt, + capabilities: VMCapabilities(snapshot: true, restore: true, fork: true, stats: false), displayName: label + ) + } + + func testStreamUpsertReplacesTheRowInPlace() { + let model = makeStreamingModel() + model.applyVMChanges( + upserts: [streamSummary("c", createdAt: 3_000), streamSummary("b", createdAt: 2_000), streamSummary("a", createdAt: 1_000)], + deletions: [], authoritative: true + ) + XCTAssertEqual(model.machines.map(\.id), ["c", "b", "a"]) + XCTAssertTrue(model.hasLoadedOnce, "a complete fleet is a loaded list") + + model.applyVMChanges(upserts: [streamSummary("b", status: "paused", createdAt: 2_000, label: "dev box")], deletions: [], authoritative: false) + XCTAssertEqual(model.machines.map(\.id), ["c", "b", "a"], "an update keeps the row's slot") + XCTAssertEqual(model.machines[1].activity, .ready) + XCTAssertEqual(model.machines[1].label, "dev box") + XCTAssertEqual(model.machineIndexByID, ["c": 0, "b": 1, "a": 2]) + } + + func testStreamInsertKeepsCreatedAtDescendingOrder() { + let model = makeStreamingModel() + model.applyVMChanges( + upserts: [streamSummary("c", createdAt: 3_000), streamSummary("a", createdAt: 1_000)], + deletions: [], authoritative: true + ) + model.applyVMChanges(upserts: [streamSummary("b", createdAt: 2_000)], deletions: [], authoritative: false) + XCTAssertEqual(model.machines.map(\.id), ["c", "b", "a"]) + model.applyVMChanges(upserts: [streamSummary("d", createdAt: 4_000)], deletions: [], authoritative: false) + XCTAssertEqual(model.machines.map(\.id), ["d", "c", "b", "a"], "the newest machine leads, as the list endpoint orders it") + model.applyVMChanges(upserts: [streamSummary("z", createdAt: 0)], deletions: [], authoritative: false) + XCTAssertEqual(model.machines.map(\.id), ["d", "c", "b", "a", "z"], "an unknown creation time sorts last") + XCTAssertEqual(model.machineIndexByID, ["d": 0, "c": 1, "b": 2, "a": 3, "z": 4]) + XCTAssertNil(model.plan, "limits only arrive with a REST list") + } + + func testStreamDeleteRemovesTheRow() { + let model = makeStreamingModel() + model.applyVMChanges( + upserts: [streamSummary("c", createdAt: 3_000), streamSummary("b", createdAt: 2_000), streamSummary("a", createdAt: 1_000)], + deletions: [], authoritative: true + ) + model.applyVMChanges(upserts: [], deletions: ["b", "never-listed"], authoritative: false) + XCTAssertEqual(model.machines.map(\.id), ["c", "a"]) + XCTAssertEqual(model.machineIndexByID, ["c": 0, "a": 1]) + + // A backfilled snapshot is the complete fleet: rows it lacks are gone. + model.applyVMChanges(upserts: [streamSummary("a", createdAt: 1_000)], deletions: [], authoritative: true) + XCTAssertEqual(model.machines.map(\.id), ["a"]) + } + + func testUnbackfilledSnapshotNeverDeletes() { + let model = makeStreamingModel() + model.applyVMChanges( + upserts: [streamSummary("c", createdAt: 3_000), streamSummary("a", createdAt: 1_000)], + deletions: [], authoritative: true + ) + // The worker has only seen `b` since the collection shipped: it may + // add and update, but `c` and `a` stay until a REST list says otherwise. + model.applyVMChanges(upserts: [streamSummary("b", createdAt: 2_000)], deletions: [], authoritative: false) + XCTAssertEqual(model.machines.map(\.id), ["c", "b", "a"]) + model.applyVMChanges(upserts: [], deletions: [], authoritative: false) + XCTAssertEqual(model.machines.map(\.id), ["c", "b", "a"]) + } + + /// The stream record carries only the row's own columns; per-request + /// list fields (creator name, pool claim, free-access expiry) must survive + /// a live update instead of resetting until the next REST read. + func testStreamUpsertKeepsListOnlyFieldsOfTheExistingRow() { + let model = makeStreamingModel() + var listed = streamSummary("a", createdAt: 1_000) + listed.createdBy = VMCreator(userId: "user-1", displayName: "Ada") + let claim = CloudVMResourceReservation(vcpus: 4, memoryMb: 8_192) + listed.resourcePoolClaim = claim + listed.resourceReservation = claim + listed.agentUpdates = .latest + let expiresAt = Int64(Date().addingTimeInterval(3 * 86_400).timeIntervalSince1970 * 1000) + listed.freeAccessExpiresAt = expiresAt + model.applyVMChanges(upserts: [listed], deletions: [], authoritative: true) + let before = model.machines[0] + + var streamed = streamSummary("a", status: "paused", createdAt: 1_000, label: "renamed") + streamed.createdBy = VMCreator(userId: "user-1") + model.applyVMChanges(upserts: [streamed], deletions: [], authoritative: false) + + let after = model.machines[0] + XCTAssertEqual(after.label, "renamed", "the stream's own columns apply") + XCTAssertEqual(after.createdBy, VMCreator(userId: "user-1", displayName: "Ada")) + XCTAssertEqual(after.resourcePoolClaim, claim) + XCTAssertEqual(after.resourceReservation, claim) + XCTAssertEqual(after.agentUpdates, .latest) + XCTAssertEqual(after.freeAccess, before.freeAccess) + } + + func testStreamInsertTakesTheCreatorFromTheRecord() throws { + let summary = try VMClient.decodeListItem([ + "id": "vm-new", "provider": "freestyle", "image": "cmux-devbox", "status": "running", + "createdAt": 1_000, "createdByUserId": "user-2", + ]) + XCTAssertEqual(summary.createdBy, VMCreator(userId: "user-2")) + } + /// A REST read that started before a stream change lands after it: the + /// newer change must survive the older page instead of being undone. + func testStreamChangesDuringARestReadSurviveItsResult() { + let model = makeStreamingModel() + model.applyVMChanges( + upserts: [streamSummary("c", createdAt: 3_000), streamSummary("b", createdAt: 2_000), streamSummary("a", createdAt: 1_000)], + deletions: [], authoritative: true + ) + model.beginRestReadStreamCapture() + model.applyVMChanges(upserts: [], deletions: ["b"], authoritative: false) + model.applyVMChanges(upserts: [streamSummary("a", status: "paused", createdAt: 1_000, label: "renamed")], deletions: [], authoritative: false) + + let stalePage = VMListPage(vms: [streamSummary("c", createdAt: 3_000), streamSummary("b", createdAt: 2_000), streamSummary("a", createdAt: 1_000)]) + model.applyRefreshResult(.success(stalePage), generation: model.refreshGeneration, scope: nil) + + XCTAssertEqual(model.machines.map(\.id), ["c", "a"], "a delete newer than the read stays deleted") + XCTAssertEqual(model.machines[1].label, "renamed", "an update newer than the read stays applied") + + // Outside a read, the REST page is the authority again. + model.applyRefreshResult(.success(stalePage), generation: model.refreshGeneration, scope: nil) + XCTAssertEqual(model.machines.map(\.id), ["c", "b", "a"]) + } + + func testRoutinePollIntervalFollowsTheStream() { + let clock = CloudReadManualClock() + let model = makeStreamingModel(pollingClock: clock) + XCTAssertEqual(model.routinePollInterval, MachinesPanelViewModel.pollInterval) + XCTAssertEqual(MachinesPanelViewModel.pollInterval, .seconds(45)) + XCTAssertEqual(MachinesPanelViewModel.streamingPollInterval, .seconds(300)) + XCTAssertFalse(model.isVMSyncConnected) + XCTAssertNil(model.vmSync, "no auth coordinator, no stream") + model.stopVMSync() + XCTAssertEqual(model.routinePollInterval, .seconds(45), "stopping an absent stream keeps the polling cadence") + } } diff --git a/docs/presence-service.md b/docs/presence-service.md index ee8d6d05bef2..da189e5cf90a 100644 --- a/docs/presence-service.md +++ b/docs/presence-service.md @@ -128,6 +128,36 @@ pins** are never pruned, so a change to their shape is the one case that genuinely requires the versioned-record plus lazy-upgrade treatment. Most presence deploys can ship freely; only owner-pin schema changes need care. +## Cloud machine list sync (`vms`) + +The Mac machines list no longer needs to poll `GET /api/vm`. The web backend +publishes list-relevant `cloud_vms` writes to `POST /v1/sync/vms` on the +presence Worker (`workers/presence/src/syncVms.ts`), and the per-team +`TeamPresence` DO broadcasts them as `vms` sync deltas over the existing +`sync.hello` channel on `/v1/presence/subscribe`. Postgres stays the source of +truth: each op carries the row's `updated_at` as `sourceUpdatedAtMs`, the DO +rejects older ops, and every list read publishes a full `replace` (with its +observation time) that tombstones ids missing from the list, so the DO +converges without a backfill job and a machine created after the observation +survives. A republish of an unchanged row mints no rev. Until the first +`replace` lands, a DO holds only rows written since the collection shipped; +its `sync.snapshot` frames for `vms` carry `backfilled: false` (true after), +so the client applies such a snapshot upsert-only and keeps its REST list as +the initial truth instead of dropping machines the snapshot lacks. Other +collections never carry the field. The publisher on the web side is +`web/services/vms/presencePublisher.ts`: a decorator around the VM repository +re-reads a row after each list-relevant write and publishes it best-effort +(750 ms timeout, never failing the mutation). Request-scoped writers publish +after the response with `after()`; cron and reconcile writers, which run with +no request scope where Vercel would freeze detached work, await the bounded +publish inline before the write resolves. +The route is service-to-service only: Worker secret `VMS_PUBLISHER_SECRET` +must equal web `CMUX_PRESENCE_VMS_PUBLISHER_SECRET` (set once per Worker with +`wrangler secret put VMS_PUBLISHER_SECRET` on production and on the dev +instance); the body's `teamId` is the list scope (`ownerTeamId`) and is trusted +only behind that secret. No new Durable Object migration: the collection adds +`synced:vms:*` keys next to the existing sync key space. + ## Workspace viewing presence Workspace viewing is a separate protocol from device reachability. The source @@ -204,6 +234,24 @@ the first production deploy and dogfood. tag, best-effort, never disturbs the Mac. Every beat carries the full current attach-route set, a route change triggers one immediate out-of-cadence beat, and a clean quit sends a goodbye. +- **Mac Cloud machines list** (`Sources/Cloud/CloudVMSyncSubscriber.swift`, + owned by `MachinesPanelViewModel`): a second subscribe socket per visible + machines panel whose `sync.hello` asks only for `vms`, from the cursor and + epoch it last applied (0/0 on the first connect and after a team switch). + It sends the same `X-Cmux-Team-Id` header as `VMClient` + (`auth.resolvedTeamID`; omitted for a personal account without teams) and + fails closed on an account or team change, after which the panel + resubscribes under the new scope. Frames decode records with the REST list + parser (`VMClient.decodeListItem`) and merge into the rows in place: + upserts replace by id or insert in createdAt-descending order, tombstones + remove, a `backfilled: true` snapshot is the complete fleet, and an + unbackfilled snapshot is upsert-only. The stream never carries `limits`, so + a REST `GET /api/vm` still runs on panel show, wake, app activation, + network online and on every stream (re)connect; the routine poll stretches + from 45 s to 5 minutes while the stream is connected. Reconnect backoff is + `DeviceDirectory`'s (`1, 2, 5, 10, 30` s; at once after the 15-minute clean + close). The socket exists exactly while the panel polls (Cloud enabled, + signed in, visible, online). - **iOS** (`Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift`): typed WebSocket subscribe client. `MobileShellComposite` owns the subscription (starts on sign-in, blanks and stops on sign-out, backoff diff --git a/web/.env.example b/web/.env.example index 932ca7434ecf..4b335a72076b 100644 --- a/web/.env.example +++ b/web/.env.example @@ -140,6 +140,9 @@ CMUX_IROH_RATE_LIMIT_ID= # Must exactly match the presence Worker's CONNECTIVITY_INVALIDATION_SECRET. # Generate an independent value with `openssl rand -hex 32`. CMUX_CONNECTIVITY_INVALIDATION_SECRET= +# Must exactly match the presence Worker's VMS_PUBLISHER_SECRET (Cloud machine +# list sync publication). Independent value, `openssl rand -hex 32`. +CMUX_PRESENCE_VMS_PUBLISHER_SECRET= CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED=0 CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS= CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS= diff --git a/web/app/api/account/route.ts b/web/app/api/account/route.ts index a3972103ec93..83e568c8cebf 100644 --- a/web/app/api/account/route.ts +++ b/web/app/api/account/route.ts @@ -77,10 +77,16 @@ import { } from "../../../services/coderouter/repository"; import type { ProviderId } from "../../../services/vms/drivers"; import { jsonResponse } from "../../../services/vms/routeHelpers"; +import { + publishVmOps, + publishVmRowsById, + vmSyncTeamId, + type VmSyncOp, +} from "../../../services/vms/presencePublisher"; import { authorizeCronRequest } from "../../../services/cronAuth"; import { reportError } from "../../../services/observability/report"; import { createHostedSubrouterClient } from "../../../services/subrouter/hostedClient"; -import { OBSERVED_DESTROY_CLEANUP_METADATA_KEY } from "../../../services/vms/repository"; +import { OBSERVED_DESTROY_CLEANUP_METADATA_KEY, type CloudVmRow } from "../../../services/vms/repository"; import { destroyVm, listUserVms, @@ -1791,7 +1797,7 @@ function isStripeAlreadyInDeletionTargetState(error: unknown, messagePatterns: r async function deleteCmuxOwnedAccountRows(userId: string, accountTeamIds: readonly string[]): Promise { const db = cloudDb(); - await db.transaction(async (tx) => { + const vmSync = await db.transaction(async (tx) => { const now = new Date(); // Acquire the extra VM/team locks in a stable order. The handoff // authority helper below uses the same sorted-team rule; keeping this @@ -1812,6 +1818,8 @@ async function deleteCmuxOwnedAccountRows(userId: string, accountTeamIds: readon const userVmRows = await tx .select({ id: cloudVms.id, + userId: cloudVms.userId, + ownerTeamId: cloudVms.ownerTeamId, billingTeamId: cloudVms.billingTeamId, provider: cloudVms.provider, providerVmId: cloudVms.providerVmId, @@ -2006,7 +2014,38 @@ async function deleteCmuxOwnedAccountRows(userId: string, accountTeamIds: readon await tx.delete(vaultCliAuthRequests).where( eq(vaultCliAuthRequests.userId, userId), ); + return { + deletedRows: personalVmRows, + deletedAtMs: now.getTime(), + rewrittenIds: sharedTeamVmRows.map((vm) => vm.id), + }; }); + publishAccountDeletionVmSync(vmSync); +} + +/** Best-effort machines-list publication after the deletion commits; a + * failure here must never fail the account deletion. */ +function publishAccountDeletionVmSync(vmSync: { + readonly deletedRows: readonly Pick[]; + readonly deletedAtMs: number; + readonly rewrittenIds: readonly string[]; +}): void { + try { + // Hard-deleted personal rows: tombstone them at the deletion clock (the + // rows are gone, so there is nothing to re-read). + const byTeam = new Map(); + for (const vm of vmSync.deletedRows) { + if (!vm.providerVmId) continue; + const teamId = vmSyncTeamId(vm); + const op: VmSyncOp = { kind: "delete", id: vm.providerVmId, sourceUpdatedAtMs: vmSync.deletedAtMs }; + byTeam.set(teamId, [...(byTeam.get(teamId) ?? []), op]); + } + for (const [teamId, ops] of byTeam) void publishVmOps(teamId, ops); + // Shared-team rows keep living with a rewritten creator: re-read and republish. + void publishVmRowsById(vmSync.rewrittenIds); + } catch (error) { + console.error("[account] machines list publication skipped after deletion", error); + } } function observedDestroyCleanupFromMetadata( diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index a41646eb4852..711ee2aab976 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -93,6 +93,7 @@ import { VmTimingRecorder, } from "../../../services/vms/timings"; import { getGoVmUsage, GO_SAVED_VM_LIMIT } from "../../../services/vms/goUsage"; +import { publishVmOps, vmSyncReplaceOp } from "../../../services/vms/presencePublisher"; // Provisioning can run minutes; without an explicit budget the platform @@ -147,9 +148,16 @@ export async function GET(request: Request): Promise { throw err; } + // Taken BEFORE the read: the presence Worker tombstones machines missing + // from this list as of this instant, so a machine created after it + // (newer row clock) survives the replace published below. + const listObservedAtMs = Date.now(); const listed = await runVmRoute(listUserVms(user.id, billingTeamId), { request }); if (!listed.ok) return listed.response; const entries = listed.value; + // Converge the realtime `vms` collection on exactly what this list shows; + // best-effort after the response, never on the request path. + void publishVmOps(billingTeamId || user.id, [vmSyncReplaceOp(entries, listObservedAtMs)]); setSpanAttributes(span, { "cmux.vm.count": entries.length }); // REST adapter: expose `id` at the top level so existing CLI + curl users don't need to // learn the new `providerVmId` field name. Swift CLI reads `vm["id"]`. diff --git a/web/app/env.ts b/web/app/env.ts index 29906af0263b..dbbaed92e037 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -433,6 +433,10 @@ export const env = createEnv({ // Server-to-worker authentication for revision publication. Native clients // hold only their Stack access token and can never mint invalidations. CMUX_CONNECTIVITY_INVALIDATION_SECRET: z.string().min(32).max(512).optional(), + // Server-to-worker authentication for Cloud machine list (`vms` sync) + // publication. Must equal the presence Worker's VMS_PUBLISHER_SECRET. + // Optional: unset skips publication and the Mac list keeps polling. + CMUX_PRESENCE_VMS_PUBLISHER_SECRET: z.string().min(32).max(512).optional(), CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: localDevelopmentOptIn( "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER", ), @@ -595,6 +599,9 @@ export const env = createEnv({ CMUX_CONNECTIVITY_INVALIDATION_SECRET: trimEnv( process.env.CMUX_CONNECTIVITY_INVALIDATION_SECRET, ), + CMUX_PRESENCE_VMS_PUBLISHER_SECRET: trimEnv( + process.env.CMUX_PRESENCE_VMS_PUBLISHER_SECRET, + ), CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: trimEnv( process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER, ), diff --git a/web/services/vm-publications/managedRepository.ts b/web/services/vm-publications/managedRepository.ts index 1495fedb8e9d..219faf1206b7 100644 --- a/web/services/vm-publications/managedRepository.ts +++ b/web/services/vm-publications/managedRepository.ts @@ -5,6 +5,7 @@ import { assertAccountDeletionUserMutationAllowed } from "../account/deletionLoc import { allocateVmSlug } from "../vms/vmNaming"; import { managedPublicationHostname, organizationSlugCandidate, validOrganizationSlug } from "./managedHostnames"; import { PublicationConflictError, PublicationNotFoundError, type CloudVmPublicationTarget } from "./repository"; +import { publishVmRowsById } from "../vms/presencePublisher"; type Tx = Parameters["transaction"]>[0]>[0]; @@ -80,12 +81,24 @@ async function ensureVmSlug(tx: Tx, vm: typeof cloudVms.$inferSelect, scopeId: s )); return !!row; }); - await tx.update(cloudVms).set({ slug }).where(eq(cloudVms.id, vm.id)); + // Bump the row clock: the slug is list-visible and the presence Worker + // orders publications by `updated_at`. + await tx.update(cloudVms).set({ slug, updatedAt: new Date() }).where(eq(cloudVms.id, vm.id)); return slug; } /** VM authorization and hostname reservation commit together. No managed domain row. */ export async function reserveManagedPublication(input: ManagedPublicationInput): Promise { + const { slugAssigned, ...target } = await reserveManagedPublicationTx(input); + // A first publication assigns the machine's slug: tell the machines list + // once the row is committed (best-effort, after the response). + if (slugAssigned) void publishVmRowsById([target.vm.id]); + return target; +} + +async function reserveManagedPublicationTx( + input: ManagedPublicationInput, +): Promise { return cloudDb().transaction(async (tx) => { await assertAccountDeletionUserMutationAllowed(tx, input.ownerUserId); const { vm, scopeId } = await requireManagedVm(tx, input); @@ -94,6 +107,7 @@ export async function reserveManagedPublication(input: ManagedPublicationInput): } const orgSlug = await reserveOrganization(tx, input, scopeId); const vmSlug = await ensureVmSlug(tx, vm, scopeId); + const slugAssigned = vm.slug !== vmSlug; const hostname = managedPublicationHostname(vmSlug, orgSlug, input.port, input.generatedDomain); const [existing] = await tx.select().from(cloudVmPublications).where(and( eq(cloudVmPublications.hostname, hostname), isNull(cloudVmPublications.disabledAt), @@ -101,7 +115,7 @@ export async function reserveManagedPublication(input: ManagedPublicationInput): if (existing) { if (existing.vmId !== vm.id || existing.port !== input.port) throw new PublicationConflictError({ reason: "hostname_taken" }); if (existing.state === "disabling") throw new PublicationConflictError({ reason: "publication_not_active" }); - return { publication: existing, vm: { ...vm, slug: vmSlug }, domain: null }; + return { publication: existing, vm: { ...vm, slug: vmSlug }, domain: null, slugAssigned }; } const [publication] = await tx.insert(cloudVmPublications).values({ ownerUserId: input.ownerUserId, vmId: vm.id, hostname, domainId: null, @@ -109,6 +123,6 @@ export async function reserveManagedPublication(input: ManagedPublicationInput): teamId: input.teamId, createdAt: input.now, updatedAt: input.now, }).onConflictDoNothing().returning(); if (!publication) throw new PublicationConflictError({ reason: "hostname_taken" }); - return { publication, vm: { ...vm, slug: vmSlug }, domain: null }; + return { publication, vm: { ...vm, slug: vmSlug }, domain: null, slugAssigned }; }); } diff --git a/web/services/vms/presencePublisher.ts b/web/services/vms/presencePublisher.ts new file mode 100644 index 000000000000..a3a7494d25cf --- /dev/null +++ b/web/services/vms/presencePublisher.ts @@ -0,0 +1,329 @@ +// Realtime Cloud machine list publication to the presence Worker. +// +// The Mac machines list subscribes to the `vms` sync collection on the +// per-team presence Durable Object instead of polling `GET /api/vm`. Postgres +// stays the source of truth; this module pushes each list-relevant +// `cloud_vms` write (and, after every list read, the full visible list) to +// `POST /v1/sync/vms` on the Worker (`workers/presence/src/syncVms.ts`). +// +// Publication is best-effort and never fails a mutation: a request-scoped +// writer publishes after the response (`after()`), a cron or reconcile writer +// with no request scope awaits the bounded publish inline (Vercel freezes +// detached work once the function returns), and a Worker outage only delays +// the next list convergence (the Mac still has its REST list). The +// Worker is service-to-service authenticated with +// `CMUX_PRESENCE_VMS_PUBLISHER_SECRET` (no user bearer: cron reconcilers have +// none), so the body's `teamId` is the list scope the Worker trusts. Each op +// carries the row's `updated_at` as `sourceUpdatedAtMs` so the Worker rejects +// out-of-order publications from concurrent web instances. + +import { eq } from "drizzle-orm"; +import * as Effect from "effect/Effect"; +import { after } from "next/server"; +import { env } from "../../app/env"; +import { cloudDb } from "../../db/client"; +import { cloudVms } from "../../db/schema"; +import { isProviderId, vmCapabilitiesFor } from "./drivers"; +import type { VmCapabilities } from "./drivers/types"; +import { vmImageKindFor } from "./images/resolver"; +import type { CloudVmRow, VmRepositoryShape } from "./repository"; + +/** Wall-clock budget for one publication. The Worker does one DO RPC. */ +export const VM_SYNC_PUBLISH_TIMEOUT_MS = 750; + +/** One machine as the Worker stores it: the `GET /api/vm` entry minus + * per-request fields (creator names, plan windows). */ +export type VmSyncRecord = { + readonly id: string; + readonly provider: string; + readonly status: string; + readonly image: string; + readonly imageVersion: string | null; + readonly kind: string; + readonly capabilities: VmCapabilities; + readonly createdAt: number; + readonly displayName: string | null; + readonly slug: string | null; + readonly createdByUserId: string; + readonly address: { readonly ipv4: string | null; readonly ipv6: string | null }; + readonly sourceUpdatedAtMs: number; +}; + +export type VmSyncOp = + | { readonly kind: "upsert"; readonly record: VmSyncRecord } + | { readonly kind: "delete"; readonly id: string; readonly sourceUpdatedAtMs: number } + | { readonly kind: "replace"; readonly records: readonly VmSyncRecord[]; readonly observedAtMs: number }; + +/** The row fields the list entry carries (mirrors `VmEntry` in workflows.ts). */ +export type VmSyncEntry = { + readonly providerVmId: string; + readonly provider: string; + readonly image: string; + readonly imageVersion: string | null; + readonly status: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly displayName: string | null; + readonly slug: string | null; + readonly createdByUserId: string; + readonly ownerTeamId: string; + readonly addressIpv4: string | null; + readonly addressIpv6: string | null; +}; + +/** Build the synced record from a list entry. Derivations (image kind, + * provider capabilities) match the `GET /api/vm` handler. */ +export function vmSyncRecordFromEntry(entry: VmSyncEntry): VmSyncRecord { + const provider = entry.provider; + return { + id: entry.providerVmId, + provider, + status: entry.status, + image: entry.image, + imageVersion: entry.imageVersion, + kind: isProviderId(provider) ? vmImageKindFor(provider, entry.image) : "base", + capabilities: isProviderId(provider) ? vmCapabilitiesFor(provider) : emptyCapabilities(), + createdAt: entry.createdAt, + displayName: entry.displayName, + slug: entry.slug, + createdByUserId: entry.createdByUserId, + address: { ipv4: entry.addressIpv4, ipv6: entry.addressIpv6 }, + sourceUpdatedAtMs: entry.updatedAt, + }; +} + +/** The list scope a row belongs to: the same team `listUserVms` filters on + * (`ownerTeamId`, falling back like the runtime insert does). */ +export function vmSyncTeamId(row: Pick): string { + return row.ownerTeamId.trim() || row.billingTeamId?.trim() || row.userId.trim(); +} + +/** Build the synced record from a `cloud_vms` row, or null when the row has no + * provider VM id (the list omits it too). */ +export function vmSyncRecordFromRow(row: CloudVmRow): VmSyncRecord | null { + if (!row.providerVmId) return null; + const metadata = row.providerMetadata ?? {}; + const ipv4 = metadata["networkIpv4"]; + const ipv6 = metadata["networkIpv6"]; + return vmSyncRecordFromEntry({ + providerVmId: row.providerVmId, + provider: row.provider, + image: row.imageId, + imageVersion: row.imageVersion, + status: row.status, + createdAt: row.createdAt.getTime(), + updatedAt: row.updatedAt.getTime(), + displayName: row.displayName ?? null, + slug: row.slug ?? null, + createdByUserId: row.userId, + ownerTeamId: vmSyncTeamId(row), + addressIpv4: typeof ipv4 === "string" && ipv4 ? ipv4 : null, + addressIpv6: typeof ipv6 === "string" && ipv6 ? ipv6 : null, + }); +} + +/** Whether `listUserVms` would return this row (`GET /api/vm` filter). */ +export function vmRowIsListed(row: Pick): boolean { + return row.status !== "destroyed" && !!row.providerVmId && isProviderId(row.provider); +} + +/** The single op that brings the Worker in line with one row: an upsert when + * the list shows it, a delete when it left the list, nothing without an id. */ +export function vmSyncOpForRow(row: CloudVmRow): VmSyncOp | null { + if (!row.providerVmId) return null; + if (!vmRowIsListed(row)) { + return { kind: "delete", id: row.providerVmId, sourceUpdatedAtMs: row.updatedAt.getTime() }; + } + const record = vmSyncRecordFromRow(row); + return record ? { kind: "upsert", record } : null; +} + +/** The full-list op published after a list read. `observedAtMs` is taken + * BEFORE the query so a machine created after it carries a newer clock and + * the Worker keeps it. */ +export function vmSyncReplaceOp(entries: readonly VmSyncEntry[], observedAtMs: number): VmSyncOp { + return { kind: "replace", records: entries.map(vmSyncRecordFromEntry), observedAtMs }; +} + +/** Builds the exact backend-only Worker publication without performing I/O. + * Null when the Worker or the publisher secret is not configured. */ +export function buildVmSyncRequest( + teamId: string, + ops: readonly VmSyncOp[], + configuration: { + readonly baseURL?: string; + readonly publisherSecret?: string; + } = { + baseURL: env.CMUX_PRESENCE_BASE_URL, + publisherSecret: env.CMUX_PRESENCE_VMS_PUBLISHER_SECRET, + }, +): Request | null { + const { baseURL, publisherSecret } = configuration; + if (!baseURL || !publisherSecret || !teamId || ops.length === 0) return null; + return new Request(new URL("/v1/sync/vms", baseURL), { + method: "POST", + headers: { + "content-type": "application/json", + "x-cmux-vms-publisher-secret": publisherSecret, + }, + body: JSON.stringify({ teamId, ops }), + }); +} + +/** Publish ops for one team. Best-effort: resolves without throwing on any + * failure (misconfiguration, timeout, non-2xx), logging a coarse warning. */ +export async function publishVmSync( + teamId: string, + ops: readonly VmSyncOp[], + fetchImpl: typeof fetch = fetch, +): Promise { + const publication = buildVmSyncRequest(teamId, ops); + if (!publication) return; + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new Error("vm_sync_publish_timeout")), + VM_SYNC_PUBLISH_TIMEOUT_MS, + ); + try { + const response = await fetchImpl(publication, { signal: controller.signal }); + if (!response.ok) throw new Error(`vm_sync_publish_rejected_${response.status}`); + } catch (error) { + // The Postgres write is committed; the next list read republishes the + // full list, so a Worker outage only delays convergence. + console.warn("[VM] presence vms publish failed", { + ops: ops.length, + error: error instanceof Error ? error.message.slice(0, 120) : String(error).slice(0, 120), + }); + } finally { + clearTimeout(timeout); + } +} + +export type VmSyncPublisherDeps = { + /** Re-read one row by primary key after a write committed. */ + readonly readRow: (id: string) => Promise; + readonly publish: (teamId: string, ops: readonly VmSyncOp[]) => Promise; + /** Runs `work` after the response when a request scope exists (resolves at + * once), otherwise awaits it (resolves when the publish finished). */ + readonly schedule: (work: () => Promise) => Promise; +}; + +/** + * Run best-effort publication work. Inside a request scope Vercel keeps the + * function alive for `after` callbacks, so the caller continues at once. + * Outside one (cron reconcilers, scripts) `after` throws E91 and detached + * work would be frozen with the function, so the work is awaited instead; + * `publishVmSync` bounds it to `VM_SYNC_PUBLISH_TIMEOUT_MS`. Never rejects. + */ +export function scheduleVmSyncWork( + work: () => Promise, + defer: (task: () => Promise) => void = after, +): Promise { + const guarded = () => work().catch((err) => console.error("[VM] vms sync publish work failed", err)); + try { + defer(guarded); + return Promise.resolve(); + } catch { + return guarded(); + } +} + +export async function readCloudVmRowById(id: string): Promise { + const [row] = await cloudDb().select().from(cloudVms).where(eq(cloudVms.id, id)).limit(1); + return row ?? null; +} + +export const liveVmSyncPublisherDeps: VmSyncPublisherDeps = { + readRow: readCloudVmRowById, + publish: publishVmSync, + schedule: scheduleVmSyncWork, +}; + +/** Publish the current state of the rows with these ids, grouped by team. + * Resolves at once in a request scope, after the publish otherwise. */ +export function publishVmRowsById( + ids: readonly string[], + deps: VmSyncPublisherDeps = liveVmSyncPublisherDeps, +): Promise { + if (ids.length === 0) return Promise.resolve(); + return deps.schedule(async () => { + const byTeam = new Map(); + for (const id of new Set(ids)) { + const row = await deps.readRow(id); + const op = row ? vmSyncOpForRow(row) : null; + if (!row || !op) continue; + const teamId = vmSyncTeamId(row); + byTeam.set(teamId, [...(byTeam.get(teamId) ?? []), op]); + } + for (const [teamId, ops] of byTeam) await deps.publish(teamId, ops); + }); +} + +/** Publish already-built ops (rows that no longer exist, or a list replace). */ +export function publishVmOps( + teamId: string, + ops: readonly VmSyncOp[], + deps: VmSyncPublisherDeps = liveVmSyncPublisherDeps, +): Promise { + if (ops.length === 0) return Promise.resolve(); + return deps.schedule(() => deps.publish(teamId, ops)); +} + +/** After the write succeeds, publish the row. The effect resolves once the + * publication is scheduled (request scope) or finished (cron path). */ +function tapRow( + effect: Effect.Effect, + id: string, + deps: VmSyncPublisherDeps, +): Effect.Effect { + return effect.pipe(Effect.tap(() => Effect.promise(() => publishVmRowsById([id], deps)))); +} + +/** + * The repository wrapped so every list-relevant write also reaches the + * presence Worker. After each write succeeds the row is re-read by id and its + * current list state is published (an upsert, or a delete once destroyed). + * Optional methods stay optional so test doubles keep compiling. + */ +export function withVmSyncPublication( + repository: VmRepositoryShape, + deps: VmSyncPublisherDeps = liveVmSyncPublisherDeps, +): VmRepositoryShape { + const { markCreateAbandoned, resolveCreateCleanup, mergeProviderMetadata } = repository; + return { + ...repository, + markCreateRunning: (input) => tapRow(repository.markCreateRunning(input), input.id, deps), + markBaseCreateRunning: (input) => tapRow(repository.markBaseCreateRunning(input), input.vmId, deps), + markCreateFailed: (input) => tapRow(repository.markCreateFailed(input), input.id, deps), + markBaseCreateFailed: (input) => tapRow(repository.markBaseCreateFailed(input), input.vmId, deps), + markProviderObservedStatus: (input) => tapRow(repository.markProviderObservedStatus(input), input.id, deps), + reservePausedResume: (input) => tapRow(repository.reservePausedResume(input), input.id, deps), + setDisplayName: (input) => tapRow(repository.setDisplayName(input), input.id, deps), + markDestroyed: (id) => tapRow(repository.markDestroyed(id), id, deps), + ...(markCreateAbandoned + ? { markCreateAbandoned: (input) => tapRow(markCreateAbandoned(input), input.id, deps) } + : {}), + ...(resolveCreateCleanup + ? { resolveCreateCleanup: (input) => tapRow(resolveCreateCleanup(input), input.id, deps) } + : {}), + ...(mergeProviderMetadata + ? { mergeProviderMetadata: (input) => tapRow(mergeProviderMetadata(input), input.id, deps) } + : {}), + }; +} + +function emptyCapabilities(): VmCapabilities { + return { + snapshot: false, + restore: false, + fork: false, + restart: false, + exec: false, + stats: false, + ports: false, + desktop: false, + sizing: false, + persistentHome: false, + attachTransports: [], + }; +} diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index db59ba094227..0504332252d9 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -131,6 +131,7 @@ import { VM_PREVIEW_LEASE_RETENTION_MS, } from "./operationTimeouts"; import { withVmProductAnalytics, type VmDestroySource } from "./productAnalytics"; +import { withVmSyncPublication } from "./presencePublisher"; import { CREATE_CLEANUP_PROVIDER_VM_ID_KEY, OBSERVED_DESTROY_CLEANUP_METADATA_KEY, @@ -201,6 +202,8 @@ export type VmEntry = { readonly imageVersion: string | null; readonly status: CloudVmStatus; readonly createdAt: number; + /** The row's `updated_at` (epoch ms): the freshness clock for `vms` sync publication. */ + readonly updatedAt: number; readonly displayName: string | null; /** Generated three-word name (services/vms/vmNaming.ts); null on rows older than the column. */ readonly slug: string | null; @@ -271,11 +274,13 @@ export type VmModelPlaneRevoker = Pick; /** * The Postgres repository wrapped so every usage-ledger write also reaches - * PostHog as a product event (services/vms/productAnalytics.ts). + * PostHog as a product event (services/vms/productAnalytics.ts) and every + * list-relevant machine write reaches the presence Worker's `vms` sync + * collection (services/vms/presencePublisher.ts). */ export const VmRepositoryWithAnalyticsLive = Layer.succeed( VmRepository, - withVmProductAnalytics(vmRepositoryLiveShape), + withVmProductAnalytics(withVmSyncPublication(vmRepositoryLiveShape)), ); export const VmWorkflowLive = Layer.mergeAll(VmRepositoryWithAnalyticsLive, VmProviderGatewayLive, VmBillingGatewayLive); @@ -6036,6 +6041,7 @@ function vmEntryFromRow(row: CloudVmRow): VmEntry { imageVersion: row.imageVersion, status: row.status, createdAt: row.createdAt.getTime(), + updatedAt: row.updatedAt.getTime(), displayName: row.displayName ?? null, slug: row.slug ?? null, createdByUserId: row.userId, diff --git a/web/tests/vm-max-memory-workflow.test.ts b/web/tests/vm-max-memory-workflow.test.ts index 94f76ef33977..7285c5fee24a 100644 --- a/web/tests/vm-max-memory-workflow.test.ts +++ b/web/tests/vm-max-memory-workflow.test.ts @@ -111,6 +111,7 @@ test("a native fork retry returns a grandfathered oversized row to Pro", async ( imageId: "snapshot", imageVersion: null, createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), displayName: null, slug: null, providerMetadata: { @@ -265,6 +266,7 @@ test("reopening an existing oversized Base remains usable during migration", asy imageId: "snapshot", imageVersion: null, createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), providerMetadata: { cmuxResourceReservation: { vcpus, @@ -312,6 +314,7 @@ test("reopening a markerless Base persists the measured reservation before retur imageId: "snapshot", imageVersion: null, createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), displayName: null, slug: null, providerMetadata: {}, @@ -369,6 +372,7 @@ test("an idempotent create retry returns a grandfathered oversized row to Pro", imageId: "snapshot", imageVersion: null, createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), displayName: null, slug: null, providerMetadata: { diff --git a/web/tests/vm-presence-publisher.test.ts b/web/tests/vm-presence-publisher.test.ts new file mode 100644 index 000000000000..d39173a6dcaf --- /dev/null +++ b/web/tests/vm-presence-publisher.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import { vmCapabilitiesFor } from "../services/vms/drivers"; +import { + buildVmSyncRequest, + publishVmOps, + publishVmRowsById, + publishVmSync, + scheduleVmSyncWork, + vmSyncOpForRow, + vmSyncRecordFromRow, + vmSyncReplaceOp, + vmSyncTeamId, + withVmSyncPublication, + type VmSyncOp, + type VmSyncPublisherDeps, +} from "../services/vms/presencePublisher"; +import type { CloudVmRow, VmRepositoryShape } from "../services/vms/repository"; + +const T0 = new Date("2026-09-29T12:00:00.000Z"); + +function row(overrides: Partial = {}): CloudVmRow { + return { + id: "00000000-0000-4000-8000-000000000001", + userId: "user-a", + billingTeamId: "team-1", + billingPlanId: "pro", + provider: "freestyle", + providerVmId: "vm-1", + displayName: "Build box", + slug: "sleepy-teal-otter", + imageId: "devbox", + imageVersion: "3", + status: "running", + idempotencyKey: null, + createdAt: new Date(T0.getTime() - 60_000), + updatedAt: T0, + destroyedAt: null, + failureCode: null, + failureMessage: null, + providerMetadata: { networkIpv4: "10.0.0.5", networkIpv6: "fd00::5" }, + ownerTeamId: "team-1", + coderouterPoolId: null, + agentUpdates: null, + networkPolicyStatus: null, + networkPolicy: null, + ...overrides, + }; +} + +/** Deps that run scheduled work inline and record every publication. */ +function recordingDeps(rows: Record) { + const published: Array<{ teamId: string; ops: readonly VmSyncOp[] }> = []; + const pending: Promise[] = []; + const deps: VmSyncPublisherDeps = { + readRow: async (id) => rows[id] ?? null, + publish: async (teamId, ops) => { published.push({ teamId, ops }); }, + schedule: (work) => { const p = work(); pending.push(p); return Promise.resolve(); }, + }; + return { deps, published, settle: () => Promise.all(pending) }; +} + +describe("vm sync record shape", () => { + test("mirrors the GET /api/vm entry and carries the row clock", () => { + const record = vmSyncRecordFromRow(row()); + expect(record).toEqual({ + id: "vm-1", + provider: "freestyle", + status: "running", + image: "devbox", + imageVersion: "3", + kind: "desktop", + capabilities: vmCapabilitiesFor("freestyle"), + createdAt: T0.getTime() - 60_000, + displayName: "Build box", + slug: "sleepy-teal-otter", + createdByUserId: "user-a", + address: { ipv4: "10.0.0.5", ipv6: "fd00::5" }, + sourceUpdatedAtMs: T0.getTime(), + }); + expect(vmSyncRecordFromRow(row({ providerVmId: null }))).toBeNull(); + expect(vmSyncRecordFromRow(row({ providerMetadata: {} }))?.address).toEqual({ ipv4: null, ipv6: null }); + }); + + test("team scope is the list's ownerTeamId with the runtime fallback", () => { + expect(vmSyncTeamId(row())).toBe("team-1"); + expect(vmSyncTeamId(row({ ownerTeamId: "", billingTeamId: null }))).toBe("user-a"); + expect(vmSyncTeamId(row({ ownerTeamId: " ", billingTeamId: "team-2" }))).toBe("team-2"); + }); + + test("a destroyed row publishes a delete, a listed row an upsert, no id nothing", () => { + expect(vmSyncOpForRow(row({ status: "destroyed" }))).toEqual({ + kind: "delete", + id: "vm-1", + sourceUpdatedAtMs: T0.getTime(), + }); + expect(vmSyncOpForRow(row({ status: "failed" }))?.kind).toBe("upsert"); + expect(vmSyncOpForRow(row({ providerVmId: null, status: "failed" }))).toBeNull(); + }); + + test("replace carries the observed list and observation time", () => { + const op = vmSyncReplaceOp([{ + providerVmId: "vm-1", + provider: "freestyle", + image: "devbox", + imageVersion: null, + status: "paused", + createdAt: 1, + updatedAt: 2, + displayName: null, + slug: null, + createdByUserId: "user-a", + ownerTeamId: "team-1", + addressIpv4: null, + addressIpv6: null, + }], 99); + expect(op.kind).toBe("replace"); + if (op.kind !== "replace") return; + expect(op.observedAtMs).toBe(99); + expect(op.records.map((r) => [r.id, r.status, r.sourceUpdatedAtMs])).toEqual([["vm-1", "paused", 2]]); + }); +}); + +describe("vm sync request", () => { + const ops: VmSyncOp[] = [{ kind: "delete", id: "vm-1", sourceUpdatedAtMs: 1 }]; + + test("builds the backend-only Worker publication with the service secret only", async () => { + const request = buildVmSyncRequest("team-1", ops, { + baseURL: "https://presence.example.test/dev", + publisherSecret: "v".repeat(64), + }); + expect(request?.url).toBe("https://presence.example.test/v1/sync/vms"); + expect(request?.method).toBe("POST"); + expect(request?.headers.get("x-cmux-vms-publisher-secret")).toBe("v".repeat(64)); + expect(request?.headers.get("authorization")).toBeNull(); + expect(await request?.json()).toEqual({ teamId: "team-1", ops }); + }); + + test("skips publication when unconfigured or empty", () => { + expect(buildVmSyncRequest("team-1", ops, { baseURL: "https://presence.example.test" })).toBeNull(); + expect(buildVmSyncRequest("team-1", ops, { publisherSecret: "v".repeat(64) })).toBeNull(); + expect(buildVmSyncRequest("team-1", [], { baseURL: "https://x.test", publisherSecret: "v".repeat(64) })).toBeNull(); + expect(buildVmSyncRequest("", ops, { baseURL: "https://x.test", publisherSecret: "v".repeat(64) })).toBeNull(); + }); + + test("publishVmSync never throws on a rejected or failing publication", async () => { + // Unconfigured env: resolves without I/O. + await expect(publishVmSync("team-1", ops, async () => { throw new Error("must not be called"); })).resolves.toBeUndefined(); + }); +}); + +describe("withVmSyncPublication", () => { + const vmId = "00000000-0000-4000-8000-000000000001"; + const baseVmId = "00000000-0000-4000-8000-000000000002"; + + function fakeRepository(calls: string[]): VmRepositoryShape { + const ok = (name: string, value: A) => Effect.sync(() => { calls.push(name); return value; }); + const shape: Partial = { + markCreateRunning: (input) => ok("markCreateRunning", row({ id: input.id })), + markBaseCreateRunning: (input) => ok("markBaseCreateRunning", row({ id: input.vmId })), + markCreateFailed: () => ok("markCreateFailed", true), + markBaseCreateFailed: () => ok("markBaseCreateFailed", true), + markCreateAbandoned: (input) => ok("markCreateAbandoned", { vm: row({ id: input.id }), isBase: false }), + resolveCreateCleanup: () => ok("resolveCreateCleanup", true), + markProviderObservedStatus: () => ok("markProviderObservedStatus", true), + reservePausedResume: (input) => ok("reservePausedResume", row({ id: input.id })), + setDisplayName: () => ok("setDisplayName", true), + markDestroyed: () => ok("markDestroyed", undefined), + mergeProviderMetadata: () => ok("mergeProviderMetadata", undefined), + // Non-list writes must pass through untouched. + recordUsageEvent: () => ok("recordUsageEvent", undefined), + }; + return shape as VmRepositoryShape; + } + + test("each list-relevant write publishes the re-read row once with the right op", async () => { + const calls: string[] = []; + const rows: Record = { + [vmId]: row({ id: vmId }), + [baseVmId]: row({ id: baseVmId, providerVmId: "vm-base", ownerTeamId: "team-2", status: "provisioning" }), + }; + const { deps, published, settle } = recordingDeps(rows); + const repo = withVmSyncPublication(fakeRepository(calls), deps); + const run = (effect: Effect.Effect) => Effect.runPromise(effect); + + await run(repo.markCreateRunning({ id: vmId, providerVmId: "vm-1", image: "devbox" })); + await run(repo.markBaseCreateRunning({ baseId: "b", generation: 1, vmId: baseVmId, providerVmId: "vm-base", image: "devbox", userId: "user-a" })); + await run(repo.markCreateFailed({ id: vmId, code: "c", message: "m" })); + await run(repo.markBaseCreateFailed({ baseId: "b", generation: 1, vmId: baseVmId, userId: "user-a", code: "c", message: "m" })); + await run(repo.markCreateAbandoned!({ id: vmId, before: T0, now: T0, code: "c", message: "m" })); + await run(repo.resolveCreateCleanup!({ id: vmId, providerVmId: "vm-1", leaseId: "l" })); + await run(repo.markProviderObservedStatus({ id: vmId, providerVmId: "vm-1", status: "paused" })); + await run(repo.reservePausedResume({ id: vmId, userId: "user-a", providerVmId: "vm-1", maxActiveVms: null })); + await run(repo.setDisplayName({ id: vmId, displayName: "x" })); + await run(repo.mergeProviderMetadata!({ id: vmId, patch: { networkIpv4: "10.0.0.9" } })); + // Destroyed: the re-read row is gone from the list, so a delete goes out. + rows[vmId] = row({ id: vmId, status: "destroyed", updatedAt: new Date(T0.getTime() + 1) }); + await run(repo.markDestroyed(vmId)); + await run(repo.recordUsageEvent({} as never)); + await settle(); + + expect(calls).toEqual([ + "markCreateRunning", "markBaseCreateRunning", "markCreateFailed", "markBaseCreateFailed", + "markCreateAbandoned", "resolveCreateCleanup", "markProviderObservedStatus", "reservePausedResume", + "setDisplayName", "mergeProviderMetadata", "markDestroyed", "recordUsageEvent", + ]); + // One publication per list-relevant write (11), each a single op. + expect(published).toHaveLength(11); + expect(published.every((p) => p.ops.length === 1)).toBe(true); + const kinds = published.map((p) => [p.teamId, p.ops[0]?.kind, p.ops[0]?.kind === "delete" ? p.ops[0].id : (p.ops[0] as { record: { id: string } }).record.id]); + expect(kinds).toEqual([ + ["team-1", "upsert", "vm-1"], + ["team-2", "upsert", "vm-base"], + ["team-1", "upsert", "vm-1"], + ["team-2", "upsert", "vm-base"], + ["team-1", "upsert", "vm-1"], + ["team-1", "upsert", "vm-1"], + ["team-1", "upsert", "vm-1"], + ["team-1", "upsert", "vm-1"], + ["team-1", "upsert", "vm-1"], + ["team-1", "upsert", "vm-1"], + ["team-1", "delete", "vm-1"], + ]); + const last = published[10]?.ops[0]; + expect(last).toEqual({ kind: "delete", id: "vm-1", sourceUpdatedAtMs: T0.getTime() + 1 }); + }); + + test("a failed write publishes nothing and a row without provider id is skipped", async () => { + const calls: string[] = []; + const { deps, published, settle } = recordingDeps({ [vmId]: row({ id: vmId, providerVmId: null }) }); + const failing: VmRepositoryShape = { + ...fakeRepository(calls), + setDisplayName: () => Effect.fail(new Error("db down")) as never, + }; + const repo = withVmSyncPublication(failing, deps); + await expect(Effect.runPromise(repo.setDisplayName({ id: vmId, displayName: "x" }))).rejects.toThrow("db down"); + await Effect.runPromise(repo.markProviderObservedStatus({ id: vmId, providerVmId: "vm-1", status: "paused" })); + await settle(); + expect(published).toEqual([]); + }); + + test("optional methods stay absent when the wrapped repository lacks them", () => { + const calls: string[] = []; + const { markCreateAbandoned: _a, resolveCreateCleanup: _b, mergeProviderMetadata: _c, ...rest } = fakeRepository(calls); + const repo = withVmSyncPublication(rest as VmRepositoryShape, recordingDeps({}).deps); + expect(repo.markCreateAbandoned).toBeUndefined(); + expect(repo.resolveCreateCleanup).toBeUndefined(); + expect(repo.mergeProviderMetadata).toBeUndefined(); + }); +}); + +/** `after()` outside a request scope, as a cron reconciler sees it. Passed + * explicitly because other suites mock `next/server` process-wide. */ +const noRequestScope = (): void => { throw new Error("`after` was called outside a request scope"); }; + +describe("cron path (no request scope) awaits the publish", () => { + test("scheduleVmSyncWork resolves only after the work finished and never rejects", async () => { + // Without a request scope `after()` throws, as it does for a cron + // reconciler on Vercel: the work must be awaited. + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let finished = false; + const scheduled = scheduleVmSyncWork(async () => { await gate; finished = true; }, noRequestScope); + let settled = false; + void scheduled.then(() => { settled = true; }); + await Promise.resolve(); + expect(settled).toBe(false); + release(); + await scheduled; + expect(finished).toBe(true); + await expect(scheduleVmSyncWork(async () => { throw new Error("worker down"); }, noRequestScope)).resolves.toBeUndefined(); + }); + + test("a decorated write does not resolve before an awaited publish completes", async () => { + const vmId = "00000000-0000-4000-8000-000000000001"; + let releasePublish!: () => void; + const publishGate = new Promise((resolve) => { releasePublish = resolve; }); + let markPublishStarted!: () => void; + const publishStarted = new Promise((resolve) => { markPublishStarted = resolve; }); + const published: string[] = []; + const deps: VmSyncPublisherDeps = { + readRow: async () => row({ id: vmId }), + publish: async (teamId) => { markPublishStarted(); await publishGate; published.push(teamId); }, + // The cron-path scheduler: no request scope, so it awaits the work. + schedule: (work) => scheduleVmSyncWork(work, noRequestScope), + }; + const repo = withVmSyncPublication({ + markProviderObservedStatus: () => Effect.succeed(true), + } as Partial as VmRepositoryShape, deps); + let writeSettled = false; + const write = Effect.runPromise(repo.markProviderObservedStatus({ id: vmId, providerVmId: "vm-1", status: "paused" })) + .then((value) => { writeSettled = true; return value; }); + await publishStarted; + expect(writeSettled).toBe(false); + expect(published).toEqual([]); + releasePublish(); + expect(await write).toBe(true); + expect(published).toEqual(["team-1"]); + }); +}); + +describe("publish helpers", () => { + test("publishVmRowsById groups current rows by team and dedups ids", async () => { + const a = row({ id: "a", providerVmId: "vm-a", ownerTeamId: "team-1" }); + const b = row({ id: "b", providerVmId: "vm-b", ownerTeamId: "team-2", status: "destroyed" }); + const { deps, published, settle } = recordingDeps({ a, b, gone: null }); + publishVmRowsById(["a", "b", "a", "gone"], deps); + await settle(); + expect(published.map((p) => [p.teamId, p.ops.map((op) => op.kind)])).toEqual([ + ["team-1", ["upsert"]], + ["team-2", ["delete"]], + ]); + }); + + test("publishVmOps forwards prebuilt ops and skips empty batches", async () => { + const { deps, published, settle } = recordingDeps({}); + publishVmOps("team-1", [], deps); + publishVmOps("team-1", [{ kind: "delete", id: "vm-1", sourceUpdatedAtMs: 1 }], deps); + await settle(); + expect(published).toEqual([{ teamId: "team-1", ops: [{ kind: "delete", id: "vm-1", sourceUpdatedAtMs: 1 }] }]); + }); +}); diff --git a/workers/presence/README.md b/workers/presence/README.md index 738ec23bb91e..eaeee58128a6 100644 --- a/workers/presence/README.md +++ b/workers/presence/README.md @@ -21,6 +21,7 @@ solo-account user id). | `/v1/presence/subscribe` | GET | WebSocket upgrade or SSE stream: `snapshot` first, then `online` / `offline` / `seen` events. Team-scoped by default; `?scope=account` streams only the verified user's own devices, whatever team each selected (macOS My Devices) | | `/v1/connectivity/subscribe` | GET | quiet WebSocket isolated by the verified Stack user; carries only route-revision invalidations | | `/v1/connectivity/invalidate` | POST | backend-only publication of `{revision}` to every connected Mac and iPhone for the verified Stack user | +| `/v1/sync/vms` | POST | backend-only Cloud machine list publication `{teamId, ops}`; no user bearer, authenticated by `X-Cmux-Vms-Publisher-Secret` only; the team DO applies the ops and broadcasts `vms` sync deltas | | `/v1/control/socket` | GET | account control-plane WebSocket (`AccountControlPlane` DO, one per verified Stack user): revisioned `directory` / `hint_update` / `relay_passes` / `snapshot_complete` facts per the frozen `schemas/control-plane/` contract | The heartbeat response returns `heartbeatIntervalMs` (15s) and @@ -58,6 +59,21 @@ Publication also requires the server-only `CONNECTIVITY_INVALIDATION_SECRET`; a native client access token cannot forge a revision. +The `vms` sync collection is the realtime Cloud machine list. Postgres stays +the source of truth: the web backend publishes every list-relevant row write +(status, name, slug, address, creator) and, after each `GET /api/vm` read, a +full `replace` of the visible list, so the per-team DO converges without a +backfill job. Every op carries the row's `updated_at` as `sourceUpdatedAtMs`; +the DO rejects an op older than what it stores and tombstones list-missing ids +at the list's observation time, so out-of-order publishers converge on the +newest row. Unchanged republishes mint no rev. Macs subscribe with the +existing `sync.hello` (collection `vms`) on `/v1/presence/subscribe`, scoped +to the same team the list route uses (`ownerTeamId`: the billing team, or the +user id for a personal account). The publisher is service-to-service only +(cron writers hold no user token): the Worker secret `VMS_PUBLISHER_SECRET` +must equal the web server's `CMUX_PRESENCE_VMS_PUBLISHER_SECRET`, and the body's +`teamId` is trusted only behind that check. + The control plane (`/v1/control/socket`) is the successor channel: instead of a bare revision nudge, one `AccountControlPlane` Durable Object per verified Stack user streams the facts themselves. On `hello` the DO replies `hello_ack` @@ -112,13 +128,20 @@ Required GitHub repository secrets: The Worker secret `CONNECTIVITY_INVALIDATION_SECRET` and web server secret `CMUX_CONNECTIVITY_INVALIDATION_SECRET` must contain the same random value of -at least 32 characters. +at least 32 characters. Likewise the Worker secret `VMS_PUBLISHER_SECRET` and +the web server secret `CMUX_PRESENCE_VMS_PUBLISHER_SECRET` (an independent +value; `openssl rand -hex 32`). Without it `POST /v1/sync/vms` fails closed +and the web backend skips publication. -One-time Worker secrets (survive deploys; production Stack project values): +One-time Worker secrets (survive deploys; production Stack project values). +Run each once on production (`wrangler.toml`) and once on the dev instance +(`--config wrangler.dev.toml`): ```bash bunx wrangler secret put STACK_PROJECT_ID bunx wrangler secret put STACK_PUBLISHABLE_CLIENT_KEY +bunx wrangler secret put CONNECTIVITY_INVALIDATION_SECRET +bunx wrangler secret put VMS_PUBLISHER_SECRET ``` Set `SENTRY_DSN` once for production and for each isolated dev Worker. The @@ -175,10 +198,11 @@ developer — multiple people dogfood worker changes simultaneously without clobbering each other or the shared baseline. Because Cloudflare secrets are scoped to each Worker, the script also provisions the new Worker with the dev Stack Auth values from your shell environment or `.dev.vars` -(`STACK_PROJECT_ID`, `STACK_PUBLISHABLE_CLIENT_KEY`, and -`CONNECTIVITY_INVALIDATION_SECRET`, plus optional `STACK_API_URL`); it refuses -to deploy if those values are missing. Configure the web backend's -`CMUX_CONNECTIVITY_INVALIDATION_SECRET` to the same value. The script prints the +(`STACK_PROJECT_ID`, `STACK_PUBLISHABLE_CLIENT_KEY`, +`CONNECTIVITY_INVALIDATION_SECRET` and `VMS_PUBLISHER_SECRET`, plus optional +`STACK_API_URL`); it refuses to deploy if those values are missing. Configure +the web backend's `CMUX_CONNECTIVITY_INVALIDATION_SECRET` and +`CMUX_PRESENCE_VMS_PUBLISHER_SECRET` to the same values. The script prints the worker URL and the env var to export: ``` diff --git a/workers/presence/scripts/deploy-dev.sh b/workers/presence/scripts/deploy-dev.sh index 814a503ce989..5d65af07c420 100755 --- a/workers/presence/scripts/deploy-dev.sh +++ b/workers/presence/scripts/deploy-dev.sh @@ -17,8 +17,8 @@ set -euo pipefail # ./scripts/deploy-dev.sh # explicit slug (e.g. a feature name) # # Required Stack config is read from the shell environment first, then from -# .dev.vars: STACK_PROJECT_ID, STACK_PUBLISHABLE_CLIENT_KEY, and -# CONNECTIVITY_INVALIDATION_SECRET. STACK_API_URL is optional and defaults in +# .dev.vars: STACK_PROJECT_ID, STACK_PUBLISHABLE_CLIENT_KEY, +# CONNECTIVITY_INVALIDATION_SECRET and VMS_PUBLISHER_SECRET. STACK_API_URL is optional and defaults in # code to https://api.stack-auth.com. # # Do NOT deploy the shared `cmux-presence-dev` from a feature branch: that single @@ -77,9 +77,11 @@ stack_project_id="$(read_dev_value STACK_PROJECT_ID)" stack_client_key="$(read_dev_value STACK_PUBLISHABLE_CLIENT_KEY)" stack_api_url="$(read_dev_value STACK_API_URL)" connectivity_invalidation_secret="$(read_dev_value CONNECTIVITY_INVALIDATION_SECRET)" +vms_publisher_secret="$(read_dev_value VMS_PUBLISHER_SECRET)" if [ -z "$stack_project_id" ] || [ -z "$stack_client_key" ] \ - || [ "${#connectivity_invalidation_secret}" -lt 32 ]; then + || [ "${#connectivity_invalidation_secret}" -lt 32 ] \ + || [ "${#vms_publisher_secret}" -lt 32 ]; then cat >&2 <<'EOF' error: missing Stack Auth config for the isolated worker. @@ -87,9 +89,10 @@ Set these in your shell or workers/presence/.dev.vars before deploying: STACK_PROJECT_ID=... STACK_PUBLISHABLE_CLIENT_KEY=... CONNECTIVITY_INVALIDATION_SECRET=... # at least 32 random characters + VMS_PUBLISHER_SECRET=... # at least 32 random characters Without these Worker secrets, authenticated /v1 presence and paired-Mac backup -routes or backend-only connectivity publication fail closed. +routes or backend-only connectivity and machine-list publication fail closed. EOF exit 1 fi @@ -108,6 +111,7 @@ echo "→ Provisioning Stack Auth secrets on ${name}" put_worker_secret STACK_PROJECT_ID "$stack_project_id" put_worker_secret STACK_PUBLISHABLE_CLIENT_KEY "$stack_client_key" put_worker_secret CONNECTIVITY_INVALIDATION_SECRET "$connectivity_invalidation_secret" +put_worker_secret VMS_PUBLISHER_SECRET "$vms_publisher_secret" if [ -n "$stack_api_url" ]; then put_worker_secret STACK_API_URL "$stack_api_url" fi @@ -126,6 +130,7 @@ subscribes/backs up must use the SAME worker), then reload: Configure the web backend with the same publisher capability: export CMUX_CONNECTIVITY_INVALIDATION_SECRET= + export CMUX_PRESENCE_VMS_PUBLISHER_SECRET= The reload scripts inject it into the tagged build, so a normally-tapped dev app uses your worker, not the shared one. Unset it to go back to the shared diff --git a/workers/presence/src/do.ts b/workers/presence/src/do.ts index 82e27becda6a..b69eccc753ec 100644 --- a/workers/presence/src/do.ts +++ b/workers/presence/src/do.ts @@ -71,6 +71,13 @@ import { type PairedMacBackupOp, type PairedMacBackupRecord, } from "./syncPairedMacs"; +import { + annotateVmSnapshotPages, + applyVmOps, + VMS_COLLECTION, + type VmPublishOp, + type VmRecord, +} from "./syncVms"; import { sanitizePublishedRoutes } from "./routePrivacy"; import { ackPhoneReplies, @@ -416,6 +423,29 @@ export class TeamPresence extends DurableObject { return { ok: true, changed: deltas.length, teamId }; } + /** Publish Cloud machine list changes for this team. Called only by the + * worker after it checks the service publisher secret, so `teamId` is the + * trusted list scope the web backend chose (never a user bearer: cron + * writers have none). Applies the ops in one storage transaction, then + * broadcasts the resulting deltas to every socket subscribed to `vms` + * (team-wide, like `devices`). Returns the number of records changed. */ + async publishVms( + teamId: string, + ops: readonly VmPublishOp[], + ): Promise<{ ok: true; changed: number; teamId: string }> { + await this.rememberTeamId(teamId); + const deltas = await this.ctx.storage.transaction(async (transaction) => { + const storage: SyncStorage = transaction; + return await applyVmOps(storage, ops, Date.now()); + }); + for (const delta of deltas) this.broadcastSync(delta); + // A delete leaves a tombstone the alarm GCs; an idle team may never + // schedule an alarm otherwise, so pin the next GC deadline now. + const gcTime = await nextTombstoneGcTime(this.syncStorage(), VMS_COLLECTION); + if (gcTime !== null) await this.ensureAlarmAt(gcTime); + return { ok: true, changed: deltas.length, teamId }; + } + /** Read a user's backed-up saved-host list (the GET restore path). Called only * by the worker after it verifies the token, so `userId` is trusted. Returns * live records plus retained delete tombstones for the per-user collection, @@ -740,9 +770,10 @@ export class TeamPresence extends DurableObject { const already = new Set(wsSyncCollections(ws)); const subscribed: string[] = []; for (const { name, cursor, epoch } of collections) { - // Phase serves `devices` (team-wide, server-derived) and `pairedMacs` - // (per-user, client-owned). Any other name is ignored. - if (name !== DEVICES_COLLECTION && name !== PAIRED_MACS_COLLECTION) continue; + // Serves `devices` (team-wide, server-derived), `pairedMacs` (per-user, + // client-owned) and `vms` (team-wide, backend-published). Any other name + // is ignored. + if (name !== DEVICES_COLLECTION && name !== PAIRED_MACS_COLLECTION && name !== VMS_COLLECTION) continue; if (already.has(name)) continue; // duplicate hello; reconnect to resync // Mark as seen IMMEDIATELY so a hello that repeats the same collection name // N times within one message does the backfill + snapshot/delta serialization @@ -783,6 +814,30 @@ export class TeamPresence extends DurableObject { continue; } + if (name === VMS_COLLECTION) { + // `vms`: team-wide like `devices`, but there is no local source to + // backfill from (Postgres owns the list); the web backend publishes a + // full `replace` after every list read, which marks the backfill. A + // snapshot before that carries only rows written since this shipped; + // its `backfilled: false` tells the client to apply it upsert-only. + subscribed.push(name); + const resolved = await resolveHelloFrames( + this.syncStorage(), + name, + cursor, + undefined, + epoch ?? 0, + ); + if (resolved.mode === "snapshot") { + for (const page of await annotateVmSnapshotPages(this.syncStorage(), resolved.pages)) { + this.sendSync(ws, page); + } + } else if (resolved.delta !== null) { + this.sendSync(ws, resolved.delta); + } + continue; + } + // `pairedMacs`: scope to the connection's verified user. Without a pinned // user id (old worker that didn't forward it) we cannot safely scope, so // we do not serve it. The physical collection is `pairedMacs:`; @@ -937,6 +992,10 @@ export class TeamPresence extends DurableObject { // instances left to schedule a heartbeat-driven alarm) still wakes to GC // its tombstones and advance the GC floor (DESIGN.md §3.5). tombGc = await nextTombstoneGcTime(this.syncStorage(), DEVICES_COLLECTION); + // The backend-published machine list tombstones destroyed machines. + await gcTombstones(this.syncStorage(), VMS_COLLECTION, now); + const vmsGc = await nextTombstoneGcTime(this.syncStorage(), VMS_COLLECTION); + if (vmsGc !== null) tombGc = tombGc === null ? vmsGc : Math.min(tombGc, vmsGc); // Each Stack user's paired-Mac backup is its OWN physical collection, // including build-scoped variants. GC every collection that currently holds // tombstones; otherwise authenticated create/delete churn grows diff --git a/workers/presence/src/index.ts b/workers/presence/src/index.ts index c9800b2683ca..c677fe1f5952 100644 --- a/workers/presence/src/index.ts +++ b/workers/presence/src/index.ts @@ -17,6 +17,9 @@ // POST /v1/replies park one phone inline-notification reply // GET /v1/replies?macDeviceId=… pending replies for one Mac // POST /v1/replies/ack remove processed replies +// POST /v1/sync/vms backend-only Cloud machine list +// publication ({teamId, ops}); the +// DO broadcasts `vms` sync deltas // // Auth on every /v1 route: `Authorization: Bearer ` plus // optional `X-Cmux-Team-Id` / `?teamId=` team scoping, verified in auth.ts the @@ -39,11 +42,13 @@ import { AccountControlPlane, type ControlPlaneEnv } from "./controlPlaneDo"; import { parseRevocationRequest } from "./controlPlane"; import { isConnectivityPublisherAuthorized, + isVmsPublisherAuthorized, parseConnectivityInvalidation, parseHeartbeat, readBoundedJson, } from "./validate"; import { MAX_PAIRED_MAC_BACKUP_BYTES, normalizeClientScope, parsePairedMacBackup } from "./syncPairedMacs"; +import { MAX_VM_PUBLISH_BYTES, parseVmPublish } from "./syncVms"; import { MAX_PHONE_REPLY_BODY_BYTES, parsePhoneReply, @@ -67,6 +72,9 @@ export interface Env extends AuthEnv, ControlPlaneEnv { ACCOUNT_CONTROL_PLANE: DurableObjectNamespace; WORKSPACE_PRESENCE: DurableObjectNamespace; CONNECTIVITY_INVALIDATION_SECRET?: string; + /** Service secret for `POST /v1/sync/vms`; must equal the web backend's + * `CMUX_PRESENCE_VMS_PUBLISHER_SECRET`. */ + VMS_PUBLISHER_SECRET?: string; } function json(body: unknown, status = 200): Response { @@ -372,6 +380,22 @@ const worker = { return json(result); } + if (url.pathname === "/v1/sync/vms") { + // Backend-only Cloud machine list publication. Service-to-service auth + // only: the web backend (route handlers and cron reconcilers, which + // have no user bearer) proves itself with the shared publisher secret + // and names the list scope (`ownerTeamId`) in the body. The DO is + // derived from that trusted team id exactly like a verified team. + if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405); + if (!await isVmsPublisherAuthorized(request, env.VMS_PUBLISHER_SECRET)) return unauthorized(); + const body = await readBoundedJson(request, MAX_VM_PUBLISH_BYTES); + if (!body.ok) return json({ error: "invalid_request" }, body.status); + const parsed = parseVmPublish(body.value); + if (!parsed.ok) return json({ error: parsed.error }, 400); + const stub = env.TEAM_PRESENCE.get(env.TEAM_PRESENCE.idFromName(parsed.teamId)); + return json(await stub.publishVms(parsed.teamId, parsed.ops)); + } + if (url.pathname === "/v1/presence/snapshot") { if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405); const team = await resolveTeamOr403(request, env); diff --git a/workers/presence/src/sync.ts b/workers/presence/src/sync.ts index 3e831790c5d2..a7b4d5e4067a 100644 --- a/workers/presence/src/sync.ts +++ b/workers/presence/src/sync.ts @@ -85,6 +85,11 @@ export interface SyncSnapshotFrame

{ records: SyncRecord

[]; /** false ⇒ more pages follow; the client commits only on the complete page. */ complete: boolean; + /** `vms` only: whether a full list has ever landed in this DO. false means + * the snapshot holds only rows written since the collection shipped, so + * the client applies it upsert-only (no reconciliation drops). Absent on + * every other collection. */ + backfilled?: boolean; } /** Server → client: incremental change(s). `rev` is the head this frame brings diff --git a/workers/presence/src/syncStorage.ts b/workers/presence/src/syncStorage.ts index 9c28a1a1119e..709888b5bad0 100644 --- a/workers/presence/src/syncStorage.ts +++ b/workers/presence/src/syncStorage.ts @@ -241,13 +241,19 @@ async function firstWriteEpochEntry( * tombstone record, adds the rev-ordered `synctomb:` index entry for GC, and * returns the delta. A no-op if the record is already a tombstone (idempotent on * a double prune). */ -export async function tombstoneRecord( +export async function tombstoneRecord>( storage: SyncStorage, collection: string, id: string, nowMs: number, - options?: { createIfMissing?: boolean }, -): Promise>> { + options?: { + createIfMissing?: boolean; + /** Collection-typed tombstone body (default `{}`). A collection whose + * writes carry a source freshness clock keeps it on the tombstone so a + * late older upsert for the deleted id can still be rejected. */ + payload?: T; + }, +): Promise> { const head = await readHead(storage, collection); const stored = await readRecord(storage, collection, id); if ((stored === undefined && options?.createIfMissing !== true) || stored?.deleted) { @@ -255,7 +261,10 @@ export async function tombstoneRecord( return { delta: null, head }; } const rev = head + 1; - const tomb = makeTombstone(id, rev, nowMs); + const tomb: SyncRecord = { + ...makeTombstone(id, rev, nowMs), + payload: options?.payload ?? ({} as T), + }; // Atomic: tombstone record + head + rev-ordered GC index commit together, so // the GC index can never reference a head that does not include the tombstone. await storage.put({ diff --git a/workers/presence/src/syncVms.ts b/workers/presence/src/syncVms.ts new file mode 100644 index 000000000000..fc8c27a0167d --- /dev/null +++ b/workers/presence/src/syncVms.ts @@ -0,0 +1,360 @@ +// Team-wide Cloud machine list (`vms`) sync collection for the TeamPresence DO. +// +// The Mac machines list used to poll `GET /api/vm`. This collection lets the +// web backend push every list-relevant Postgres write (status, name, address, +// slug, creator) into the per-team DO, which broadcasts sync deltas over the +// existing presence WebSocket (`sync.hello` with collection `vms`). +// +// Unlike `devices` (derived from presence) and `pairedMacs` (client-owned, +// per user), `vms` is SERVER-owned: the only writer is the web backend, which +// authenticates with a service secret (no user bearer, because cron writers +// have none). The DO is `idFromName(ownerTeamId)`, the same team scope the +// list route uses, so one team never sees another team's machines. +// +// Postgres remains the source of truth. Every op carries the row's +// `updatedAt` as `sourceUpdatedAtMs`; the DO rejects an op older than what it +// stores, so two concurrent web instances publishing out of order converge on +// the newest row. A `replace` op (published after every list read) carries the +// full visible list plus the time the list was observed; ids the DO holds live +// but the list lacks are tombstoned at that observation time, which a newer +// upsert for the same id still wins against. Payload compares ignore +// `sourceUpdatedAtMs` so a republish of an unchanged row mints no rev. +// +// Pure + storage-bound so it unit-tests against the Map-backed fake, same +// posture as sync.ts / syncStorage.ts / syncPairedMacs.ts. + +import type { SyncDeltaFrame, SyncSnapshotFrame } from "./sync"; +import { + listRecords, + markBackfillDone, + readBackfillDone, + readRecord, + tombstoneRecord, + upsertRecord, + type SyncStorage, +} from "./syncStorage"; + +export const VMS_COLLECTION = "vms"; + +/** Max ops in one publish. A full `replace` carries at most the team's + * visible list, and the web list is bounded well below this. */ +export const MAX_VM_PUBLISH_OPS = 200; +/** A `replace` leaves a live record alone when its write clock is within this + * window before the list's observation time: the list read may have run before + * that write's transaction committed. Explicit deletes are not delayed. */ +export const VM_REPLACE_ABSENCE_GRACE_MS = 60_000; +/** Max records inside one `replace` op (the whole visible list). */ +export const MAX_VM_REPLACE_RECORDS = MAX_VM_PUBLISH_OPS; +/** Max request body for a vms publish. ~1 KiB per record covers every bounded + * string below plus JSON overhead; the bounded reader aborts past this. */ +export const MAX_VM_PUBLISH_BYTES = MAX_VM_REPLACE_RECORDS * 1024 + 4096; + +const MAX_ID_LENGTH = 256; +const MAX_TEAM_ID_LENGTH = 256; +const MAX_SHORT_STRING_LENGTH = 128; +const MAX_ADDRESS_LENGTH = 64; +const MAX_CAPABILITY_KEYS = 32; +const MAX_CAPABILITY_TRANSPORTS = 8; + +/** One machine as the Mac list renders it: the `GET /api/vm` entry shape + * (`web/app/api/vm/route.ts`) minus per-request fields (`createdBy` names and + * plan windows, which the client derives or fetches). */ +export interface VmRecord { + /** The provider VM id; the record id and the machine's address. */ + id: string; + provider: string; + status: string; + image: string; + imageVersion: string | null; + kind: string; + /** Provider verbs (`web/services/vms/drivers/types.ts` VmCapabilities). Opaque + * here beyond bounding; booleans and short string arrays only. */ + capabilities: Record; + /** epoch ms */ + createdAt: number; + displayName: string | null; + slug: string | null; + createdByUserId: string; + address: { ipv4: string | null; ipv6: string | null }; + /** The Postgres row's `updated_at` (epoch ms): the freshness clock. */ + sourceUpdatedAtMs: number; +} + +/** Tombstone payload: keeps the freshness clock so a late older upsert for a + * destroyed machine is rejected instead of reviving it. */ +export interface VmTombstonePayload { + sourceUpdatedAtMs: number; +} + +export type VmPublishOp = + | { kind: "upsert"; record: VmRecord } + | { kind: "delete"; id: string; sourceUpdatedAtMs: number } + | { kind: "replace"; records: VmRecord[]; observedAtMs: number }; + +export type VmPublishParse = + | { ok: true; teamId: string; ops: VmPublishOp[] } + | { ok: false; error: string }; + +function trimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function boundedNullableString(value: unknown, max: number): string | null | undefined { + if (value === null || value === undefined) return null; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (trimmed.length > max) return undefined; + return trimmed || null; +} + +function parseCapabilities(value: unknown): Record | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) return null; + const out: Record = {}; + const entries = Object.entries(value as Record); + if (entries.length > MAX_CAPABILITY_KEYS) return null; + for (const [key, raw] of entries) { + if (key.length > MAX_SHORT_STRING_LENGTH) return null; + if (typeof raw === "boolean") { + out[key] = raw; + continue; + } + if (!Array.isArray(raw) || raw.length > MAX_CAPABILITY_TRANSPORTS) return null; + const list: string[] = []; + for (const item of raw) { + if (typeof item !== "string" || item.length > MAX_SHORT_STRING_LENGTH) return null; + list.push(item); + } + out[key] = list; + } + return out; +} + +function parseAddress(value: unknown): VmRecord["address"] | null { + if (value === null || value === undefined) return { ipv4: null, ipv6: null }; + if (typeof value !== "object" || Array.isArray(value)) return null; + const a = value as Record; + const ipv4 = boundedNullableString(a.ipv4, MAX_ADDRESS_LENGTH); + const ipv6 = boundedNullableString(a.ipv6, MAX_ADDRESS_LENGTH); + if (ipv4 === undefined || ipv6 === undefined) return null; + return { ipv4, ipv6 }; +} + +/** Parse and bound one published record. Returns an error code, or the record. */ +export function parseVmRecord(raw: unknown): { ok: true; record: VmRecord } | { ok: false; error: string } { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return { ok: false, error: "invalid_record" }; + } + const r = raw as Record; + const id = trimmedString(r.id); + if (!id || id.length > MAX_ID_LENGTH) return { ok: false, error: "invalid_id" }; + const provider = trimmedString(r.provider); + const status = trimmedString(r.status); + const image = trimmedString(r.image); + const kind = trimmedString(r.kind); + const createdByUserId = trimmedString(r.createdByUserId); + for (const value of [provider, status, image, kind, createdByUserId]) { + if (!value || value.length > MAX_SHORT_STRING_LENGTH) return { ok: false, error: "invalid_record" }; + } + const imageVersion = boundedNullableString(r.imageVersion, MAX_SHORT_STRING_LENGTH); + const displayName = boundedNullableString(r.displayName, MAX_SHORT_STRING_LENGTH); + const slug = boundedNullableString(r.slug, MAX_SHORT_STRING_LENGTH); + if (imageVersion === undefined || displayName === undefined || slug === undefined) { + return { ok: false, error: "invalid_record" }; + } + const capabilities = parseCapabilities(r.capabilities); + if (capabilities === null) return { ok: false, error: "invalid_capabilities" }; + const address = parseAddress(r.address); + if (address === null) return { ok: false, error: "invalid_address" }; + const createdAt = finiteNumber(r.createdAt); + const sourceUpdatedAtMs = finiteNumber(r.sourceUpdatedAtMs); + if (createdAt === null || sourceUpdatedAtMs === null) return { ok: false, error: "invalid_timestamps" }; + return { + ok: true, + record: { + id, provider, status, image, imageVersion, kind, capabilities, createdAt, + displayName, slug, createdByUserId, address, sourceUpdatedAtMs, + }, + }; +} + +function parseOp(entry: unknown): { ok: true; op: VmPublishOp } | { ok: false; error: string } { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + return { ok: false, error: "invalid_op" }; + } + const e = entry as Record; + if (e.kind === "upsert") { + const parsed = parseVmRecord(e.record); + return parsed.ok ? { ok: true, op: { kind: "upsert", record: parsed.record } } : parsed; + } + if (e.kind === "delete") { + const id = trimmedString(e.id); + if (!id || id.length > MAX_ID_LENGTH) return { ok: false, error: "invalid_id" }; + const sourceUpdatedAtMs = finiteNumber(e.sourceUpdatedAtMs); + if (sourceUpdatedAtMs === null) return { ok: false, error: "invalid_timestamps" }; + return { ok: true, op: { kind: "delete", id, sourceUpdatedAtMs } }; + } + if (e.kind === "replace") { + if (!Array.isArray(e.records)) return { ok: false, error: "invalid_records" }; + if (e.records.length > MAX_VM_REPLACE_RECORDS) return { ok: false, error: "too_many_records" }; + const observedAtMs = finiteNumber(e.observedAtMs); + if (observedAtMs === null) return { ok: false, error: "invalid_timestamps" }; + const records: VmRecord[] = []; + for (const raw of e.records) { + const parsed = parseVmRecord(raw); + if (!parsed.ok) return parsed; + records.push(parsed.record); + } + return { ok: true, op: { kind: "replace", records, observedAtMs } }; + } + return { ok: false, error: "invalid_op_kind" }; +} + +/** Parse and bound a publish body that has already been JSON-decoded: + * `{ teamId, ops: [...] }`. `teamId` is trusted (service-to-service auth + * guards the route) but still bounded. Pure for tests. */ +export function parseVmPublish(body: Record): VmPublishParse { + const teamId = trimmedString(body.teamId); + if (!teamId || teamId.length > MAX_TEAM_ID_LENGTH) return { ok: false, error: "invalid_team_id" }; + if (!Array.isArray(body.ops)) return { ok: false, error: "invalid_ops" }; + if (body.ops.length > MAX_VM_PUBLISH_OPS) return { ok: false, error: "too_many_ops" }; + const ops: VmPublishOp[] = []; + for (const entry of body.ops) { + const parsed = parseOp(entry); + if (!parsed.ok) return parsed; + ops.push(parsed.op); + } + return { ok: true, teamId, ops }; +} + +/** Stamp `vms` snapshot pages with whether a full `replace` has ever landed. + * Before that the DO holds only rows written since the collection shipped, + * and the client must not drop machines missing from the snapshot. */ +export async function annotateVmSnapshotPages

( + storage: SyncStorage, + pages: readonly SyncSnapshotFrame

[], +): Promise[]> { + const backfilled = await readBackfillDone(storage, VMS_COLLECTION); + return pages.map((page) => ({ ...page, backfilled })); +} + +/** List-shape equality: everything the list renders, ignoring the freshness + * clock, so a republish of an unchanged row (every list read publishes a full + * `replace`) mints no rev and broadcasts nothing. */ +export function vmShapeEqual(a: VmRecord, b: VmRecord): boolean { + const { sourceUpdatedAtMs: _a, ...left } = a; + const { sourceUpdatedAtMs: _b, ...right } = b; + return JSON.stringify(left) === JSON.stringify(right); +} + +type StoredVm = { deleted: boolean; payload: VmRecord | VmTombstonePayload }; + +function storedFreshness(stored: StoredVm | undefined): number | null { + if (stored === undefined) return null; + const value = (stored.payload as Partial).sourceUpdatedAtMs; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** Whether an op stamped `sourceUpdatedAtMs` is older than the stored record + * and must be dropped. A missing stored clock (never seen) accepts anything. */ +function isStale(stored: StoredVm | undefined, sourceUpdatedAtMs: number): boolean { + const current = storedFreshness(stored); + return current !== null && sourceUpdatedAtMs < current; +} + +async function applyUpsert( + storage: SyncStorage, + record: VmRecord, + nowMs: number, +): Promise | null> { + const stored = await readRecord(storage, VMS_COLLECTION, record.id); + if (isStale(stored, record.sourceUpdatedAtMs)) return null; + // A tombstone stamped at the same clock as this upsert stays a tombstone + // (`<=`): the delete is the later intent for one row version, and a + // destroyed row is never listed again. + if (stored?.deleted && storedFreshness(stored) === record.sourceUpdatedAtMs) return null; + const res = await upsertRecord( + storage, + VMS_COLLECTION, + record.id, + record, + nowMs, + vmShapeEqual, + (payload) => payload.sourceUpdatedAtMs, + ); + return res.delta; +} + +async function applyDelete( + storage: SyncStorage, + id: string, + sourceUpdatedAtMs: number, + nowMs: number, +): Promise | null> { + const stored = await readRecord(storage, VMS_COLLECTION, id); + if (isStale(stored, sourceUpdatedAtMs)) return null; + // `createIfMissing`: a machine the DO never saw live (its create upsert was + // lost) still needs a tombstone so its later, older upsert is rejected. + const res = await tombstoneRecord(storage, VMS_COLLECTION, id, nowMs, { + createIfMissing: true, + payload: { sourceUpdatedAtMs } satisfies VmTombstonePayload, + }); + return res.delta; +} + +async function applyReplace( + storage: SyncStorage, + records: readonly VmRecord[], + observedAtMs: number, + nowMs: number, +): Promise[]> { + const deltas: SyncDeltaFrame[] = []; + const listed = new Set(); + for (const record of records) { + listed.add(record.id); + const delta = await applyUpsert(storage, record, nowMs); + if (delta !== null) deltas.push(delta); + } + // Ids the DO holds live but the observed list lacks left the list (destroyed, + // or a lost delete). Tombstone them at the observation time. A record written + // shortly before that time survives: its transaction may have committed + // after the list read, and a later replace removes it if it really left. + const existing = await listRecords(storage, VMS_COLLECTION); + for (const stored of existing) { + if (stored.deleted || listed.has(stored.id)) continue; + const writtenAtMs = storedFreshness(stored); + if (writtenAtMs !== null && writtenAtMs > observedAtMs - VM_REPLACE_ABSENCE_GRACE_MS) continue; + const delta = await applyDelete(storage, stored.id, observedAtMs, nowMs); + if (delta !== null) deltas.push(delta); + } + // A full list has landed: snapshots from here on are complete, not just the + // rows written since this collection shipped. + await markBackfillDone(storage, VMS_COLLECTION); + return deltas; +} + +/** Apply a batch of publish ops for one team, returning the deltas the DO + * should broadcast. Unchanged payloads and stale ops are no-ops. Storage + * writes reuse the generic `upsertRecord` / `tombstoneRecord`. */ +export async function applyVmOps( + storage: SyncStorage, + ops: readonly VmPublishOp[], + nowMs: number, +): Promise[]> { + const deltas: SyncDeltaFrame[] = []; + for (const op of ops) { + if (op.kind === "upsert") { + const delta = await applyUpsert(storage, op.record, nowMs); + if (delta !== null) deltas.push(delta); + } else if (op.kind === "delete") { + const delta = await applyDelete(storage, op.id, op.sourceUpdatedAtMs, nowMs); + if (delta !== null) deltas.push(delta); + } else { + deltas.push(...await applyReplace(storage, op.records, op.observedAtMs, nowMs)); + } + } + return deltas; +} diff --git a/workers/presence/src/validate.ts b/workers/presence/src/validate.ts index aee977ad61fd..6083d04dd001 100644 --- a/workers/presence/src/validate.ts +++ b/workers/presence/src/validate.ts @@ -136,11 +136,29 @@ export interface ConnectivityInvalidationInput { export async function isConnectivityPublisherAuthorized( request: Request, configuredSecret: string | undefined, +): Promise { + return isPublisherAuthorized(request, "x-cmux-connectivity-publisher-secret", configuredSecret); +} + +/** Constant-work comparison for the server-only machine-list publisher. The + * route has no user bearer (cron writers have none), so this secret is the + * whole authorization: the body's `teamId` is trusted only behind it. */ +export async function isVmsPublisherAuthorized( + request: Request, + configuredSecret: string | undefined, +): Promise { + return isPublisherAuthorized(request, "x-cmux-vms-publisher-secret", configuredSecret); +} + +/** Shared HMAC-based constant-time compare of a request header against a + * configured service secret (32..512 chars, else fail closed). */ +async function isPublisherAuthorized( + request: Request, + headerName: string, + configuredSecret: string | undefined, ): Promise { const expected = configuredSecret?.trim() ?? ""; - const actual = request.headers.get( - "x-cmux-connectivity-publisher-secret", - )?.trim() ?? ""; + const actual = request.headers.get(headerName)?.trim() ?? ""; if (expected.length < 32 || expected.length > 512) return false; const encoder = new TextEncoder(); const expectedBytes = encoder.encode(expected); diff --git a/workers/presence/test/syncVms.test.ts b/workers/presence/test/syncVms.test.ts new file mode 100644 index 000000000000..9414c0587b62 --- /dev/null +++ b/workers/presence/test/syncVms.test.ts @@ -0,0 +1,344 @@ +// Tests for the backend-published Cloud machine list (`vms`) sync collection. +// Uses the same Map-backed `SyncStorage` fake as syncStorage.test.ts (no +// Workers runtime). Covers: parse bounds, out-of-order rejection by the source +// clock, `replace` tombstoning ids missing from the observed list (but not a +// machine created after the observation), idempotent no-op upserts, delete, +// tombstone GC, hello snapshot then delta, and the publisher auth check. + +import { describe, expect, it } from "bun:test"; +import { + annotateVmSnapshotPages, + applyVmOps, + MAX_VM_PUBLISH_OPS, + MAX_VM_REPLACE_RECORDS, + parseVmPublish, + parseVmRecord, + VM_REPLACE_ABSENCE_GRACE_MS, + VMS_COLLECTION, + vmShapeEqual, + type VmRecord, +} from "../src/syncVms"; +import { + gcTombstones, + listRecords, + readBackfillDone, + readGcFloor, + readHead, + readRecord, + resolveHelloFrames, + type SyncStorage, +} from "../src/syncStorage"; +import { TOMBSTONE_RETENTION_MS } from "../src/sync"; +import { isVmsPublisherAuthorized } from "../src/validate"; + +const T0 = 1_750_000_000_000; + +class FakeStorage implements SyncStorage { + private map = new Map(); + async get(key: string): Promise { + return this.map.get(key) as T | undefined; + } + async put(keyOrEntries: string | Record, value?: T): Promise { + if (typeof keyOrEntries === "string") { + this.map.set(keyOrEntries, JSON.parse(JSON.stringify(value))); + return; + } + for (const [k, v] of Object.entries(keyOrEntries)) { + this.map.set(k, JSON.parse(JSON.stringify(v))); + } + } + async delete(key: string): Promise { + return this.map.delete(key); + } + async list(options: { prefix: string; limit?: number }): Promise> { + const out = new Map(); + const keys = [...this.map.keys()].filter((k) => k.startsWith(options.prefix)).sort(); + for (const k of keys) { + if (options.limit !== undefined && out.size >= options.limit) break; + out.set(k, this.map.get(k) as T); + } + return out; + } +} + +function vm(id: string, overrides: Partial = {}): VmRecord { + return { + id, + provider: "freestyle", + status: "running", + image: "devbox", + imageVersion: "3", + kind: "desktop", + capabilities: { checkpoint: true, fork: false, attachTransports: ["ssh", "cmux-remote"] }, + createdAt: T0 - 60_000, + displayName: null, + slug: "sleepy-teal-otter", + createdByUserId: "user-a", + address: { ipv4: "10.0.0.5", ipv6: null }, + sourceUpdatedAtMs: T0, + ...overrides, + }; +} + +async function liveIds(storage: SyncStorage): Promise { + return (await listRecords(storage, VMS_COLLECTION)) + .filter((r) => !r.deleted) + .map((r) => r.id) + .sort(); +} + +describe("parseVmPublish", () => { + it("accepts upsert, delete and replace ops with a trusted team id", () => { + const parsed = parseVmPublish({ + teamId: " team-1 ", + ops: [ + { kind: "upsert", record: vm("vm-1") }, + { kind: "delete", id: "vm-2", sourceUpdatedAtMs: T0 }, + { kind: "replace", records: [vm("vm-1")], observedAtMs: T0 }, + ], + }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.teamId).toBe("team-1"); + expect(parsed.ops.map((op) => op.kind)).toEqual(["upsert", "delete", "replace"]); + }); + + it("rejects a missing team id, unbounded ops and malformed records", () => { + expect(parseVmPublish({ ops: [] })).toEqual({ ok: false, error: "invalid_team_id" }); + expect(parseVmPublish({ teamId: "t", ops: {} })).toEqual({ ok: false, error: "invalid_ops" }); + expect(parseVmPublish({ + teamId: "t", + ops: Array.from({ length: MAX_VM_PUBLISH_OPS + 1 }, () => ({ kind: "delete", id: "x", sourceUpdatedAtMs: 1 })), + })).toEqual({ ok: false, error: "too_many_ops" }); + expect(parseVmPublish({ + teamId: "t", + ops: [{ kind: "replace", observedAtMs: 1, records: Array.from({ length: MAX_VM_REPLACE_RECORDS + 1 }, (_, i) => vm(`vm-${i}`)) }], + })).toEqual({ ok: false, error: "too_many_records" }); + expect(parseVmPublish({ teamId: "t", ops: [{ kind: "upsert", record: { ...vm("vm-1"), id: "" } }] })) + .toEqual({ ok: false, error: "invalid_id" }); + expect(parseVmPublish({ teamId: "t", ops: [{ kind: "upsert", record: { ...vm("vm-1"), sourceUpdatedAtMs: "now" } }] })) + .toEqual({ ok: false, error: "invalid_timestamps" }); + expect(parseVmPublish({ teamId: "t", ops: [{ kind: "delete", id: "vm-1" }] })) + .toEqual({ ok: false, error: "invalid_timestamps" }); + expect(parseVmPublish({ teamId: "t", ops: [{ kind: "replace", records: [vm("vm-1")] }] })) + .toEqual({ ok: false, error: "invalid_timestamps" }); + expect(parseVmPublish({ teamId: "t", ops: [{ kind: "rename", id: "vm-1" }] })) + .toEqual({ ok: false, error: "invalid_op_kind" }); + expect(parseVmPublish({ teamId: "t", ops: [{ kind: "upsert", record: { ...vm("vm-1"), capabilities: { a: "yes" } } }] })) + .toEqual({ ok: false, error: "invalid_capabilities" }); + expect(parseVmPublish({ teamId: "t", ops: [{ kind: "upsert", record: { ...vm("vm-1"), address: "10.0.0.1" } }] })) + .toEqual({ ok: false, error: "invalid_address" }); + }); + + it("normalizes optional fields and drops unknown keys", () => { + const parsed = parseVmRecord({ ...vm("vm-1"), displayName: " ", address: undefined, extra: "ignored" }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.record.displayName).toBeNull(); + expect(parsed.record.address).toEqual({ ipv4: null, ipv6: null }); + expect("extra" in parsed.record).toBe(false); + }); +}); + +describe("applyVmOps", () => { + it("upserts a new machine and is a no-op for an unchanged republish", async () => { + const storage = new FakeStorage(); + const first = await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1") }], T0); + expect(first).toHaveLength(1); + expect(first[0]?.collection).toBe(VMS_COLLECTION); + expect(first[0]?.records[0]?.payload).toEqual(vm("vm-1")); + // Same shape, newer clock: no rev, no delta, but the stored clock advances. + const again = await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1", { sourceUpdatedAtMs: T0 + 5 }) }], T0 + 5); + expect(again).toEqual([]); + expect(await readHead(storage, VMS_COLLECTION)).toBe(1); + const stored = await readRecord(storage, VMS_COLLECTION, "vm-1"); + expect(stored?.payload.sourceUpdatedAtMs).toBe(T0 + 5); + }); + + it("rejects an upsert older than the stored row (out-of-order publishers)", async () => { + const storage = new FakeStorage(); + await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1", { status: "paused", sourceUpdatedAtMs: T0 + 10 }) }], T0); + const stale = await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1", { status: "running", sourceUpdatedAtMs: T0 }) }], T0 + 1); + expect(stale).toEqual([]); + const stored = await readRecord(storage, VMS_COLLECTION, "vm-1"); + expect(stored?.payload.status).toBe("paused"); + // A newer change still lands and mints a rev. + const newer = await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1", { status: "running", sourceUpdatedAtMs: T0 + 20 }) }], T0 + 2); + expect(newer).toHaveLength(1); + expect(newer[0]?.rev).toBe(2); + }); + + it("deletes with a tombstone that keeps rejecting older upserts", async () => { + const storage = new FakeStorage(); + await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1") }], T0); + const deleted = await applyVmOps(storage, [{ kind: "delete", id: "vm-1", sourceUpdatedAtMs: T0 + 10 }], T0 + 1); + expect(deleted).toHaveLength(1); + expect(deleted[0]?.records[0]?.deleted).toBe(true); + expect(deleted[0]?.records[0]?.payload).toEqual({ sourceUpdatedAtMs: T0 + 10 }); + // Late, older upsert (a slow web instance): rejected. + const late = await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1", { sourceUpdatedAtMs: T0 + 5 }) }], T0 + 2); + expect(late).toEqual([]); + expect(await liveIds(storage)).toEqual([]); + // Older delete for the same id: no-op too (already a tombstone). + const again = await applyVmOps(storage, [{ kind: "delete", id: "vm-1", sourceUpdatedAtMs: T0 }], T0 + 3); + expect(again).toEqual([]); + }); + + it("tombstones a delete for a machine the DO never saw", async () => { + const storage = new FakeStorage(); + const deltas = await applyVmOps(storage, [{ kind: "delete", id: "ghost", sourceUpdatedAtMs: T0 }], T0); + expect(deltas).toHaveLength(1); + expect((await readRecord(storage, VMS_COLLECTION, "ghost"))?.deleted).toBe(true); + }); + + it("replace tombstones ids missing from the observed list and marks the backfill", async () => { + const storage = new FakeStorage(); + await applyVmOps(storage, [ + { kind: "upsert", record: vm("vm-1") }, + { kind: "upsert", record: vm("vm-2") }, + { kind: "upsert", record: vm("vm-3") }, + ], T0); + expect(await readBackfillDone(storage, VMS_COLLECTION)).toBe(false); + const deltas = await applyVmOps(storage, [{ + kind: "replace", + records: [vm("vm-1"), vm("vm-2", { displayName: "Renamed", sourceUpdatedAtMs: T0 + 1 })], + observedAtMs: T0 + VM_REPLACE_ABSENCE_GRACE_MS + 1, + }], T0 + VM_REPLACE_ABSENCE_GRACE_MS + 2); + // vm-1 unchanged (no delta), vm-2 renamed (delta), vm-3 tombstoned (delta). + expect(deltas.map((d) => [d.records[0]?.id, d.records[0]?.deleted])).toEqual([ + ["vm-2", false], + ["vm-3", true], + ]); + expect(await liveIds(storage)).toEqual(["vm-1", "vm-2"]); + expect(await readBackfillDone(storage, VMS_COLLECTION)).toBe(true); + }); + + it("replace does not tombstone a machine created after the list was observed", async () => { + const storage = new FakeStorage(); + // The list was read at T0+2; a create landed at T0+5 and its upsert reached + // the DO before the list's after-response replace did. + await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-new", { sourceUpdatedAtMs: T0 + 5 }) }], T0 + 6); + const deltas = await applyVmOps(storage, [{ kind: "replace", records: [vm("vm-1")], observedAtMs: T0 + 2 }], T0 + 7); + expect(deltas.map((d) => d.records[0]?.id)).toEqual(["vm-1"]); + expect(await liveIds(storage)).toEqual(["vm-1", "vm-new"]); + }); + + it("replace does not tombstone a machine whose write committed after the list read", async () => { + const storage = new FakeStorage(); + // The create stamped updated_at at T0+1 inside its transaction; the list + // read at T0+2 ran before that commit, so it lacks the machine, and the + // create's upsert reached the DO first. + await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-new", { sourceUpdatedAtMs: T0 + 1 }) }], T0 + 3); + const deltas = await applyVmOps(storage, [{ kind: "replace", records: [vm("vm-1")], observedAtMs: T0 + 2 }], T0 + 4); + expect(deltas.map((d) => d.records[0]?.id)).toEqual(["vm-1"]); + expect(await liveIds(storage)).toEqual(["vm-1", "vm-new"]); + // A list observed well after that write still removes a machine it lacks. + const later = T0 + 1 + VM_REPLACE_ABSENCE_GRACE_MS + 1; + await applyVmOps(storage, [{ kind: "replace", records: [vm("vm-1")], observedAtMs: later }], later); + expect(await liveIds(storage)).toEqual(["vm-1"]); + }); + + it("replace with an unchanged list mints no rev", async () => { + const storage = new FakeStorage(); + await applyVmOps(storage, [{ kind: "replace", records: [vm("vm-1"), vm("vm-2")], observedAtMs: T0 }], T0); + const head = await readHead(storage, VMS_COLLECTION); + const deltas = await applyVmOps(storage, [{ kind: "replace", records: [vm("vm-2"), vm("vm-1")], observedAtMs: T0 + 60_000 }], T0 + 60_000); + expect(deltas).toEqual([]); + expect(await readHead(storage, VMS_COLLECTION)).toBe(head); + }); + + it("vmShapeEqual ignores only the source clock", () => { + expect(vmShapeEqual(vm("a"), vm("a", { sourceUpdatedAtMs: 1 }))).toBe(true); + expect(vmShapeEqual(vm("a"), vm("a", { status: "paused" }))).toBe(false); + expect(vmShapeEqual(vm("a"), vm("a", { address: { ipv4: "10.0.0.6", ipv6: null } }))).toBe(false); + expect(vmShapeEqual(vm("a"), vm("a", { capabilities: { checkpoint: false } }))).toBe(false); + }); +}); + +describe("tombstone GC", () => { + it("removes an expired vms tombstone and raises the GC floor", async () => { + const storage = new FakeStorage(); + await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1") }], T0); + await applyVmOps(storage, [{ kind: "delete", id: "vm-1", sourceUpdatedAtMs: T0 + 1 }], T0 + 1); + const early = await gcTombstones(storage, VMS_COLLECTION, T0 + 1 + TOMBSTONE_RETENTION_MS - 1); + expect(early.collected).toBe(0); + const late = await gcTombstones(storage, VMS_COLLECTION, T0 + 1 + TOMBSTONE_RETENTION_MS); + expect(late.collected).toBe(1); + expect(await readGcFloor(storage, VMS_COLLECTION)).toBe(2); + expect(await readRecord(storage, VMS_COLLECTION, "vm-1")).toBeUndefined(); + }); +}); + +describe("hello: snapshot then delta", () => { + it("serves a full snapshot to a first-time client, then a catch-up delta", async () => { + const storage = new FakeStorage(); + await applyVmOps(storage, [{ kind: "replace", records: [vm("vm-1"), vm("vm-2")], observedAtMs: T0 }], T0); + const first = await resolveHelloFrames(storage, VMS_COLLECTION, 0, undefined, 0, T0); + expect(first.mode).toBe("snapshot"); + if (first.mode !== "snapshot") return; + expect(first.snapshotRev).toBe(2); + expect(first.pages).toHaveLength(1); + expect(first.pages[0]?.complete).toBe(true); + expect(first.pages[0]?.records.map((r) => r.id)).toEqual(["vm-1", "vm-2"]); + + await applyVmOps(storage, [ + { kind: "upsert", record: vm("vm-2", { status: "paused", sourceUpdatedAtMs: T0 + 1 }) }, + { kind: "delete", id: "vm-1", sourceUpdatedAtMs: T0 + 1 }, + ], T0 + 1); + const second = await resolveHelloFrames(storage, VMS_COLLECTION, first.snapshotRev, undefined, first.epoch, T0 + 2); + expect(second.mode).toBe("delta"); + if (second.mode !== "delta") return; + expect(second.delta?.rev).toBe(4); + expect(second.delta?.records.map((r) => [r.id, r.deleted])).toEqual([["vm-2", false], ["vm-1", true]]); + expect(second.delta?.records[0]?.payload).toMatchObject({ status: "paused" }); + }); +}); + +describe("hello: backfilled flag", () => { + it("is false until the first replace lands, then true; deltas carry no flag", async () => { + const storage = new FakeStorage(); + await applyVmOps(storage, [{ kind: "upsert", record: vm("vm-1") }], T0); + const before = await resolveHelloFrames(storage, VMS_COLLECTION, 0, undefined, 0, T0); + expect(before.mode).toBe("snapshot"); + if (before.mode !== "snapshot") return; + expect(before.pages[0]?.backfilled).toBeUndefined(); + const unbackfilled = await annotateVmSnapshotPages(storage, before.pages); + expect(unbackfilled.map((p) => p.backfilled)).toEqual([false]); + expect(unbackfilled[0]?.records.map((r) => r.id)).toEqual(["vm-1"]); + + await applyVmOps(storage, [{ kind: "replace", records: [vm("vm-1"), vm("vm-2")], observedAtMs: T0 + 1 }], T0 + 1); + const after = await resolveHelloFrames(storage, VMS_COLLECTION, 0, undefined, 0, T0 + 2); + if (after.mode !== "snapshot") throw new Error("expected snapshot"); + const backfilled = await annotateVmSnapshotPages(storage, after.pages); + expect(backfilled.map((p) => p.backfilled)).toEqual([true]); + expect(backfilled[0]?.complete).toBe(true); + // A catch-up delta is unaffected: the flag lives on snapshot frames only. + const delta = await resolveHelloFrames(storage, VMS_COLLECTION, 1, undefined, backfilled[0]!.epoch, T0 + 3); + expect(delta.mode).toBe("delta"); + if (delta.mode !== "delta") return; + expect("backfilled" in (delta.delta ?? {})).toBe(false); + }); +}); + +describe("isVmsPublisherAuthorized", () => { + const secret = "v".repeat(64); + const request = (value?: string) => new Request("https://presence.example/v1/sync/vms", { + method: "POST", + headers: value ? { "x-cmux-vms-publisher-secret": value } : {}, + }); + + it("requires the exact service secret and never a user bearer", async () => { + expect(await isVmsPublisherAuthorized(request(secret), secret)).toBe(true); + expect(await isVmsPublisherAuthorized(request("w".repeat(64)), secret)).toBe(false); + expect(await isVmsPublisherAuthorized(request(), secret)).toBe(false); + expect(await isVmsPublisherAuthorized(request(secret), undefined)).toBe(false); + expect(await isVmsPublisherAuthorized(request("short"), "short")).toBe(false); + expect(await isVmsPublisherAuthorized(request(`${secret}extra`), secret)).toBe(false); + // The connectivity header is a different capability. + const wrongHeader = new Request("https://presence.example/v1/sync/vms", { + method: "POST", + headers: { "x-cmux-connectivity-publisher-secret": secret }, + }); + expect(await isVmsPublisherAuthorized(wrongHeader, secret)).toBe(false); + }); +}); diff --git a/workers/presence/wrangler.dev.toml b/workers/presence/wrangler.dev.toml index 81329c1114c5..621bb302c527 100644 --- a/workers/presence/wrangler.dev.toml +++ b/workers/presence/wrangler.dev.toml @@ -2,6 +2,8 @@ # the workers.dev URL (cmux-presence-dev..workers.dev), NOT the # production presence.cmux.dev custom domain. Use: # bunx wrangler deploy --config wrangler.dev.toml +# Worker secrets are per Worker: provision STACK_*, CONNECTIVITY_INVALIDATION_SECRET +# and VMS_PUBLISHER_SECRET once on this instance too (see README.md). name = "cmux-presence-dev" main = "src/index.ts" compatibility_date = "2026-05-01" diff --git a/workers/presence/wrangler.toml b/workers/presence/wrangler.toml index 175b4b20e0cf..3bed21fa750d 100644 --- a/workers/presence/wrangler.toml +++ b/workers/presence/wrangler.toml @@ -17,6 +17,8 @@ # Worker secrets (see README.md) and survive deploys: # wrangler secret put STACK_PROJECT_ID # wrangler secret put STACK_PUBLISHABLE_CLIENT_KEY +# wrangler secret put CONNECTIVITY_INVALIDATION_SECRET +# wrangler secret put VMS_PUBLISHER_SECRET # = web CMUX_PRESENCE_VMS_PUBLISHER_SECRET # Optional plain var STACK_API_URL defaults to https://api.stack-auth.com. name = "cmux-presence" From 62f5cb2bb5639e7b62879ebed18432fd3fa342ec Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 02:05:54 -0700 Subject: [PATCH 035/183] Print OK for cmux vm restart like pause and resume (#20526) #20361 added cmux vm restart and its test. pause and resume print "OK ...", the success token scripts look for, but restart printed only the localized " restarted (status=...)", so the new test failed on main. Print the OK prefix outside the localized text so it is the same in every locale. Co-authored-by: Claude Opus 5.5 --- CLI/cmux.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index cd081d96dfe5..ee4d00f2ae7f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7419,7 +7419,7 @@ struct CMUXCLI { } else if sub == "resume" { print("OK \(vmId) resumed (status=\(status))") } else { - print(String(format: String(localized: "cli.vm.restart.result", defaultValue: "%1$@ restarted (status=%2$@)"), vmId, status)) + print("OK " + String(format: String(localized: "cli.vm.restart.result", defaultValue: "%1$@ restarted (status=%2$@)"), vmId, status)) } case "base": From 1a6dc293adcc9fa496a5b582767914421216e51e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 02:43:16 -0700 Subject: [PATCH 036/183] Promote Cloud images with the Codex hook-trust fix and latest agents (#20524) * test: verify a cmux pane's Codex hooks are trusted in every devbox image The image verifier launched codex directly, never through the cmux-tui codex wrapper a daemon pane uses, and codex draws its composer before "Hooks need review", so a screen probe passes either way. Ask codex itself: capture the wrapper's argv, run hooks/list over app-server, and require every hook trusted. Against the f9d8ad69 cmux-tui the current default images bake, it reports 11 untrusted session-flag hooks. Co-Authored-By: Claude Opus 5.5 * docs: say where a new Cloud machine's image and cmux-tui come from Neither create nor attach installs or heals cmux-tui; the Dockerfile header, the VM README and .env.example said otherwise. Document the manifest's defaultForKind rows as the one source every default create resolves, the fork/restore/template exception, and the verifier's codex hook-trust check. Co-Authored-By: Claude Opus 5.5 * fix: require codex to list the hooks the wrapper injected The probe passed whenever every listed hook was trusted, so a codex that dropped the wrapper's -c hooks would still pass on the persistent ones. Count the injected hooks against codex's session-flags layer. Also bound the app-server wait with a reader thread, skip server requests that reuse an id, keep the fake codex under HOME (noexec /tmp), and correct which scripts read CMUX_VM_CMUX_TUI_MANIFEST_URL. Co-Authored-By: Claude Opus 5.5 * Promote Cloud images with the latest agents and the Codex hook-trust fix (#20532) * Bump devbox coding-agent pins to their latest releases Claude Code 2.1.299, Codex 0.162.1, OpenCode 1.19.0, pi 1.2.0 and agent-browser 0.39.1 (npm latest, devbox:pins:check --write). Epoch 2026-10-14-r1. Co-Authored-By: Claude Opus 5.5 * Promote devbox images with the latest agents and the Codex hook-trust fix Bake sh-ab014406eece40d49716f044c0880c85 (epoch 2026-10-14-r1, cmux-tui 28e864017f with #20482, Claude Code 2.1.299, Codex 0.162.1, OpenCode 1.19.0, pi 1.2.0, agent-browser 0.39.1) and its size ladder become the desktop and base defaults; the f9d8ad69 rows are demoted. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/cloud-vm-image-contract.yml | 4 + docs/cloud-guest-upgrades.md | 3 + web/.env.example | 5 +- web/scripts/devbox-agent-launch.ts | 13 + web/scripts/devbox-codex-hook-trust.py | 166 +++++++ web/scripts/verify-devbox-image.ts | 11 +- web/services/vms/README.md | 20 +- web/services/vms/drivers/cmuxTuiDaemon.ts | 8 +- web/services/vms/images/devbox/Dockerfile | 20 +- web/services/vms/images/devbox/README.md | 30 +- web/services/vms/images/manifest.json | 455 +++++++++++++++++- web/tests/devbox-codex-hook-trust-test.py | 89 ++++ web/tests/vm-devbox-image.test.ts | 13 + 13 files changed, 795 insertions(+), 42 deletions(-) create mode 100644 web/scripts/devbox-codex-hook-trust.py create mode 100644 web/tests/devbox-codex-hook-trust-test.py diff --git a/.github/workflows/cloud-vm-image-contract.yml b/.github/workflows/cloud-vm-image-contract.yml index 9431e10d15fd..e50b51f2bb59 100644 --- a/.github/workflows/cloud-vm-image-contract.yml +++ b/.github/workflows/cloud-vm-image-contract.yml @@ -14,6 +14,8 @@ on: - web/scripts/verify-devbox-image.ts - web/scripts/devbox-agent-launch.* - web/tests/devbox-agent-launch-test.py + - web/scripts/devbox-codex-hook-trust.py + - web/tests/devbox-codex-hook-trust-test.py - web/scripts/check-devbox-agent-pins.ts - web/services/vms/images/manifest.json - web/services/vms/images/sizes.ts @@ -33,6 +35,8 @@ on: - web/scripts/verify-devbox-image.ts - web/scripts/devbox-agent-launch.* - web/tests/devbox-agent-launch-test.py + - web/scripts/devbox-codex-hook-trust.py + - web/tests/devbox-codex-hook-trust-test.py - web/scripts/check-devbox-agent-pins.ts - web/services/vms/images/manifest.json - web/services/vms/images/sizes.ts diff --git a/docs/cloud-guest-upgrades.md b/docs/cloud-guest-upgrades.md index 562573d4ee17..707f35961cf7 100644 --- a/docs/cloud-guest-upgrades.md +++ b/docs/cloud-guest-upgrades.md @@ -93,6 +93,9 @@ else is image-only: design it so old machines keep working without it. The target is the cmux-tui build the default image bakes (the manifest's `cmuxTuiCommit`), so an upgraded machine runs what a new machine runs. +Machines created by fork, restore or a template boot the source's snapshot, +not the default image, so they keep its older cmux-tui and belong in the +inventory like any other running machine. 1. **Inventory.** List running machines and their owners (read-only): `select provider_vm_id, provider_metadata->>'cmuxTuiContract', image_version from cloud_vms where destroyed_at is null and provider = 'freestyle' and status = 'running'`. diff --git a/web/.env.example b/web/.env.example index 4b335a72076b..9a44e33118ce 100644 --- a/web/.env.example +++ b/web/.env.example @@ -253,6 +253,7 @@ CMUX_OTEL_BASE_SAMPLE_RATIO= # cmux-tui remote daemon on Freestyle machines (docs/cloud-cmux-tui-daemon.md) is the only # session daemon; nothing to enable. The build and its sha256 come from the -# artifacts manifest published by .github/workflows/cmux-tui-artifacts.yml. Optionally -# pin a deployment to one commit's manifest instead of the rolling `latest`: +# artifacts manifest published by .github/workflows/cmux-tui-artifacts.yml and is baked +# into the image; a deployment never installs it. The bake and verify scripts (not the +# server) can pin one commit's manifest instead of the rolling `latest`: # CMUX_VM_CMUX_TUI_MANIFEST_URL=https://files.cmux.com/cmux-tui//manifest.json diff --git a/web/scripts/devbox-agent-launch.ts b/web/scripts/devbox-agent-launch.ts index 2f8cee47c990..bef4c9f3626b 100644 --- a/web/scripts/devbox-agent-launch.ts +++ b/web/scripts/devbox-agent-launch.ts @@ -15,3 +15,16 @@ export function agentLaunchCheck( const python = `import base64; exec(compile(base64.b64decode("${supervisor}"), "devbox-agent-launch.py", "exec"))`; return `sudo -n -u ${quote(user)} env -i HOME=${quote(home)} USER=${quote(user)} TERM=xterm-256color PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin python3 -c ${quote(python)} --command ${quote(command)} --ready ${quote(marker)} --forbidden ${quote(forbidden)} --label ${quote(label)}`; } + +const hookTrust = readFileSync(new URL("./devbox-codex-hook-trust.py", import.meta.url)).toString("base64"); + +/** + * Codex's own verdict on the hooks a daemon pane's codex starts with: the + * wrapper's exact argv, then `hooks/list`, every hook trusted. Prints + * `

+
+

{t("eyebrow")}

+

{t("title")}

+

{t("description")}

+
+ {children} +
+ ); +} + +export function CloudVmHealthRouteError(props: ErrorComponentProps) { + const t = useTranslations("opsCloudVmHealth"); + return ( + + + + ); +} diff --git a/web/dashboard-app/screens/ops/cloud-vm-health.tsx b/web/dashboard-app/screens/ops/cloud-vm-health.tsx new file mode 100644 index 000000000000..3bebf655f6f0 --- /dev/null +++ b/web/dashboard-app/screens/ops/cloud-vm-health.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useLocale, useTranslations } from "next-intl"; +import type { ReactNode } from "react"; +import type { CloudVmHealth, CloudVmHealthReport } from "@/orpc/server/dashboard/schemas/ops"; +import { EmptyState } from "../../components/page-states"; +import { cloudVmHealthQuery } from "../../queries/ops"; +import { CloudVmHealthFrame } from "./cloud-vm-health-frame"; + +type Owners = CloudVmHealth["owners"]; +type Owner = CloudVmHealthReport["hung"][number]["owner"]; + +/** `/dashboard/ops/cloud-vms`: the newest stored health sweep. Staff only. */ +export function CloudVmHealthScreen() { + const { data } = useSuspenseQuery(cloudVmHealthQuery); + return ; +} + +export function CloudVmHealthView({ health }: { readonly health: CloudVmHealth }) { + const t = useTranslations("opsCloudVmHealth"); + const { snapshot, report } = health; + if (!snapshot || !report) { + return ( + + + + ); + } + return ( + +
+ + + + + + {report.errors.length > 0 ? : null} +
+
+ ); +} + +function Summary({ snapshot, report }: { readonly snapshot: NonNullable; readonly report: CloudVmHealthReport }) { + const t = useTranslations("opsCloudVmHealth"); + const locale = useLocale(); + const needed = report.attention.needed || snapshot.stale; + return ( +
+
+

+ {needed ? t("attentionYes") : t("attentionNo")} +

+ {snapshot.stale || report.attention.reasons.length > 0 ? ( +
    + {snapshot.stale ?
  • {t("reason.stale", { age: formatAge(snapshot.ageSeconds, locale) })}
  • : null} + {report.attention.reasons.map((reason) => ( +
  • {t(`reason.${reason.code}`, reasonValues(reason))}
  • + ))} +
+ ) : null} +

+ {t("sweptAt", { + age: formatAge(snapshot.ageSeconds, locale), + seconds: Math.round(report.durationMs / 1000), + })} +

+
+
+ {Object.entries(report.stateCounts).sort(([a], [b]) => a.localeCompare(b)).map(([state, count]) => ( + + ))} +
+

+ {Object.entries(report.workloadCounts).map(([workload, count]) => `${count.toLocaleString(locale)} ${workload}`).join(" · ")} +

+

+ {t("coverage", { + probed: report.coverage.probed, + running: report.coverage.running, + skippedBudget: report.coverage.skippedBudget, + skippedIdle: report.coverage.skippedIdle, + probeErrors: report.coverage.probeErrors, + })} +

+
+ ); +} + +function reasonValues(reason: CloudVmHealthReport["attention"]["reasons"][number]): Record { + return Object.fromEntries(Object.entries(reason).filter(([key]) => key !== "code")) as Record; +} + +function HungSection({ report, owners }: { readonly report: CloudVmHealthReport; readonly owners: Owners }) { + const t = useTranslations("opsCloudVmHealth"); + const locale = useLocale(); + return ( +
+ {report.hung.length === 0 ? : ( + + {report.hung.map((vm) => ( + + {vm.vmId} + {vm.workload} + + + {vm.snapshot ?? "—"} + {t("size", sizeValues(vm.size))} + + {formatTime(vm.firstSeenHungAt, locale)} + + {formatTime(vm.providerUpdatedAt, locale)} + {vm.dbUpdatedAt ? {t("dbUpdated", { time: formatTime(vm.dbUpdatedAt, locale) })} : null} + + {vm.detail} + + ))} +
+ )} +
+ ); +} + +function StuckSection({ report, owners }: { readonly report: CloudVmHealthReport; readonly owners: Owners }) { + const t = useTranslations("opsCloudVmHealth"); + const locale = useLocale(); + return ( +
+ {report.stuck.length === 0 ? : ( + + {report.stuck.map((vm) => ( + + {vm.vmId} + {vm.state} + {t(`source.${vm.source}`)} + {vm.workload} + + {formatTime(vm.since, locale)} ({formatAge(vm.minutes * 60, locale)}) + + ))} +
+ )} +
+ ); +} + +function DriftSection({ report, owners }: { readonly report: CloudVmHealthReport; readonly owners: Owners }) { + const t = useTranslations("opsCloudVmHealth"); + const locale = useLocale(); + return ( +
+ {report.drift.length === 0 ? : ( + + {report.drift.map((vm) => ( + + {vm.vmId} + {t(`driftKind.${vm.kind}`)} + {vm.dbStatus ?? "—"} + {vm.providerState ?? "—"} + + {formatTime(vm.dbUpdatedAt ?? vm.providerUpdatedAt, locale)} + + ))} +
+ )} +
+ ); +} + +function TlsSection({ report, owners }: { readonly report: CloudVmHealthReport; readonly owners: Owners }) { + const t = useTranslations("opsCloudVmHealth"); + const locale = useLocale(); + const tls = report.tls; + if (!tls) { + return
; + } + const over = tls.totalCount - tls.limit; + return ( +
+

= 0 ? "text-red-600" : ""}`}> + {t("tls.usage", { count: tls.totalCount, limit: tls.limit })} + {over >= 0 ? ` ${t("tls.over", { over })}` : ""} +

+ {!tls.complete ?

{t("tls.partial", { listed: tls.listed })}

: null} +
+ {Object.entries(tls.byOwner).sort(([, a], [, b]) => b - a).map(([owner, count]) => ( + + ))} +
+

+ {t("tls.pausedTitle", { days: tls.pausedOverDays, vms: tls.pausedHolders.vmCount, rules: tls.pausedHolders.ruleCount })} +

+ {tls.pausedHolders.top.length === 0 ? : ( + + {tls.pausedHolders.top.map((holder) => ( + + {holder.vmId} + {holder.workload} + + {formatTime(holder.pausedSince, locale)} + {holder.rules} + + ))} +
+ )} +
+ ); +} + +function tlsOwnerLabel(owner: string, t: ReturnType>): string { + switch (owner) { + case "account_level": + case "vpc_unattributed": + case "unknown_vm": + case "managed": + return t(`tlsOwner.${owner}`); + default: + return owner; + } +} + +function ErrorsSection({ report }: { readonly report: CloudVmHealthReport }) { + const t = useTranslations("opsCloudVmHealth"); + return ( +
+
    + {report.errors.map((error, index) => ( +
  • {error.stage}: {error.message}
  • + ))} +
+
+ ); +} + +function OwnerCell({ owner, owners }: { readonly owner: Owner; readonly owners: Owners }) { + const t = useTranslations("opsCloudVmHealth"); + if (!owner) return {t("noOwner")}; + const contact = owners[owner.userId]; + return ( + + {contact?.email ?? owner.userId} + {owner.teamId && owner.teamId !== owner.userId ? {owner.teamId} : null} + + ); +} + +function Section({ title, body, testId, children }: { readonly title: string; readonly body: string; readonly testId: string; readonly children?: ReactNode }) { + return ( +
+

{title}

+

{body}

+ {children ?
{children}
: null} +
+ ); +} + +function None() { + const t = useTranslations("opsCloudVmHealth"); + return

{t("none")}

; +} + +function Table({ headers, children }: { readonly headers: readonly string[]; readonly children: ReactNode }) { + return ( +
+ + + {headers.map((header) => )} + + {children} +
{header}
+
+ ); +} + +function Cell({ children, mono }: { readonly children: ReactNode; readonly mono?: boolean }) { + return {children}; +} + +function Metric({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** Size in the units the provider bills: vCPU, GiB of memory, GB of disk; "?" when unknown. */ +function sizeValues(size: CloudVmHealthReport["hung"][number]["size"]): Record { + return { + cpu: size.cpu === null ? "?" : String(size.cpu), + memory: size.memoryMib === null ? "?" : String(Math.round(size.memoryMib / 1024)), + disk: size.storageMib === null ? "?" : String(Math.round(size.storageMib / 1024)), + }; +} + +function formatTime(iso: string | null, locale: string): string { + if (!iso) return "—"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" }); +} + +/** "3 minutes ago" in the viewer's language, using the largest whole unit. */ +function formatAge(seconds: number, locale: string): string { + const format = new Intl.RelativeTimeFormat(locale, { numeric: "always" }); + if (seconds < 90) return format.format(-Math.round(seconds), "second"); + if (seconds < 90 * 60) return format.format(-Math.round(seconds / 60), "minute"); + if (seconds < 36 * 3600) return format.format(-Math.round(seconds / 3600), "hour"); + return format.format(-Math.round(seconds / 86400), "day"); +} diff --git a/web/db/migrations/20261014120000_cloud_vm_health_snapshots/migration.sql b/web/db/migrations/20261014120000_cloud_vm_health_snapshots/migration.sql new file mode 100644 index 000000000000..9405c3ee1be6 --- /dev/null +++ b/web/db/migrations/20261014120000_cloud_vm_health_snapshots/migration.sql @@ -0,0 +1,11 @@ +-- Staff Cloud VM health sweep snapshots. Additive: only the new health cron +-- writes this table and only the new staff page reads it, so old app code +-- runs unchanged before and after the migration. +CREATE TABLE IF NOT EXISTS "cloud_vm_health_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "attention_needed" boolean NOT NULL, + "report" jsonb NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cloud_vm_health_snapshots_created_at_idx" ON "cloud_vm_health_snapshots" USING btree ("created_at"); diff --git a/web/db/schema.ts b/web/db/schema.ts index 0fe222c40fee..36eceb559a80 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -2340,6 +2340,20 @@ export const cloudVmAlertStates = pgTable("cloud_vm_alert_states", { check("cloud_vm_alert_states_severity_check", sql`${table.severity} in ('critical', 'warning')`), ]); +/** + * One staff Cloud VM health sweep per row (services/vms/health). Read-only + * observations of the provider account; the page reads the newest row and + * the sweep prunes rows past its retention window. + */ +export const cloudVmHealthSnapshots = pgTable("cloud_vm_health_snapshots", { + id: uuid("id").defaultRandom().primaryKey(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + attentionNeeded: boolean("attention_needed").notNull(), + report: jsonb("report").notNull(), +}, (table) => [ + index("cloud_vm_health_snapshots_created_at_idx").on(table.createdAt), +]); + /** Sanitized Cloud diagnostics. The receipt and export lease survive server restarts. */ export const cloudDiagnosticEvents = pgTable("cloud_diagnostic_events", { userId: text("user_id").notNull(), diff --git a/web/messages/en.json b/web/messages/en.json index ec267a14bf08..b8df12aa4932 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -7373,5 +7373,89 @@ "errorSignUpNotAllowed": "New accounts can't be created right now.", "errorPasskey": "Passkey sign-in didn't finish. Try again or use another method.", "errorGeneric": "Something went wrong. Try again." + }, + "opsCloudVmHealth": { + "eyebrow": "Staff · Operations", + "title": "VM Health", + "description": "Whether any Cloud VM needs an operator. A read-only sweep of the provider account runs every five minutes; this page shows the latest one.", + "noSnapshotTitle": "No sweep yet", + "noSnapshotBody": "The health cron has not stored a sweep. It runs every five minutes once deployed and migrated.", + "attentionYes": "Needs attention: yes", + "attentionNo": "Needs attention: no", + "sweptAt": "Swept {age}, in {seconds, plural, one {# second} other {# seconds}}.", + "coverage": "Probed {probed} of {running} running VMs. Not probed: {skippedBudget} past the time budget, {skippedIdle} with an idle timeout. {probeErrors, plural, =0 {No probe errors.} one {# probe error.} other {# probe errors.}}", + "reason": { + "stale": "The latest sweep is from {age}; the health cron may be failing.", + "hung_vms": "{count, plural, one {# running VM does not answer its control path} other {# running VMs do not answer their control path}}", + "stuck_transitions": "{count, plural, one {# VM is stuck in a transition} other {# VMs are stuck in a transition}}", + "drift": "{count, plural, one {# VM differs between the database and the provider} other {# VMs differ between the database and the provider}}", + "tls_over_limit": "TLS rules are at or over the account limit: {count} of {limit}. Network policy domain additions fail.", + "tls_near_limit": "TLS rules are near the account limit: {count} of {limit}.", + "sweep_incomplete": "The sweep was incomplete: {unprobed, plural, one {# running VM was} other {# running VMs were}} not probed, or the VM list was partial.", + "sweep_errors": "{count, plural, one {# sweep stage failed} other {# sweep stages failed}}." + }, + "hung": { + "title": "Hung VMs ({count})", + "body": "Provider state is running, but a 10-second file system probe timed out or the provider answered 409 or 5xx." + }, + "stuck": { + "title": "Stuck transitions ({count})", + "body": "VMs starting or pausing at the provider, or provisioning in the database, for longer than 15 minutes." + }, + "drift": { + "title": "Database and provider drift ({count})", + "body": "cmux Cloud VMs whose database status disagrees with the provider for longer than 10 minutes. The reconcile cron repairs drift; this page only reports it." + }, + "tls": { + "title": "TLS rule budget", + "body": "Account-wide TLS rules count against one limit shared by every workload on the provider account.", + "unavailable": "The sweep could not read TLS rules.", + "usage": "{count} of {limit} rules in use.", + "over": "{over, plural, =0 {At the limit.} other {# over the limit.}}", + "partial": "Only {listed} rules were listed, so the breakdown is partial.", + "pausedTitle": "Held by VMs paused for more than {days} days: {rules, plural, one {# rule} other {# rules}} on {vms, plural, one {# VM} other {# VMs}}" + }, + "tlsOwner": { + "account_level": "Account level (no VM)", + "vpc_unattributed": "Private network (no VM)", + "unknown_vm": "Deleted or unlisted VM", + "managed": "Provider managed" + }, + "errors": { + "title": "Sweep errors", + "body": "Stages of the last sweep that failed. Sections that depend on them may be empty." + }, + "driftKind": { + "provider_missing": "Missing at the provider", + "db_running_provider_paused": "Running in the database, paused at the provider", + "db_paused_provider_running": "Paused in the database, running at the provider", + "db_destroyed_provider_present": "Destroyed in the database, present at the provider", + "provider_untracked": "At the provider with no database row" + }, + "source": { + "provider": "Provider", + "db": "Database" + }, + "col": { + "vm": "VM", + "workload": "Workload", + "owner": "Owner", + "image": "Image and size", + "firstSeenHung": "First seen hung", + "updated": "Last updated", + "detail": "Probe result", + "state": "State", + "source": "Source", + "since": "Since", + "drift": "Drift", + "dbStatus": "Database", + "providerState": "Provider", + "pausedSince": "Paused since", + "rules": "Rules" + }, + "size": "{cpu} vCPU · {memory} GiB · {disk} GB disk", + "dbUpdated": "Database: {time}", + "noOwner": "No cmux owner", + "none": "None." } } diff --git a/web/messages/ja.json b/web/messages/ja.json index 4d61a202659b..c044c054ba7c 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -7283,5 +7283,89 @@ "errorSignUpNotAllowed": "現在、新しいアカウントは作成できません。", "errorPasskey": "パスキーでのサインインが完了しませんでした。もう一度試すか、別の方法を使用してください。", "errorGeneric": "問題が発生しました。もう一度お試しください。" + }, + "opsCloudVmHealth": { + "eyebrow": "スタッフ · 運用", + "title": "VM ヘルス", + "description": "オペレーターの対応が必要な Cloud VM があるかを示します。プロバイダーアカウントの読み取り専用スイープが 5 分ごとに実行され、このページは最新の結果を表示します。", + "noSnapshotTitle": "スイープはまだありません", + "noSnapshotBody": "ヘルス cron はまだスイープを保存していません。デプロイとマイグレーションの後、5 分ごとに実行されます。", + "attentionYes": "対応が必要: はい", + "attentionNo": "対応が必要: いいえ", + "sweptAt": "スイープ: {age}(所要 {seconds} 秒)。", + "coverage": "実行中の VM {running} 台のうち {probed} 台をプローブしました。未プローブ: 時間予算超過 {skippedBudget} 台、アイドルタイムアウト設定あり {skippedIdle} 台。プローブエラー {probeErrors} 件。", + "reason": { + "stale": "最新のスイープは {age} のものです。ヘルス cron が失敗している可能性があります。", + "hung_vms": "実行中の VM {count} 台が制御経路に応答しません", + "stuck_transitions": "VM {count} 台が状態遷移の途中で止まっています", + "drift": "VM {count} 台でデータベースとプロバイダーの状態が一致しません", + "tls_over_limit": "TLS ルールがアカウント上限に達しているか超えています: {limit} 件中 {count} 件。ネットワークポリシーのドメイン追加は失敗します。", + "tls_near_limit": "TLS ルールがアカウント上限に近づいています: {limit} 件中 {count} 件。", + "sweep_incomplete": "スイープが不完全です。実行中の VM {unprobed} 台がプローブされなかったか、VM 一覧が部分的でした。", + "sweep_errors": "スイープの {count} 段階が失敗しました。" + }, + "hung": { + "title": "応答しない VM({count})", + "body": "プロバイダー上の状態は running ですが、10 秒のファイルシステムプローブがタイムアウトしたか、プロバイダーが 409 または 5xx を返しました。" + }, + "stuck": { + "title": "止まった状態遷移({count})", + "body": "プロバイダーで starting または pausing のまま、またはデータベースで provisioning のまま 15 分を超えた VM です。" + }, + "drift": { + "title": "データベースとプロバイダーの不一致({count})", + "body": "データベースの状態がプロバイダーと 10 分以上一致しない cmux Cloud VM です。修復は reconcile cron が行い、このページは報告のみです。" + }, + "tls": { + "title": "TLS ルールの予算", + "body": "アカウント全体の TLS ルールは、プロバイダーアカウント上のすべてのワークロードで共有される 1 つの上限に数えられます。", + "unavailable": "スイープは TLS ルールを読み取れませんでした。", + "usage": "{limit} 件中 {count} 件を使用中。", + "over": "{over, plural, =0 {上限ちょうどです。} other {上限を # 件超えています。}}", + "partial": "一覧できたルールは {listed} 件のみのため、内訳は部分的です。", + "pausedTitle": "{days} 日以上一時停止中の VM が保持: VM {vms} 台で {rules} 件" + }, + "tlsOwner": { + "account_level": "アカウントレベル(VM なし)", + "vpc_unattributed": "プライベートネットワーク(VM なし)", + "unknown_vm": "削除済みまたは一覧にない VM", + "managed": "プロバイダー管理" + }, + "errors": { + "title": "スイープのエラー", + "body": "前回のスイープで失敗した段階です。それに依存するセクションは空の場合があります。" + }, + "driftKind": { + "provider_missing": "プロバイダーに存在しない", + "db_running_provider_paused": "データベースでは実行中、プロバイダーでは一時停止", + "db_paused_provider_running": "データベースでは一時停止、プロバイダーでは実行中", + "db_destroyed_provider_present": "データベースでは削除済み、プロバイダーには存在", + "provider_untracked": "プロバイダーに存在し、データベースの行がない" + }, + "source": { + "provider": "プロバイダー", + "db": "データベース" + }, + "col": { + "vm": "VM", + "workload": "ワークロード", + "owner": "所有者", + "image": "イメージとサイズ", + "firstSeenHung": "初回検出", + "updated": "最終更新", + "detail": "プローブ結果", + "state": "状態", + "source": "出所", + "since": "開始", + "drift": "不一致", + "dbStatus": "データベース", + "providerState": "プロバイダー", + "pausedSince": "一時停止開始", + "rules": "ルール" + }, + "size": "{cpu} vCPU · メモリ {memory} GiB · ディスク {disk} GB", + "dbUpdated": "データベース: {time}", + "noOwner": "cmux の所有者なし", + "none": "なし。" } } diff --git a/web/orpc/server/dashboard/ops.ts b/web/orpc/server/dashboard/ops.ts new file mode 100644 index 000000000000..eb495acf0c74 --- /dev/null +++ b/web/orpc/server/dashboard/ops.ts @@ -0,0 +1,25 @@ +import * as cloudVmHealthRoute from "@/app/api/admin/cloud-vm-health/route"; +import type { NextRequest } from "next/server"; +import { dashboardOS, requireDashboardOrigin } from "./base"; +import { callRoute } from "./route-call"; +import { cloudVmHealthSchema } from "./schemas/ops"; + +/** + * Staff-only operations views. Each procedure runs the matching + * `/api/admin/*` route in process, so the admin gate (verified company email + * or active admin member) is the same one the REST API enforces. Non-staff get + * its 403, which the screen renders as not found. + */ +const opsOS = dashboardOS.use(requireDashboardOrigin); + +const cloudVmHealth = opsOS + .output(cloudVmHealthSchema) + .handler(async ({ context }) => + cloudVmHealthSchema.parse(await callRoute( + context, + (request: Request) => cloudVmHealthRoute.GET(request as NextRequest), + { method: "GET", path: "/api/admin/cloud-vm-health" }, + )) + ); + +export const opsRouter = { cloudVmHealth }; diff --git a/web/orpc/server/dashboard/router.ts b/web/orpc/server/dashboard/router.ts index 34f714d83de2..d1e3f9582e41 100644 --- a/web/orpc/server/dashboard/router.ts +++ b/web/orpc/server/dashboard/router.ts @@ -2,6 +2,7 @@ import { accountRouter } from "./account"; import { billingRouter } from "./billing"; import { cloudRouter } from "./cloud"; import { coderouterRouter } from "./coderouter"; +import { opsRouter } from "./ops"; import { settingsRouter } from "./settings"; import { teamsRouter } from "./teams"; import { vaultRouter } from "./vault"; @@ -19,6 +20,7 @@ export const dashboardRouter = { cloud: cloudRouter, vault: vaultRouter, settings: settingsRouter, + ops: opsRouter, }; export type DashboardRouter = typeof dashboardRouter; diff --git a/web/orpc/server/dashboard/schemas/ops.ts b/web/orpc/server/dashboard/schemas/ops.ts new file mode 100644 index 000000000000..f6edef54d11a --- /dev/null +++ b/web/orpc/server/dashboard/schemas/ops.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +// Zod only: the dashboard client imports this module at runtime. + +const nullableString = z.string().nullable(); +const nullableNumber = z.number().nullable(); + +const ownerSchema = z.object({ + cloudVmId: z.string(), + userId: z.string(), + teamId: nullableString, +}).nullable(); + +const hungSchema = z.object({ + vmId: z.string(), + workload: z.string(), + owner: ownerSchema, + snapshot: nullableString, + size: z.object({ cpu: nullableNumber, memoryMib: nullableNumber, storageMib: nullableNumber }), + firstSeenHungAt: z.string(), + providerUpdatedAt: z.string(), + dbUpdatedAt: nullableString, + detail: z.string(), +}); + +const stuckSchema = z.object({ + vmId: z.string(), + source: z.enum(["provider", "db"]), + state: z.string(), + workload: z.string(), + owner: ownerSchema, + since: z.string(), + minutes: z.number(), +}); + +const driftSchema = z.object({ + vmId: z.string(), + kind: z.enum([ + "provider_missing", + "db_running_provider_paused", + "db_paused_provider_running", + "db_destroyed_provider_present", + "provider_untracked", + ]), + dbStatus: nullableString, + providerState: nullableString, + owner: ownerSchema, + dbUpdatedAt: nullableString, + providerUpdatedAt: nullableString, +}); + +const tlsSchema = z.object({ + totalCount: z.number(), + limit: z.number(), + listed: z.number(), + complete: z.boolean(), + byOwner: z.record(z.string(), z.number()), + pausedOverDays: z.number(), + pausedHolders: z.object({ + vmCount: z.number(), + ruleCount: z.number(), + top: z.array(z.object({ + vmId: z.string(), + workload: z.string(), + owner: ownerSchema, + pausedSince: z.string(), + rules: z.number(), + })), + }), +}).nullable(); + +const reasonSchema = z.discriminatedUnion("code", [ + z.object({ code: z.literal("hung_vms"), count: z.number() }), + z.object({ code: z.literal("stuck_transitions"), count: z.number() }), + z.object({ code: z.literal("drift"), count: z.number() }), + z.object({ code: z.literal("tls_over_limit"), count: z.number(), limit: z.number() }), + z.object({ code: z.literal("tls_near_limit"), count: z.number(), limit: z.number() }), + z.object({ code: z.literal("sweep_incomplete"), unprobed: z.number() }), + z.object({ code: z.literal("sweep_errors"), count: z.number() }), +]); + +const reportSchema = z.object({ + version: z.literal(1), + generatedAt: z.string(), + durationMs: z.number(), + stateCounts: z.record(z.string(), z.number()), + workloadCounts: z.record(z.string(), z.number()), + coverage: z.object({ + inventoryComplete: z.boolean(), + running: z.number(), + probed: z.number(), + probeErrors: z.number(), + skippedBudget: z.number(), + skippedIdle: z.number(), + skippedNotRunning: z.number().optional(), + }), + hung: z.array(hungSchema), + stuck: z.array(stuckSchema), + drift: z.array(driftSchema), + tls: tlsSchema, + errors: z.array(z.object({ + stage: z.enum(["previous_snapshot", "inventory", "tls_rules", "db_rows"]), + message: z.string(), + })), + attention: z.object({ needed: z.boolean(), reasons: z.array(reasonSchema) }), +}); + +/** `GET /api/admin/cloud-vm-health`: the newest stored sweep, staff only. */ +export const cloudVmHealthSchema = z.object({ + snapshot: z.object({ + id: z.string(), + createdAt: z.string(), + ageSeconds: z.number(), + stale: z.boolean(), + }).nullable(), + report: reportSchema.nullable(), + owners: z.record(z.string(), z.object({ email: nullableString, displayName: nullableString })), +}); + +export type CloudVmHealth = z.output; +export type CloudVmHealthReport = z.output; diff --git a/web/services/observability/vmAlerts.ts b/web/services/observability/vmAlerts.ts index 40915adb2252..928ce7313ff2 100644 --- a/web/services/observability/vmAlerts.ts +++ b/web/services/observability/vmAlerts.ts @@ -46,6 +46,8 @@ export type VmAlertStateStore = { readonly claim: (input: AlertInput, now: Date) => Promise; readonly acknowledge: (key: string, leaseId: string, now: Date) => Promise; readonly clear: (key: string, now: Date) => Promise; + /** Drop a delivery lease that did not send, keeping the alert active, so the next run can retry. */ + readonly release?: (key: string, leaseId: string, now: Date) => Promise; }; type SendAlert = (input: AlertInput) => Promise; @@ -517,6 +519,15 @@ export function durableVmAlertStateStore(db: ReturnType): VmAler eq(cloudVmAlertStates.deliveryLeaseId, leaseId), )); }, + release: async (key, leaseId, now) => { + await db + .update(cloudVmAlertStates) + .set({ deliveryLeaseId: null, deliveryLeaseUntil: null, updatedAt: now }) + .where(and( + eq(cloudVmAlertStates.alertKey, key), + eq(cloudVmAlertStates.deliveryLeaseId, leaseId), + )); + }, clear: async (key, now) => { await db .update(cloudVmAlertStates) diff --git a/web/services/observability/vmHealthAlerts.ts b/web/services/observability/vmHealthAlerts.ts new file mode 100644 index 000000000000..f48ab01e7391 --- /dev/null +++ b/web/services/observability/vmHealthAlerts.ts @@ -0,0 +1,107 @@ +import type { VmHealthReport } from "../vms/health/types"; +import { sendAlert, type AlertInput, type AlertResult } from "./alerts"; +import { durableVmAlertStateStore, type VmAlertStateStore } from "./vmAlerts"; +import { eq } from "drizzle-orm"; +import { cloudDb } from "../../db/client"; +import { cloudVmAlertStates } from "../../db/schema"; + +export const VM_HEALTH_ALERT_KEY = "vm-health-intervention"; +const SAMPLE_LIMIT = 10; +/** A cleared alert can page again only this long after its last page. */ +export const VM_HEALTH_ALERT_MIN_INTERVAL_MS = 60 * 60 * 1000; + +/** + * The operator alert for a health sweep, or null when nothing needs a person. + * Hung VMs are critical (users cannot reach them); stuck transitions alone are + * a warning. Drift and TLS budget stay on the page: the reconcile cron and the + * existing TLS rule-capacity alert own those. + */ +export function vmHealthAlert(report: VmHealthReport, pageUrl: string | null): AlertInput | null { + const hung = report.hung; + const stuck = report.stuck; + if (hung.length === 0 && stuck.length === 0) return null; + const lines: string[] = []; + if (hung.length > 0) { + lines.push(`${hung.length} running VM(s) do not answer their control path (${countWorkloads(hung)}).`); + lines.push(`Hung: ${hung.slice(0, SAMPLE_LIMIT).map((vm) => `${vm.vmId} since ${vm.firstSeenHungAt}`).join(", ")}${hung.length > SAMPLE_LIMIT ? ", ..." : ""}.`); + } + if (stuck.length > 0) { + lines.push(`${stuck.length} VM(s) stuck in a transition: ${stuck.slice(0, SAMPLE_LIMIT).map((vm) => `${vm.vmId} ${vm.state} ${vm.minutes}m`).join(", ")}${stuck.length > SAMPLE_LIMIT ? ", ..." : ""}.`); + } + if (pageUrl) lines.push(`Details: ${pageUrl}`); + return { + key: VM_HEALTH_ALERT_KEY, + title: hung.length > 0 ? "Cloud VMs hung" : "Cloud VMs stuck in a transition", + body: lines.join(" "), + severity: hung.length > 0 ? "critical" : "warning", + }; +} + +function countWorkloads(vms: readonly { readonly workload: string }[]): string { + const counts = new Map(); + for (const vm of vms) counts.set(vm.workload, (counts.get(vm.workload) ?? 0) + 1); + return [...counts].map(([workload, count]) => `${count} ${workload}`).join(", "); +} + +export type VmHealthAlertOutcome = "none" | "sent" | "suppressed" | "unconfigured" | "failed"; + +/** + * Send the sweep's alert through the shared Slack path, deduplicated by the + * same durable state the other VM alerts use: one page when it starts, a + * reminder every 24 hours, and an immediate re-page only on escalation to + * critical. Only a clean sweep that saw the whole fleet clears the state, and + * only when the last page is at least an hour old, so a VM flapping between + * hung and healthy pages at most once an hour instead of on every run. + */ +export async function deliverVmHealthAlert( + report: VmHealthReport, + options: { + readonly now?: Date; + readonly pageUrl?: string | null; + readonly store?: VmAlertStateStore; + readonly send?: (input: AlertInput) => Promise; + readonly lastSentAt?: (key: string) => Promise; + } = {}, +): Promise { + const now = options.now ?? new Date(); + const store = options.store ?? durableVmAlertStateStore(cloudDb()); + const alert = vmHealthAlert(report, options.pageUrl ?? null); + try { + if (!alert) { + // A sweep that could not see the fleet is not evidence the incident is + // over: keep the state so recovery from a blind sweep does not re-page. + if (!sweepSawFleet(report)) return "none"; + const lastSentAt = await (options.lastSentAt ?? readLastSentAt)(VM_HEALTH_ALERT_KEY); + if (!lastSentAt || now.getTime() - lastSentAt.getTime() >= VM_HEALTH_ALERT_MIN_INTERVAL_MS) { + await store.clear(VM_HEALTH_ALERT_KEY, now); + } + return "none"; + } + const leaseId = await store.claim(alert, now); + if (!leaseId) return "suppressed"; + const result = await (options.send ?? ((input) => sendAlert(input)))(alert); + if (result.sent) await store.acknowledge(VM_HEALTH_ALERT_KEY, leaseId, now); + else await store.release?.(VM_HEALTH_ALERT_KEY, leaseId, now); + if (result.configured === false) return "unconfigured"; + return result.sent ? "sent" : "failed"; + } catch (error) { + console.error("vm.health.alert_failed", { + error: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200), + }); + return "failed"; + } +} + +/** True when the sweep listed every VM, probed every running one it could, and hit no stage error. */ +export function sweepSawFleet(report: VmHealthReport): boolean { + return report.errors.length === 0 && report.coverage.inventoryComplete && report.coverage.skippedBudget === 0; +} + +async function readLastSentAt(key: string): Promise { + const [row] = await cloudDb() + .select({ lastSentAt: cloudVmAlertStates.lastSentAt }) + .from(cloudVmAlertStates) + .where(eq(cloudVmAlertStates.alertKey, key)) + .limit(1); + return row?.lastSentAt ?? null; +} diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index f97a585bb65a..9de54f2a6c07 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -15,6 +15,7 @@ import { FreestyleTlsRuleLimitRestoreError, inlineEgressFirewallRules, inlineEgr import { isIP } from "node:net"; import { Effect } from "effect"; import { FreestyleResourceStatsReader } from "./freestyleResourceStatsReader"; +import { freestyleHealthInventory } from "./freestyleHealthInventory"; import { announceFreestyleNetwork } from "./freestyleNetworkAnnouncement"; import { freestyleRequestFetch } from "./freestyleRequestTiming"; import { currentVmRequestContext } from "../requestContext"; @@ -57,6 +58,7 @@ import { type VMStatus, type ProviderReadOptions, type VmPauseState, + type VmHealthInventory, } from "./types"; import { DEVBOX_DESKTOP_NOVNC_PORT, @@ -1048,6 +1050,7 @@ export class FreestyleProvider implements VMProvider { readonly privateNetworking: VMPrivateNetworking; private readonly resourceStats: FreestyleResourceStatsReader; + readonly healthInventory: VmHealthInventory; constructor( private readonly deps: FreestyleProviderDependencies = { @@ -1056,6 +1059,7 @@ export class FreestyleProvider implements VMProvider { ) { this.privateNetworking = new FreestylePrivateNetworking(this.deps.client); this.resourceStats = new FreestyleResourceStatsReader(this.deps.client); + this.healthInventory = freestyleHealthInventory(this.deps.client); } async prepareSCP(vmId: string, publicKey: string): Promise { diff --git a/web/services/vms/drivers/freestyleHealthInventory.ts b/web/services/vms/drivers/freestyleHealthInventory.ts new file mode 100644 index 000000000000..cfafeba2ea31 --- /dev/null +++ b/web/services/vms/drivers/freestyleHealthInventory.ts @@ -0,0 +1,173 @@ +import type { Freestyle, TlsRuleData, VmData } from "freestyle"; +import type { + ProviderInventoryVm, + ProviderTlsRuleInventory, + ProviderTlsRuleRef, + ProviderVmInventory, + VmControlProbe, +} from "../health/types"; +import { sanitizeDetail } from "../health/sanitize"; +import type { VmHealthInventory } from "./types"; + +/** + * Read-only account inventory for the staff health sweep. Nothing here + * starts, pauses, updates, or deletes a VM or rule: the listing calls are + * plain GETs, and the probe is one raw `fs/stat` of `/` with no background + * polling and no retry, so a hung VM costs exactly one bounded request. + */ +const PAGE_SIZE = 100; +/** 50 pages covers 5000 VMs or rules; past that the listing reports itself partial. */ +const MAX_PAGES = 50; +const LIST_REQUEST_TIMEOUT_MS = 20_000; + +type ClientFactory = (timeoutMs?: number, signal?: AbortSignal) => Freestyle; + +export function freestyleHealthInventory(client: ClientFactory): VmHealthInventory { + return { + listAccountVms: (options) => listAccountVms(client, options), + listAccountTlsRules: (options) => listAccountTlsRules(client, options), + probeControl: (vmId, options) => probeControl(client, vmId, options), + }; +} + +type ListOptions = { readonly deadlineAtMs: number; readonly signal?: AbortSignal }; + +async function listAccountVms(client: ClientFactory, options: ListOptions): Promise { + const vms: ProviderInventoryVm[] = []; + const seen = new Set(); + for (let page = 0; page < MAX_PAGES; page += 1) { + if (Date.now() >= options.deadlineAtMs) return { vms, complete: false }; + const result = await client(LIST_REQUEST_TIMEOUT_MS, options.signal).vms.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE }); + for (const vm of result.vms) { + // Offset paging over a changing list can repeat a VM across pages. + if (seen.has(vm.id)) continue; + seen.add(vm.id); + vms.push(inventoryVm(vm)); + } + if (result.vms.length < PAGE_SIZE || (page + 1) * PAGE_SIZE >= result.totalCount) { + // A delete during offset paging shifts a VM onto a page already read; + // a short count means some VM was never seen, so missing is unknown. + return { vms, complete: seen.size >= result.totalCount }; + } + } + return { vms, complete: false }; +} + +export function inventoryVm(vm: VmData): ProviderInventoryVm { + return { + id: vm.id, + state: vm.state, + metadata: { ...(vm.metadata ?? {}) }, + snapshot: vm.sourceSnapshotSlug ?? vm.sourceSnapshotSlugAtCreate ?? vm.snapshotId ?? null, + cpu: finiteOrNull(vm.resources?.cpu), + memoryMib: finiteOrNull(vm.resources?.memory), + storageMib: finiteOrNull(vm.resources?.storage), + idleTimeoutSeconds: finiteOrNull(vm.idleTimeoutSeconds), + vpcIds: (vm.vpcs ?? []).map((network) => network.vpcId ?? network.vpc).filter((id): id is string => Boolean(id)), + createdAt: vm.createdAt, + updatedAt: vm.updatedAt, + }; +} + +async function listAccountTlsRules(client: ClientFactory, options: ListOptions): Promise { + const rules: ProviderTlsRuleRef[] = []; + const seen = new Set(); + let totalCount: number | null = null; + for (let page = 0; page < MAX_PAGES; page += 1) { + if (Date.now() >= options.deadlineAtMs) break; + const result = await client(LIST_REQUEST_TIMEOUT_MS, options.signal).tls.rules.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE }); + totalCount = result.totalCount; + for (const rule of result.rules) { + // Newest-first paging repeats a rule when one is created mid-walk. + if (seen.has(rule.id)) continue; + seen.add(rule.id); + rules.push(tlsRuleRef(rule)); + } + if (result.rules.length < PAGE_SIZE || (page + 1) * PAGE_SIZE >= result.totalCount) { + return { totalCount: result.totalCount, rules, complete: seen.size >= result.totalCount }; + } + } + // No page at all is a failed read, not an account with zero rules. + if (totalCount === null) throw new Error("TLS rule listing ran out of time before its first page"); + return { totalCount, rules, complete: false }; +} + +/** The ids a rule dies with or names. Domain, transform and auth config never leave this function. */ +export function tlsRuleRef(rule: TlsRuleData): ProviderTlsRuleRef { + const vmIds = new Set(); + const vpcIds = new Set(); + for (const dependency of rule.dependencies ?? []) { + if (dependency.kind === "vm") vmIds.add(dependency.id); + else if (dependency.kind === "vpc") vpcIds.add(dependency.id); + } + for (const endpoint of [rule.source, rule.destination]) { + if (endpoint?.vmId) vmIds.add(endpoint.vmId); + if (endpoint?.vpcId) vpcIds.add(endpoint.vpcId); + } + return { id: rule.id, managed: rule.managed === true, vmIds: [...vmIds], vpcIds: [...vpcIds] }; +} + +/** + * One bounded control-path probe. The VM's state is re-read first with a plain + * GET that never wakes it: the inventory can be minutes old by the time a + * probe runs, and a file system call on a VM paused since then would resume + * it. A failed state read is a probe error (the API, not the VM, is in + * trouble), so a provider outage is never reported as a fleet of hung VMs. + */ +async function probeControl( + client: ClientFactory, + vmId: string, + options: { readonly timeoutMs: number; readonly signal?: AbortSignal }, +): Promise { + const started = Date.now(); + const timeout = AbortSignal.timeout(options.timeoutMs); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + const api = client(options.timeoutMs, signal); + const elapsed = () => Date.now() - started; + let stateResponse: Response; + try { + stateResponse = await api.fetch(`/v5/vms/${encodeURIComponent(vmId)}`, { method: "GET", signal }); + } catch (error) { + return { outcome: "error", latencyMs: elapsed(), detail: sanitizeDetail(`state read: ${timeout.aborted ? "timeout" : errorName(error)}`) }; + } + if (stateResponse.status === 404) return { outcome: "gone", latencyMs: elapsed() }; + if (!stateResponse.ok) return { outcome: "error", latencyMs: elapsed(), detail: `state read: ${await errorDetail(stateResponse)}` }; + const state = await stateResponse.json().then((body: unknown) => (body as { state?: unknown })?.state).catch(() => undefined); + // Only a known state skips the probe; an unreadable body says nothing about the VM. + if (typeof state !== "string") return { outcome: "error", latencyMs: elapsed(), detail: "state read: unreadable body" }; + if (state !== "running") return { outcome: "not_running", latencyMs: elapsed() }; + let response: Response; + try { + response = await api.fetch(`/v5/vms/${encodeURIComponent(vmId)}/fs/stat?path=${encodeURIComponent("/")}`, { method: "GET", signal }); + } catch (error) { + if (timeout.aborted) return { outcome: "hung", latencyMs: elapsed(), detail: `timeout after ${options.timeoutMs}ms` }; + return { outcome: "error", latencyMs: elapsed(), detail: sanitizeDetail(errorName(error)) }; + } + if (response.ok) { + await response.body?.cancel().catch(() => undefined); + return { outcome: "ok", latencyMs: elapsed() }; + } + if (response.status === 404) return { outcome: "gone", latencyMs: elapsed() }; + const detail = await errorDetail(response); + // 409 is the stale-runtime conflict seen in the incident; 5xx is a provider + // that reached for the VM and could not get an answer. 401, 403 and 429 say + // nothing about the VM, so they are probe errors, not hangs. + if (response.status === 409 || response.status >= 500) return { outcome: "hung", latencyMs: elapsed(), detail }; + return { outcome: "error", latencyMs: elapsed(), detail }; +} + +function errorName(error: unknown): string { + return error instanceof Error ? error.name : "fetch_failed"; +} + +async function errorDetail(response: Response): Promise { + const body: unknown = await response.json().catch(() => null); + const record = body && typeof body === "object" ? body as Record : {}; + const code = typeof record.code === "string" ? record.code : ""; + const message = typeof record.message === "string" ? record.message : ""; + return sanitizeDetail([`${response.status}`, code, message].filter(Boolean).join(" ")); +} + +function finiteOrNull(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/web/services/vms/drivers/types.ts b/web/services/vms/drivers/types.ts index 501435862736..fb17f622d282 100644 --- a/web/services/vms/drivers/types.ts +++ b/web/services/vms/drivers/types.ts @@ -585,6 +585,11 @@ export interface VMProvider { /** Optional provider volume inventory used by the report-only VM reaper. */ listVolumes?(options?: VMVolumeListOptions): Promise; + /** + * Optional account-wide, read-only inventory for the staff health sweep. It + * covers every VM and TLS rule on the provider account, not only cmux rows. + */ + readonly healthInventory?: VmHealthInventory; getStatus?(vmId: string, options?: ProviderReadOptions): Promise; /** @@ -868,3 +873,14 @@ export class ProviderArtifactUnavailableError extends ProviderError { this.name = "ProviderArtifactUnavailableError"; } } + +/** + * Read-only account inventory for the staff Cloud VM health sweep. No member + * may start, pause, update, or delete anything. + */ +export type VmHealthInventory = { + listAccountVms(options: { readonly deadlineAtMs: number; readonly signal?: AbortSignal }): Promise; + listAccountTlsRules(options: { readonly deadlineAtMs: number; readonly signal?: AbortSignal }): Promise; + /** One bounded control-path request against a running VM; never wakes a paused one on purpose. */ + probeControl(vmId: string, options: { readonly timeoutMs: number; readonly signal?: AbortSignal }): Promise; +}; diff --git a/web/services/vms/health/classify.ts b/web/services/vms/health/classify.ts new file mode 100644 index 000000000000..ffe8ae68116c --- /dev/null +++ b/web/services/vms/health/classify.ts @@ -0,0 +1,436 @@ +import { DEFAULT_FREESTYLE_TLS_RULE_LIMIT } from "../../observability/providerRuleCapacity"; +import type { + DriftVm, + HungVm, + PausedRuleHolder, + ProviderInventoryVm, + ProviderTlsRuleInventory, + ProviderTlsRuleRef, + ProviderVmInventory, + StuckVm, + TlsBudget, + TlsRuleOwner, + VmAttentionReason, + VmHealthCoverage, + VmHealthDbRow, + VmHealthOwner, + VmHealthReport, + VmHealthSweepError, + VmProbeRecord, + VmTransitionSighting, + VmWorkload, +} from "./types"; + +/** Provider states that are moving between two resting states. */ +const PROVIDER_TRANSITION_STATES: ReadonlySet = new Set(["starting", "pausing"]); +/** DB statuses that claim a live provider VM. */ +const DB_LIVE_STATUSES: ReadonlySet = new Set(["running", "paused"]); +const MINUTE_MS = 60_000; +const DAY_MS = 24 * 60 * MINUTE_MS; +/** Every list is capped so one bad incident cannot make a snapshot unbounded. */ +export const VM_HEALTH_LIST_CAP = 500; +export const VM_HEALTH_PAUSED_HOLDER_CAP = 50; +export const VM_HEALTH_TRANSITION_CAP = 2_000; +const TLS_WARNING_FRACTION = 0.85; + +export type VmHealthThresholds = { + /** A provider transition or DB provisioning row older than this is stuck. */ + readonly stuckAfterMs: number; + /** Drift younger than this is an in-flight lifecycle call, not drift. */ + readonly driftGraceMs: number; + /** A VM paused at least this long counts as holding idle TLS rules. */ + readonly pausedRuleHolderAfterMs: number; + readonly tlsRuleLimit: number; +}; + +export const DEFAULT_VM_HEALTH_THRESHOLDS: VmHealthThresholds = { + stuckAfterMs: 15 * MINUTE_MS, + driftGraceMs: 10 * MINUTE_MS, + pausedRuleHolderAfterMs: 7 * DAY_MS, + tlsRuleLimit: DEFAULT_FREESTYLE_TLS_RULE_LIMIT, +}; + +export type VmHealthClassifierInput = { + readonly now: Date; + readonly startedAt: Date; + readonly inventory: ProviderVmInventory | null; + readonly tlsRules: ProviderTlsRuleInventory | null; + readonly dbRows: readonly VmHealthDbRow[]; + readonly probes: readonly VmProbeRecord[]; + /** The previous report's hung VMs, so first-seen time survives across runs. */ + readonly previousHung: readonly Pick[]; + /** The previous report's transition sightings (absent on reports written before they existed). */ + readonly previousTransitions?: readonly VmTransitionSighting[]; + readonly errors: readonly VmHealthSweepError[]; + readonly thresholds?: Partial; +}; + +/** The workload a provider VM belongs to on the shared account. */ +export function vmWorkload(metadata: Readonly>): VmWorkload { + if (metadata.cmux === "cloud") return "cmux_cloud"; + const app = metadata.app?.trim(); + return app ? `app:${app.slice(0, 63)}` : "other"; +} + +/** Pure: turn one sweep's observations into the stored report. */ +export function classifyVmHealth(input: VmHealthClassifierInput): VmHealthReport { + const thresholds = { ...DEFAULT_VM_HEALTH_THRESHOLDS, ...input.thresholds }; + const vms = input.inventory?.vms ?? []; + const vmById = new Map(vms.map((vm) => [vm.id, vm])); + const rowByProviderId = indexDbRows(input.dbRows); + const ownerOf = (vmId: string) => ownerFromRow(rowByProviderId.get(vmId)); + + const hung = classifyHung(input, vmById, rowByProviderId); + const transitions = transitionSightings(input.now, vms, input.previousTransitions ?? []); + const stuck = classifyStuck(input.now, transitions, vms, input.dbRows, thresholds, ownerOf); + const drift = input.inventory ? classifyDrift(input.now, input.inventory, input.dbRows, rowByProviderId, thresholds) : []; + const tls = input.tlsRules ? classifyTlsBudget(input.now, input.tlsRules, vms, thresholds, ownerOf) : null; + const coverage = probeCoverage(input.inventory, input.probes); + const reasons = attentionReasons({ hung, stuck, drift, tls, coverage, errors: input.errors }); + + return { + version: 1, + generatedAt: input.now.toISOString(), + durationMs: Math.max(0, input.now.getTime() - input.startedAt.getTime()), + stateCounts: countBy(vms, (vm) => vm.state), + workloadCounts: countBy(vms, (vm) => vmWorkload(vm.metadata)), + coverage, + hung: hung.slice(0, VM_HEALTH_LIST_CAP), + stuck: stuck.slice(0, VM_HEALTH_LIST_CAP), + transitions: transitions.slice(0, VM_HEALTH_TRANSITION_CAP), + drift: drift.slice(0, VM_HEALTH_LIST_CAP), + tls, + errors: input.errors, + attention: { needed: reasons.some(isAttentionReason), reasons }, + }; +} + +function indexDbRows(rows: readonly VmHealthDbRow[]): Map { + const byProviderId = new Map(); + for (const row of rows) { + const existing = byProviderId.get(row.providerVmId); + // A provider id can appear on a destroyed row and a live one (a reused + // id after restore); the live row is the owner that matters. + if (!existing || (existing.status === "destroyed" && row.status !== "destroyed")) { + byProviderId.set(row.providerVmId, row); + } + } + return byProviderId; +} + +function ownerFromRow(row: VmHealthDbRow | undefined): VmHealthOwner | null { + if (!row) return null; + return { cloudVmId: row.id, userId: row.userId, teamId: row.ownerTeamId || null }; +} + +function classifyHung( + input: VmHealthClassifierInput, + vmById: ReadonlyMap, + rowByProviderId: ReadonlyMap, +): HungVm[] { + const firstSeen = new Map(input.previousHung.map((entry) => [entry.vmId, entry.firstSeenHungAt])); + const probeByVm = new Map(input.probes.map((probe) => [probe.vmId, probe])); + const hung: HungVm[] = []; + for (const vm of vmById.values()) { + const detail = hungDetail(probeByVm.get(vm.id), firstSeen.has(vm.id)); + if (detail === null) continue; + const row = rowByProviderId.get(vm.id); + hung.push({ + vmId: vm.id, + workload: vmWorkload(vm.metadata), + owner: ownerFromRow(row), + snapshot: vm.snapshot, + size: { cpu: vm.cpu, memoryMib: vm.memoryMib, storageMib: vm.storageMib }, + firstSeenHungAt: firstSeen.get(vm.id) ?? input.now.toISOString(), + providerUpdatedAt: vm.updatedAt, + dbUpdatedAt: row ? row.updatedAt.toISOString() : null, + detail, + }); + } + // Longest-hung first: those are the ones an operator should look at. + return hung.sort((a, b) => a.firstSeenHungAt.localeCompare(b.firstSeenHungAt) || a.vmId.localeCompare(b.vmId)); +} + +/** + * Why a VM counts as hung this sweep, or null. A VM hung last sweep that this + * sweep could not re-probe (time budget, probe error) stays hung with its + * first-seen time; only an answer, a pause, or its deletion clears it. + */ +function hungDetail(probe: VmProbeRecord | undefined, wasHung: boolean): string | null { + if (probe?.outcome === "hung") return probe.detail; + if (!wasHung) return null; + if (probe?.outcome === "skipped_budget") return "not re-probed this sweep (time budget)"; + if (probe?.outcome === "error") return `not re-probed this sweep (${probe.detail})`; + return null; +} + +/** + * When each VM entered its current transition, by the first sweep that saw + * it there. Provider timestamps are not used: Freestyle refreshes a live VM's + * `updatedAt` with activity and may not move it when a transition starts, so + * it could make a fresh transition look days old. Detection lags by at most + * one sweep. + */ +function transitionSightings( + now: Date, + vms: readonly ProviderInventoryVm[], + previous: readonly VmTransitionSighting[], +): VmTransitionSighting[] { + const seen = new Map(previous.map((entry) => [`${entry.vmId}:${entry.state}`, entry.firstSeenAt])); + const sightings: VmTransitionSighting[] = []; + for (const vm of vms) { + if (!PROVIDER_TRANSITION_STATES.has(vm.state)) continue; + sightings.push({ vmId: vm.id, state: vm.state, firstSeenAt: seen.get(`${vm.id}:${vm.state}`) ?? now.toISOString() }); + } + return sightings; +} + +function classifyStuck( + now: Date, + transitions: readonly VmTransitionSighting[], + vms: readonly ProviderInventoryVm[], + dbRows: readonly VmHealthDbRow[], + thresholds: VmHealthThresholds, + ownerOf: (vmId: string) => VmHealthOwner | null, +): StuckVm[] { + const vmById = new Map(vms.map((vm) => [vm.id, vm])); + const stuck: StuckVm[] = []; + for (const sighting of transitions) { + const ageMs = now.getTime() - Date.parse(sighting.firstSeenAt); + if (ageMs < thresholds.stuckAfterMs) continue; + stuck.push({ + vmId: sighting.vmId, + source: "provider", + state: sighting.state, + workload: vmWorkload(vmById.get(sighting.vmId)?.metadata ?? {}), + owner: ownerOf(sighting.vmId), + since: sighting.firstSeenAt, + minutes: Math.floor(ageMs / MINUTE_MS), + }); + } + for (const row of dbRows) { + if (row.status !== "provisioning") continue; + const ageMs = now.getTime() - row.updatedAt.getTime(); + if (ageMs < thresholds.stuckAfterMs) continue; + stuck.push({ + vmId: row.providerVmId, + source: "db", + state: row.status, + workload: "cmux_cloud", + owner: ownerFromRow(row), + since: row.updatedAt.toISOString(), + minutes: Math.floor(ageMs / MINUTE_MS), + }); + } + return stuck.sort((a, b) => b.minutes - a.minutes || a.vmId.localeCompare(b.vmId)); +} + +/** + * DB and provider disagree. Only cmux Cloud machines are compared: other + * workloads on the account have no DB rows by design. Recent DB changes and + * new provider VMs are left alone because a lifecycle call may be between its + * provider call and its row update. The reconcile cron owns repair; this only + * reports. + */ +function classifyDrift( + now: Date, + inventory: ProviderVmInventory, + dbRows: readonly VmHealthDbRow[], + rowByProviderId: ReadonlyMap, + thresholds: VmHealthThresholds, +): DriftVm[] { + const vmById = new Map(inventory.vms.map((vm) => [vm.id, vm])); + const drift: DriftVm[] = []; + for (const row of dbRows) { + if (!DB_LIVE_STATUSES.has(row.status)) continue; + if (now.getTime() - row.updatedAt.getTime() < thresholds.driftGraceMs) continue; + const vm = vmById.get(row.providerVmId); + const kind = vm ? liveRowDrift(row.status, vm.state) : inventory.complete ? "provider_missing" : null; + // A resting VM's `updatedAt` is when it came to rest, so a pause that just + // landed is still in flight. A running VM's moves with activity: ignore it. + if (!kind || (vm && vm.state !== "running" && isRecent(now, vm.updatedAt, thresholds.driftGraceMs))) continue; + drift.push(driftEntry(row, vm, kind)); + } + for (const vm of inventory.vms) { + if (vmWorkload(vm.metadata) !== "cmux_cloud" || vm.state === "stopped") continue; + if (isRecent(now, vm.createdAt, thresholds.driftGraceMs)) continue; + const row = rowByProviderId.get(vm.id); + if (row && row.status !== "destroyed") continue; + if (row && now.getTime() - row.updatedAt.getTime() < thresholds.driftGraceMs) continue; + drift.push(driftEntry(row, vm, row ? "db_destroyed_provider_present" : "provider_untracked")); + } + return drift.sort((a, b) => a.kind.localeCompare(b.kind) || a.vmId.localeCompare(b.vmId)); +} + +function liveRowDrift(dbStatus: string, providerState: string): DriftVm["kind"] | null { + const providerResting = providerState === "paused" || providerState === "stopped"; + if (dbStatus === "running" && providerResting) return "db_running_provider_paused"; + if (dbStatus === "paused" && providerState === "running") return "db_paused_provider_running"; + return null; +} + +function driftEntry(row: VmHealthDbRow | undefined, vm: ProviderInventoryVm | undefined, kind: DriftVm["kind"]): DriftVm { + return { + vmId: vm?.id ?? row?.providerVmId ?? "", + kind, + dbStatus: row?.status ?? null, + providerState: vm?.state ?? null, + owner: ownerFromRow(row), + dbUpdatedAt: row ? row.updatedAt.toISOString() : null, + providerUpdatedAt: vm?.updatedAt ?? null, + }; +} + +function classifyTlsBudget( + now: Date, + inventory: ProviderTlsRuleInventory, + vms: readonly ProviderInventoryVm[], + thresholds: VmHealthThresholds, + ownerOf: (vmId: string) => VmHealthOwner | null, +): TlsBudget { + const vmById = new Map(vms.map((vm) => [vm.id, vm])); + const vpcWorkload = new Map(); + for (const vm of vms) { + for (const vpcId of vm.vpcIds) if (!vpcWorkload.has(vpcId)) vpcWorkload.set(vpcId, vmWorkload(vm.metadata)); + } + const byOwner: Record = {}; + const pausedRules = new Map(); + for (const rule of inventory.rules) { + const owner = tlsRuleOwner(rule, vmById, vpcWorkload); + byOwner[owner] = (byOwner[owner] ?? 0) + 1; + const holder = pausedHolderOf(now, rule, vmById, thresholds.pausedRuleHolderAfterMs); + if (holder) pausedRules.set(holder, (pausedRules.get(holder) ?? 0) + 1); + } + const holders: PausedRuleHolder[] = [...pausedRules].map(([vmId, rules]) => { + const vm = vmById.get(vmId)!; + return { vmId, workload: vmWorkload(vm.metadata), owner: ownerOf(vmId), pausedSince: vm.updatedAt, rules }; + }); + holders.sort((a, b) => b.rules - a.rules || a.pausedSince.localeCompare(b.pausedSince)); + return { + totalCount: inventory.totalCount, + limit: thresholds.tlsRuleLimit, + listed: inventory.rules.length, + complete: inventory.complete, + byOwner: byOwner as Record, + pausedOverDays: Math.round(thresholds.pausedRuleHolderAfterMs / DAY_MS), + pausedHolders: { + vmCount: holders.length, + ruleCount: holders.reduce((sum, holder) => sum + holder.rules, 0), + top: holders.slice(0, VM_HEALTH_PAUSED_HOLDER_CAP), + }, + }; +} + +/** Who a rule's slot is charged to: the VM it dies with, else its network, else the account. */ +export function tlsRuleOwner( + rule: ProviderTlsRuleRef, + vmById: ReadonlyMap, + vpcWorkload: ReadonlyMap, +): TlsRuleOwner { + if (rule.managed) return "managed"; + if (rule.vmIds.length > 0) { + const vm = rule.vmIds.map((id) => vmById.get(id)).find((candidate) => candidate !== undefined); + return vm ? vmWorkload(vm.metadata) : "unknown_vm"; + } + if (rule.vpcIds.length > 0) { + const workload = rule.vpcIds.map((id) => vpcWorkload.get(id)).find((candidate) => candidate !== undefined); + return workload ?? "vpc_unattributed"; + } + return "account_level"; +} + +function pausedHolderOf( + now: Date, + rule: ProviderTlsRuleRef, + vmById: ReadonlyMap, + pausedAfterMs: number, +): string | null { + if (rule.managed) return null; + for (const vmId of rule.vmIds) { + const vm = vmById.get(vmId); + if (!vm || (vm.state !== "paused" && vm.state !== "stopped")) continue; + const ageMs = ageSince(now, vm.updatedAt); + if (ageMs !== null && ageMs >= pausedAfterMs) return vm.id; + } + return null; +} + +function probeCoverage(inventory: ProviderVmInventory | null, probes: readonly VmProbeRecord[]): VmHealthCoverage { + const counts: Partial> = {}; + for (const probe of probes) counts[probe.outcome] = (counts[probe.outcome] ?? 0) + 1; + const skippedBudget = counts.skipped_budget ?? 0; + const skippedIdle = counts.skipped_idle ?? 0; + const skippedNotRunning = counts.not_running ?? 0; + return { + inventoryComplete: inventory?.complete ?? false, + running: inventory?.vms.filter((vm) => vm.state === "running").length ?? 0, + probed: probes.length - skippedBudget - skippedIdle - skippedNotRunning, + probeErrors: counts.error ?? 0, + skippedBudget, + skippedIdle, + skippedNotRunning, + }; +} + +/** + * Probe errors (401, 429, a refused state read) say nothing about VMs. A few + * are noise from a busy shared account; past this share of running VMs the + * sweep cannot vouch for the fleet. + */ +const PROBE_ERROR_ATTENTION_FRACTION = 0.05; + +function unprobedForAttention(coverage: VmHealthCoverage): number { + const errorsMatter = coverage.probeErrors > Math.max(2, coverage.running * PROBE_ERROR_ATTENTION_FRACTION); + return coverage.skippedBudget + (errorsMatter ? coverage.probeErrors : 0); +} + +function attentionReasons(input: { + readonly hung: readonly HungVm[]; + readonly stuck: readonly StuckVm[]; + readonly drift: readonly DriftVm[]; + readonly tls: TlsBudget | null; + readonly coverage: VmHealthCoverage; + readonly errors: readonly VmHealthSweepError[]; +}): VmAttentionReason[] { + const reasons: VmAttentionReason[] = []; + if (input.hung.length > 0) reasons.push({ code: "hung_vms", count: input.hung.length }); + if (input.stuck.length > 0) reasons.push({ code: "stuck_transitions", count: input.stuck.length }); + // A cmux-tagged VM with no row here may belong to another environment on + // the shared account; it is listed, but does not by itself page anyone. + const drift = input.drift.filter((entry) => entry.kind !== "provider_untracked").length; + if (drift > 0) reasons.push({ code: "drift", count: drift }); + const tls = input.tls; + if (tls && tls.totalCount >= tls.limit) { + reasons.push({ code: "tls_over_limit", count: tls.totalCount, limit: tls.limit }); + } else if (tls && tls.totalCount >= tls.limit * TLS_WARNING_FRACTION) { + reasons.push({ code: "tls_near_limit", count: tls.totalCount, limit: tls.limit }); + } + const unprobed = unprobedForAttention(input.coverage); + if (unprobed > 0 || !input.coverage.inventoryComplete) reasons.push({ code: "sweep_incomplete", unprobed }); + if (input.errors.length > 0) reasons.push({ code: "sweep_errors", count: input.errors.length }); + return reasons; +} + +/** A near-limit TLS budget is a warning on the page; it does not by itself need an operator. */ +function isAttentionReason(reason: VmAttentionReason): boolean { + return reason.code !== "tls_near_limit"; +} + +function countBy(items: readonly T[], key: (item: T) => string): Record { + const counts: Record = {}; + for (const item of items) { + const value = key(item); + counts[value] = (counts[value] ?? 0) + 1; + } + return counts; +} + +function ageSince(now: Date, iso: string): number | null { + const time = Date.parse(iso); + return Number.isFinite(time) ? now.getTime() - time : null; +} + +function isRecent(now: Date, iso: string, windowMs: number): boolean { + const age = ageSince(now, iso); + return age !== null && age < windowMs; +} diff --git a/web/services/vms/health/cron.ts b/web/services/vms/health/cron.ts new file mode 100644 index 000000000000..bd2d54c0bd45 --- /dev/null +++ b/web/services/vms/health/cron.ts @@ -0,0 +1,72 @@ +import { authorizeCronRequest } from "../../cronAuth"; +import type { VmHealthAlertOutcome } from "../../observability/vmHealthAlerts"; +import type { VmHealthReport } from "./types"; +import type { VmHealthSweepOptions, VmHealthSweepResult } from "./sweep"; + +/** Staff page for the alert link; the dashboard route that reads the stored snapshot. */ +export const VM_HEALTH_PAGE_PATH = "/dashboard/ops/cloud-vms"; + +export type VmHealthCronDeps = { + readonly sweep: (options: VmHealthSweepOptions) => Promise; + readonly alert: (report: VmHealthReport, pageUrl: string | null) => Promise; + readonly env: Record; +}; + +/** + * GET /api/cron/vm-health. Cron-secret gated; runs one read-only sweep inside + * the route's time budget, stores it, and hands the result to the alert path. + * The response carries counts only, never VM owners. + */ +export async function handleVmHealthCron(request: Request, deps: VmHealthCronDeps): Promise { + const auth = authorizeCronRequest(request); + if (!auth.ok) { + return Response.json({ error: auth.reason === "cron_secret_missing" ? "cron_not_configured" : "unauthorized" }, { + status: auth.reason === "cron_secret_missing" ? 503 : 401, + }); + } + const result = await deps.sweep(sweepOptionsFromEnv(deps.env)); + const origin = deps.env.CMUX_PUBLIC_ORIGIN?.trim() || new URL(request.url).origin; + const alert = await deps.alert(result.report, `${origin}${VM_HEALTH_PAGE_PATH}`); + const { report } = result; + // A failed store still alerts on what the sweep found, then answers 500 so + // the cron monitor shows the page is going stale. + return Response.json({ + ok: result.storeError === null, + snapshotId: result.snapshotId, + storeError: result.storeError, + durationMs: report.durationMs, + attentionNeeded: report.attention.needed, + reasons: report.attention.reasons.map((reason) => reason.code), + hung: report.hung.length, + stuck: report.stuck.length, + drift: report.drift.length, + tlsRules: report.tls?.totalCount ?? null, + coverage: report.coverage, + errors: report.errors.map((error) => error.stage), + pruned: result.pruned, + alert, + }, { status: result.storeError === null ? 200 : 500 }); +} + +/** Operator overrides; anything unparseable keeps the default. */ +export function sweepOptionsFromEnv(env: Record): VmHealthSweepOptions { + const budgetMs = positiveInt(env.CMUX_VM_HEALTH_BUDGET_MS); + const probeTimeoutMs = positiveInt(env.CMUX_VM_HEALTH_PROBE_TIMEOUT_MS); + const probeConcurrency = positiveInt(env.CMUX_VM_HEALTH_PROBE_CONCURRENCY); + const tlsRuleLimit = positiveInt(env.FREESTYLE_TLS_RULE_LIMIT); + return { + // The route's maxDuration is 300s; keep the sweep well inside it. + ...(budgetMs ? { budgetMs: Math.min(budgetMs, 240_000) } : {}), + ...(probeTimeoutMs ? { probeTimeoutMs: Math.min(probeTimeoutMs, 30_000) } : {}), + ...(probeConcurrency ? { probeConcurrency: Math.min(probeConcurrency, 64) } : {}), + probeIdleVms: env.CMUX_VM_HEALTH_PROBE_IDLE_VMS === "1", + ...(tlsRuleLimit ? { thresholds: { tlsRuleLimit } } : {}), + }; +} + +function positiveInt(value: string | undefined): number | null { + const trimmed = value?.trim(); + if (!trimmed || !/^[0-9]+$/.test(trimmed)) return null; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} diff --git a/web/services/vms/health/repository.ts b/web/services/vms/health/repository.ts new file mode 100644 index 000000000000..d740e8f3a193 --- /dev/null +++ b/web/services/vms/health/repository.ts @@ -0,0 +1,85 @@ +import { and, desc, eq, inArray, isNotNull, lt, ne } from "drizzle-orm"; +import * as Effect from "effect/Effect"; +import { cloudDb } from "../../../db/client"; +import { cloudVmHealthSnapshots, cloudVms } from "../../../db/schema"; +import { VmDatabaseError } from "../errors"; +import type { StoredVmHealthReport, VmHealthDbRow, VmHealthReport } from "./types"; + +/** Postgres side of the health sweep. Reads cmux rows; writes only its own snapshot table. */ +export type VmHealthRepositoryMethods = { + /** Every live row of `provider`, plus any row (destroyed included) naming one of `providerVmIds`. */ + readonly vmHealthDbRows: (input: { + readonly provider: string; + readonly providerVmIds: readonly string[]; + }) => Effect.Effect; + readonly latestVmHealthSnapshot: () => Effect.Effect; + readonly insertVmHealthSnapshot: (report: VmHealthReport) => Effect.Effect<{ readonly id: string }, VmDatabaseError>; + readonly pruneVmHealthSnapshots: (input: { readonly before: Date }) => Effect.Effect; +}; + +const ID_CHUNK = 1_000; + +function dbEffect(operation: string, run: () => Promise): Effect.Effect { + return Effect.tryPromise({ try: run, catch: (cause) => new VmDatabaseError({ operation, cause }) }); +} + +const healthRowColumns = { + id: cloudVms.id, + providerVmId: cloudVms.providerVmId, + userId: cloudVms.userId, + ownerTeamId: cloudVms.ownerTeamId, + status: cloudVms.status, + imageId: cloudVms.imageId, + createdAt: cloudVms.createdAt, + updatedAt: cloudVms.updatedAt, +}; + +export const vmHealthRepositoryMethods: VmHealthRepositoryMethods = { + vmHealthDbRows: (input) => + dbEffect("vmHealthDbRows", async () => { + const db = cloudDb(); + const provider = input.provider as typeof cloudVms.$inferSelect.provider; + const live = await db + .select(healthRowColumns) + .from(cloudVms) + .where(and(eq(cloudVms.provider, provider), isNotNull(cloudVms.providerVmId), ne(cloudVms.status, "destroyed"))); + const rows = [...live]; + const ids = [...new Set(input.providerVmIds)]; + for (let start = 0; start < ids.length; start += ID_CHUNK) { + const chunk = ids.slice(start, start + ID_CHUNK); + rows.push(...await db + .select(healthRowColumns) + .from(cloudVms) + .where(and(eq(cloudVms.provider, provider), eq(cloudVms.status, "destroyed"), inArray(cloudVms.providerVmId, chunk)))); + } + return rows.flatMap((row): VmHealthDbRow[] => row.providerVmId ? [{ ...row, providerVmId: row.providerVmId }] : []); + }), + + latestVmHealthSnapshot: () => + dbEffect("latestVmHealthSnapshot", async () => { + const [row] = await cloudDb() + .select() + .from(cloudVmHealthSnapshots) + .orderBy(desc(cloudVmHealthSnapshots.createdAt)) + .limit(1); + return row ? { id: row.id, createdAt: row.createdAt, report: row.report as VmHealthReport } : null; + }), + + insertVmHealthSnapshot: (report) => + dbEffect("insertVmHealthSnapshot", async () => { + const [row] = await cloudDb() + .insert(cloudVmHealthSnapshots) + .values({ attentionNeeded: report.attention.needed, report, createdAt: new Date(report.generatedAt) }) + .returning({ id: cloudVmHealthSnapshots.id }); + return { id: row!.id }; + }), + + pruneVmHealthSnapshots: (input) => + dbEffect("pruneVmHealthSnapshots", async () => { + const rows = await cloudDb() + .delete(cloudVmHealthSnapshots) + .where(lt(cloudVmHealthSnapshots.createdAt, input.before)) + .returning({ id: cloudVmHealthSnapshots.id }); + return rows.length; + }), +}; diff --git a/web/services/vms/health/sanitize.ts b/web/services/vms/health/sanitize.ts new file mode 100644 index 000000000000..adb5d56fbcaa --- /dev/null +++ b/web/services/vms/health/sanitize.ts @@ -0,0 +1,14 @@ +const DETAIL_MAX_LENGTH = 200; + +/** + * Provider or database prose made safe to store in a snapshot and show to + * staff: bounded, whitespace-collapsed, with anything token-shaped removed. + */ +export function sanitizeDetail(text: string): string { + return text + .replace(/\b(bearer|token|key|secret|password)[=:\s]+\S+/gi, "$1=[redacted]") + .replace(/\b[A-Za-z0-9_-]{32,}\b/g, "[redacted]") + .replace(/\s+/g, " ") + .trim() + .slice(0, DETAIL_MAX_LENGTH); +} diff --git a/web/services/vms/health/staffView.ts b/web/services/vms/health/staffView.ts new file mode 100644 index 000000000000..2d6c4e024d44 --- /dev/null +++ b/web/services/vms/health/staffView.ts @@ -0,0 +1,92 @@ +import * as Effect from "effect/Effect"; +import type { StoredVmHealthReport, VmHealthReport } from "./types"; +import { vmHealthRepositoryMethods } from "./repository"; + +/** A snapshot older than this means the cron stopped; the page says so. */ +export const VM_HEALTH_STALE_AFTER_MS = 15 * 60 * 1000; +/** Owner email lookups per page read; the rest show their user id. */ +export const VM_HEALTH_OWNER_LOOKUP_LIMIT = 60; +const OWNER_LOOKUP_CONCURRENCY = 5; +const OWNER_LOOKUP_TIMEOUT_MS = 3_000; + +export type VmHealthOwnerContact = { + readonly email: string | null; + readonly displayName: string | null; +}; + +export type VmHealthStaffView = { + readonly snapshot: { + readonly id: string; + readonly createdAt: string; + readonly ageSeconds: number; + readonly stale: boolean; + } | null; + readonly report: VmHealthReport | null; + /** Keyed by cmux user id. Only staff ever receive this map. */ + readonly owners: Readonly>; +}; + +export type VmHealthStaffViewDeps = { + readonly latest: () => Promise; + readonly lookupOwner: (userId: string) => Promise; + readonly now: () => Date; +}; + +/** + * The staff page's read: the newest stored sweep and owner contacts for the + * VMs it flags. Never probes the provider. Callers must have passed the admin + * gate; this function does not check authorization itself. + */ +export async function readVmHealthStaffView(deps: VmHealthStaffViewDeps): Promise { + const stored = await deps.latest(); + if (!stored) return { snapshot: null, report: null, owners: {} }; + const ageMs = Math.max(0, deps.now().getTime() - stored.createdAt.getTime()); + const userIds = flaggedOwnerIds(stored.report).slice(0, VM_HEALTH_OWNER_LOOKUP_LIMIT); + const owners: Record = {}; + await mapBounded(userIds, OWNER_LOOKUP_CONCURRENCY, async (userId) => { + const contact = await withTimeout(deps.lookupOwner(userId), OWNER_LOOKUP_TIMEOUT_MS).catch(() => null); + if (contact) owners[userId] = contact; + }); + return { + snapshot: { + id: stored.id, + createdAt: stored.createdAt.toISOString(), + ageSeconds: Math.floor(ageMs / 1000), + stale: ageMs > VM_HEALTH_STALE_AFTER_MS, + }, + report: stored.report, + owners, + }; +} + +/** Owners of flagged VMs, most urgent list first, deduplicated. */ +export function flaggedOwnerIds(report: VmHealthReport): string[] { + const ids = new Set(); + for (const entry of [...report.hung, ...report.stuck, ...report.drift, ...(report.tls?.pausedHolders.top ?? [])]) { + if (entry.owner?.userId) ids.add(entry.owner.userId); + } + return [...ids]; +} + +export function latestStoredVmHealthReport(): Promise { + return Effect.runPromise(vmHealthRepositoryMethods.latestVmHealthSnapshot()); +} + +async function mapBounded(items: readonly T[], concurrency: number, run: (item: T) => Promise): Promise { + let next = 0; + const worker = async () => { + while (next < items.length) { + const item = items[next++]!; + await run(item); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); +} + +function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} diff --git a/web/services/vms/health/sweep.ts b/web/services/vms/health/sweep.ts new file mode 100644 index 000000000000..30ec42b755fb --- /dev/null +++ b/web/services/vms/health/sweep.ts @@ -0,0 +1,202 @@ +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import type { ProviderId } from "../drivers"; +import type { VmWorkflowError } from "../errors"; +import { VmProviderGateway } from "../providerGateway"; +import { VmRepository, type VmRepositoryShape } from "../repository"; +import { sanitizeDetail } from "./sanitize"; +import { classifyVmHealth, vmWorkload, type VmHealthThresholds } from "./classify"; +import type { + ProviderInventoryVm, + VmHealthReport, + VmHealthSweepError, + VmProbeRecord, +} from "./types"; + +/** + * The staff Cloud VM health sweep. Read-only toward the provider: it lists + * VMs and TLS rules and sends one bounded `fs/stat` per running VM. It never + * pauses, stops, deletes, or edits a VM or a rule, and it does not repair DB + * drift (the reconcile cron owns repair). Its only write is its own snapshot. + */ +export const VM_HEALTH_PROVIDER: ProviderId = "freestyle"; +export const VM_HEALTH_DEFAULT_BUDGET_MS = 200_000; +export const VM_HEALTH_DEFAULT_PROBE_TIMEOUT_MS = 10_000; +export const VM_HEALTH_DEFAULT_PROBE_CONCURRENCY = 16; +export const VM_HEALTH_SNAPSHOT_RETENTION_MS = 3 * 24 * 60 * 60 * 1000; +const PROBE_GUARD_SLACK_MS = 2_000; + +export type VmHealthSweepOptions = { + /** Wall-clock budget for the whole sweep; no probe starts once it cannot finish inside it. */ + readonly budgetMs?: number; + readonly probeTimeoutMs?: number; + readonly probeConcurrency?: number; + /** + * Probe VMs that have a finite idle timeout. Off by default: a control-path + * request every five minutes could count as activity and keep a VM from + * idling out, which would change another workload's behavior. + */ + readonly probeIdleVms?: boolean; + readonly thresholds?: Partial; +}; + +export type VmHealthSweepResult = { + readonly snapshotId: string | null; + /** Why the snapshot was not stored, when the insert failed. */ + readonly storeError: string | null; + readonly report: VmHealthReport; + readonly pruned: number; +}; + +export function sweepVmHealth( + options: VmHealthSweepOptions = {}, +): Effect.Effect { + return Effect.gen(function* () { + const repo = yield* VmRepository; + const providers = yield* VmProviderGateway; + const startedAtMs = yield* Clock.currentTimeMillis; + const deadlineAtMs = startedAtMs + (options.budgetMs ?? VM_HEALTH_DEFAULT_BUDGET_MS); + const errors: VmHealthSweepError[] = []; + const step = (stage: VmHealthSweepError["stage"], run: (() => Effect.Effect) | undefined) => + run ? recordFailure(run(), stage, errors) : Effect.succeed(null); + const bounded = (stage: VmHealthSweepError["stage"], run: (() => Effect.Effect) | undefined) => + step(stage, run && (() => untilDeadline(run(), deadlineAtMs))); + + const previous = yield* step("previous_snapshot", repo.latestVmHealthSnapshot); + const inventory = yield* bounded("inventory", providers.listAccountVms && (() => providers.listAccountVms!(VM_HEALTH_PROVIDER, { deadlineAtMs }))); + const tlsRules = yield* bounded("tls_rules", providers.listAccountTlsRules && (() => providers.listAccountTlsRules!(VM_HEALTH_PROVIDER, { deadlineAtMs }))); + const cmuxIds = (inventory?.vms ?? []).filter((vm) => vmWorkload(vm.metadata) === "cmux_cloud").map((vm) => vm.id); + const dbRows = yield* step("db_rows", repo.vmHealthDbRows && (() => repo.vmHealthDbRows!({ provider: VM_HEALTH_PROVIDER, providerVmIds: cmuxIds }))); + + const probes = yield* probeRunningVms(inventory?.vms ?? [], { + deadlineAtMs, + probeTimeoutMs: options.probeTimeoutMs ?? VM_HEALTH_DEFAULT_PROBE_TIMEOUT_MS, + concurrency: Math.max(1, options.probeConcurrency ?? VM_HEALTH_DEFAULT_PROBE_CONCURRENCY), + probeIdleVms: options.probeIdleVms ?? false, + }); + + const nowMs = yield* Clock.currentTimeMillis; + const report = classifyVmHealth({ + now: new Date(nowMs), + startedAt: new Date(startedAtMs), + inventory, + tlsRules, + dbRows: dbRows ?? [], + probes, + previousHung: previous?.report.hung ?? [], + previousTransitions: previous?.report.transitions ?? [], + errors, + thresholds: options.thresholds, + }); + return { report, ...yield* storeReport(repo, report, nowMs) }; + }); +} + +/** Run one optional sweep stage; a failure is recorded on the report and the sweep goes on. */ +function recordFailure( + effect: Effect.Effect, + stage: VmHealthSweepError["stage"], + errors: VmHealthSweepError[], +): Effect.Effect { + return effect.pipe(Effect.catchAll((cause) => Effect.sync(() => { + errors.push({ stage, message: errorMessage(cause) }); + return null; + }))); +} + +/** + * Bound a provider listing by the sweep deadline. Paging checks the deadline + * only between pages, and one SDK request can poll a backgrounded result for + * a long time; interruption aborts its signal. + */ +function untilDeadline(effect: Effect.Effect, deadlineAtMs: number): Effect.Effect { + return Effect.flatMap(Clock.currentTimeMillis, (nowMs) => effect.pipe(Effect.timeoutFail({ + duration: Math.max(0, deadlineAtMs - nowMs), + onTimeout: () => new Error("listing did not finish inside the sweep budget"), + }))); +} + +/** + * Store the report and prune old ones. A failed insert still returns the + * report, so the cron can alert on what this sweep found. + */ +function storeReport( + repo: VmRepositoryShape, + report: VmHealthReport, + nowMs: number, +): Effect.Effect> { + return Effect.gen(function* () { + const insert = repo.insertVmHealthSnapshot; + const stored = insert + ? yield* insert(report).pipe(Effect.either) + : null; + const pruned = repo.pruneVmHealthSnapshots + ? yield* repo.pruneVmHealthSnapshots({ before: new Date(nowMs - VM_HEALTH_SNAPSHOT_RETENTION_MS) }).pipe( + Effect.catchAll(() => Effect.succeed(0)), + ) + : 0; + if (stored && stored._tag === "Left") return { snapshotId: null, storeError: errorMessage(stored.left), pruned }; + return { snapshotId: stored?.right.id ?? null, storeError: null, pruned }; + }); +} + +/** + * Probe every running VM with bounded concurrency. A probe starts only while + * its full timeout still fits before the deadline; the rest are recorded as + * skipped so the report says coverage was partial instead of calling them + * healthy. + */ +export function probeRunningVms( + vms: readonly ProviderInventoryVm[], + options: { + readonly deadlineAtMs: number; + readonly probeTimeoutMs: number; + readonly concurrency: number; + readonly probeIdleVms: boolean; + }, +): Effect.Effect { + return Effect.gen(function* () { + const providers = yield* VmProviderGateway; + const running = vms.filter((vm) => vm.state === "running"); + const probe = providers.probeVmControl; + const probeOne = (vm: ProviderInventoryVm): Effect.Effect => { + if (!options.probeIdleVms && hasFiniteIdleTimeout(vm)) return Effect.succeed({ vmId: vm.id, outcome: "skipped_idle" }); + return Effect.flatMap(Clock.currentTimeMillis, (nowMs): Effect.Effect => { + if (!probe || nowMs + options.probeTimeoutMs > options.deadlineAtMs) { + return Effect.succeed({ vmId: vm.id, outcome: "skipped_budget" }); + } + return probe(VM_HEALTH_PROVIDER, vm.id, { timeoutMs: options.probeTimeoutMs }).pipe( + // The driver already bounds the request; this guards a fetch that ignores its signal. + Effect.timeoutFail({ + duration: options.probeTimeoutMs + PROBE_GUARD_SLACK_MS, + onTimeout: () => new Error(`timeout after ${options.probeTimeoutMs}ms`), + }), + Effect.map((outcome): VmProbeRecord => ({ vmId: vm.id, ...outcome })), + Effect.catchAll((cause) => Effect.succeed(probeFailure(vm.id, cause, options.probeTimeoutMs))), + ); + }); + }; + return yield* Effect.forEach(running, probeOne, { concurrency: options.concurrency }); + }); +} + +function probeFailure(vmId: string, cause: unknown, timeoutMs: number): VmProbeRecord { + const message = errorMessage(cause); + // Only our own guard timeout means the VM did not answer; anything else is + // a probe that could not be sent and says nothing about the VM. + if (message.startsWith("timeout after")) return { vmId, outcome: "hung", latencyMs: timeoutMs, detail: message }; + return { vmId, outcome: "error", latencyMs: 0, detail: message }; +} + +function hasFiniteIdleTimeout(vm: ProviderInventoryVm): boolean { + return typeof vm.idleTimeoutSeconds === "number" && vm.idleTimeoutSeconds >= 0; +} + +function errorMessage(cause: unknown): string { + const tagged = cause as { readonly _tag?: unknown; readonly operation?: unknown; readonly cause?: unknown; readonly message?: unknown }; + const inner = tagged?.cause instanceof Error ? tagged.cause.message : undefined; + const base = typeof tagged?._tag === "string" + ? `${tagged._tag}${typeof tagged.operation === "string" ? ` ${tagged.operation}` : ""}${inner ? `: ${inner}` : ""}` + : cause instanceof Error ? cause.message : String(cause); + return sanitizeDetail(base); +} diff --git a/web/services/vms/health/types.ts b/web/services/vms/health/types.ts new file mode 100644 index 000000000000..2f0b229f1851 --- /dev/null +++ b/web/services/vms/health/types.ts @@ -0,0 +1,213 @@ +/** + * Shapes for the staff-only Cloud VM health sweep. The sweep reads the whole + * provider account (cmux Cloud machines and every other workload that shares + * it), probes running machines, and stores one report per run. The page only + * ever reads a stored report, so nothing here is computed on a page load. + */ + +/** Provider VM states. Freestyle's set today; unknown future states pass through. */ +export type ProviderVmState = "starting" | "running" | "pausing" | "paused" | "stopped" | (string & {}); + +/** One provider VM as the inventory listing returns it. No secrets, no guest data. */ +export type ProviderInventoryVm = { + readonly id: string; + readonly state: ProviderVmState; + readonly metadata: Readonly>; + /** The snapshot slug it booted from (current name, else the name at create), or its id. */ + readonly snapshot: string | null; + readonly cpu: number | null; + readonly memoryMib: number | null; + readonly storageMib: number | null; + /** Null or negative means the VM never idles out. */ + readonly idleTimeoutSeconds: number | null; + readonly vpcIds: readonly string[]; + readonly createdAt: string; + readonly updatedAt: string; +}; + +export type ProviderVmInventory = { + readonly vms: readonly ProviderInventoryVm[]; + /** False when paging stopped early (page cap, deadline); missing VMs are then unknown, not gone. */ + readonly complete: boolean; +}; + +/** One TLS rule reduced to what attribution needs. Domains and transforms are dropped. */ +export type ProviderTlsRuleRef = { + readonly id: string; + readonly managed: boolean; + readonly vmIds: readonly string[]; + readonly vpcIds: readonly string[]; +}; + +export type ProviderTlsRuleInventory = { + /** The provider's account-wide count, which is what the limit is measured against. */ + readonly totalCount: number; + readonly rules: readonly ProviderTlsRuleRef[]; + readonly complete: boolean; +}; + +/** + * A control-path probe. `hung` means the provider accepted the request but the + * VM did not answer (timeout, 409 stale runtime, 5xx); `error` is a probe that + * says nothing about the VM (401, 429, a network failure before the provider). + */ +export type VmControlProbe = + | { readonly outcome: "ok"; readonly latencyMs: number } + | { readonly outcome: "gone"; readonly latencyMs: number } + /** Paused or moving since the inventory was read; not probed, so not woken. */ + | { readonly outcome: "not_running"; readonly latencyMs: number } + | { readonly outcome: "hung"; readonly latencyMs: number; readonly detail: string } + | { readonly outcome: "error"; readonly latencyMs: number; readonly detail: string }; + +export type VmProbeRecord = + | ({ readonly vmId: string } & VmControlProbe) + | { readonly vmId: string; readonly outcome: "skipped_budget" | "skipped_idle" }; + +/** The cmux DB row the sweep joins to a provider VM. */ +export type VmHealthDbRow = { + readonly id: string; + readonly providerVmId: string; + readonly userId: string; + readonly ownerTeamId: string; + readonly status: string; + readonly imageId: string; + readonly createdAt: Date; + readonly updatedAt: Date; +}; + +/** `cmux_cloud` for metadata `cmux=cloud`, `app:` for metadata `app=`, else `other`. */ +export type VmWorkload = string; + +export type VmHealthOwner = { + readonly cloudVmId: string; + readonly userId: string; + readonly teamId: string | null; +}; + +export type VmHealthSize = { + readonly cpu: number | null; + readonly memoryMib: number | null; + readonly storageMib: number | null; +}; + +export type HungVm = { + readonly vmId: string; + readonly workload: VmWorkload; + readonly owner: VmHealthOwner | null; + readonly snapshot: string | null; + readonly size: VmHealthSize; + readonly firstSeenHungAt: string; + readonly providerUpdatedAt: string; + readonly dbUpdatedAt: string | null; + readonly detail: string; +}; + +export type StuckVm = { + readonly vmId: string; + /** `provider` for a provider transition, `db` for a cmux row stuck provisioning. */ + readonly source: "provider" | "db"; + readonly state: string; + readonly workload: VmWorkload; + readonly owner: VmHealthOwner | null; + readonly since: string; + readonly minutes: number; +}; + +/** A VM seen mid-transition, remembered across sweeps so its age does not depend on provider timestamps. */ +export type VmTransitionSighting = { + readonly vmId: string; + readonly state: string; + readonly firstSeenAt: string; +}; + +export type VmDriftKind = + | "provider_missing" + | "db_running_provider_paused" + | "db_paused_provider_running" + | "db_destroyed_provider_present" + | "provider_untracked"; + +export type DriftVm = { + readonly vmId: string; + readonly kind: VmDriftKind; + readonly dbStatus: string | null; + readonly providerState: string | null; + readonly owner: VmHealthOwner | null; + readonly dbUpdatedAt: string | null; + readonly providerUpdatedAt: string | null; +}; + +export type TlsRuleOwner = VmWorkload | "account_level" | "vpc_unattributed" | "unknown_vm" | "managed"; + +export type PausedRuleHolder = { + readonly vmId: string; + readonly workload: VmWorkload; + readonly owner: VmHealthOwner | null; + readonly pausedSince: string; + readonly rules: number; +}; + +export type TlsBudget = { + readonly totalCount: number; + readonly limit: number; + readonly listed: number; + readonly complete: boolean; + readonly byOwner: Readonly>; + readonly pausedOverDays: number; + readonly pausedHolders: { + readonly vmCount: number; + readonly ruleCount: number; + readonly top: readonly PausedRuleHolder[]; + }; +}; + +export type VmAttentionReason = + | { readonly code: "hung_vms"; readonly count: number } + | { readonly code: "stuck_transitions"; readonly count: number } + | { readonly code: "drift"; readonly count: number } + | { readonly code: "tls_over_limit"; readonly count: number; readonly limit: number } + | { readonly code: "tls_near_limit"; readonly count: number; readonly limit: number } + | { readonly code: "sweep_incomplete"; readonly unprobed: number } + | { readonly code: "sweep_errors"; readonly count: number }; + +export type VmHealthSweepError = { + readonly stage: "previous_snapshot" | "inventory" | "tls_rules" | "db_rows"; + readonly message: string; +}; + +export type VmHealthCoverage = { + readonly inventoryComplete: boolean; + readonly running: number; + readonly probed: number; + readonly probeErrors: number; + readonly skippedBudget: number; + readonly skippedIdle: number; + /** Paused or in transition by the time its probe ran; absent on older snapshots. */ + readonly skippedNotRunning?: number; +}; + +export type VmHealthReport = { + readonly version: 1; + readonly generatedAt: string; + readonly durationMs: number; + readonly stateCounts: Readonly>; + readonly workloadCounts: Readonly>; + readonly coverage: VmHealthCoverage; + readonly hung: readonly HungVm[]; + readonly stuck: readonly StuckVm[]; + /** Every provider VM currently starting or pausing; the next sweep carries their first-seen times. */ + readonly transitions: readonly VmTransitionSighting[]; + readonly drift: readonly DriftVm[]; + readonly tls: TlsBudget | null; + readonly errors: readonly VmHealthSweepError[]; + readonly attention: { + readonly needed: boolean; + readonly reasons: readonly VmAttentionReason[]; + }; +}; + +export type StoredVmHealthReport = { + readonly id: string; + readonly createdAt: Date; + readonly report: VmHealthReport; +}; diff --git a/web/services/vms/providerGateway.ts b/web/services/vms/providerGateway.ts index 9998c72fc3e3..9411506dd23f 100644 --- a/web/services/vms/providerGateway.ts +++ b/web/services/vms/providerGateway.ts @@ -1,4 +1,5 @@ import type { NetworkRulePlan } from "./networkPolicy"; +import type { ProviderTlsRuleInventory, ProviderVmInventory, VmControlProbe } from "./health/types"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; @@ -61,6 +62,22 @@ export type VmProviderGatewayShape = { provider: ProviderId, options?: VMVolumeListOptions, ) => Effect.Effect; + /** Account-wide VM inventory for the read-only staff health sweep. */ + readonly listAccountVms?: ( + provider: ProviderId, + options: { readonly deadlineAtMs: number }, + ) => Effect.Effect; + /** Account-wide TLS rules, reduced to attribution ids, for the health sweep. */ + readonly listAccountTlsRules?: ( + provider: ProviderId, + options: { readonly deadlineAtMs: number }, + ) => Effect.Effect; + /** One bounded control-path probe of a running VM; never fails, a failure is an outcome. */ + readonly probeVmControl?: ( + provider: ProviderId, + vmId: string, + options: { readonly timeoutMs: number }, + ) => Effect.Effect; readonly getStatus?: (provider: ProviderId, vmId: string) => Effect.Effect; readonly getPauseState?: (provider: ProviderId, vmId: string) => Effect.Effect; readonly resume?: (provider: ProviderId, vmId: string) => Effect.Effect; @@ -221,6 +238,15 @@ function privateNetworking(provider: ProviderId) { return impl; } +/** The driver's health inventory, or a typed unsupported failure. */ +function healthInventory(provider: ProviderId) { + const impl = getProvider(provider).healthInventory; + if (!impl) { + throw new VmOperationUnsupportedError({ provider, operation: "healthInventory" }); + } + return impl; +} + /** * A provider read a route may bound (routeDeadline.ts). Outside a bounded * route it is a plain provider call. Inside one, it gets the time left @@ -293,6 +319,21 @@ export const VmProviderGatewayLive = Layer.succeed(VmProviderGateway, { } await impl.deleteHomeVolume(volumeName); }), + listAccountVms: (provider, options) => + Effect.tryPromise({ + try: (signal) => healthInventory(provider).listAccountVms({ ...options, signal }), + catch: (cause) => new VmProviderOperationError({ provider, operation: "listAccountVms", cause }), + }), + listAccountTlsRules: (provider, options) => + Effect.tryPromise({ + try: (signal) => healthInventory(provider).listAccountTlsRules({ ...options, signal }), + catch: (cause) => new VmProviderOperationError({ provider, operation: "listAccountTlsRules", cause }), + }), + probeVmControl: (provider, vmId, options) => + Effect.tryPromise({ + try: (signal) => healthInventory(provider).probeControl(vmId, { ...options, signal }), + catch: (cause) => new VmProviderOperationError({ provider, operation: "probeVmControl", cause }), + }), listVolumes: (provider, options) => providerEffect(provider, "listVolumes", async () => { const impl = getProvider(provider); diff --git a/web/services/vms/repository.ts b/web/services/vms/repository.ts index cfa2dc88880b..11f2a91d3cc2 100644 --- a/web/services/vms/repository.ts +++ b/web/services/vms/repository.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { vmHealthRepositoryMethods, type VmHealthRepositoryMethods } from "./health/repository"; import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, max, ne, or, sql, type SQL, type SQLWrapper } from "drizzle-orm"; import { alias, type AnyPgColumn } from "drizzle-orm/pg-core"; import * as Context from "effect/Context"; @@ -204,7 +205,7 @@ export type BillingGrantClaim = | { readonly kind: "inserted"; readonly grantId: string } | { readonly kind: "already_claimed" }; -export type VmRepositoryShape = { +export type VmRepositoryShape = Partial & { readonly listUserVms: (userId: string, billingTeamId?: string | null) => Effect.Effect; /** * Private-network and tunnel bookkeeping. Optional as a group so test @@ -4378,6 +4379,8 @@ export const vmRepositoryLiveShape: VmRepositoryShape = { // functions keep their reviewed complexity fingerprints. The lease methods // remain one independently testable cross-instance capability. Object.assign(vmRepositoryLiveShape, tunnelEnrollmentRepositoryMethods); +// The staff health sweep's reads and its own snapshot table. +Object.assign(vmRepositoryLiveShape, vmHealthRepositoryMethods); export const VmRepositoryLive = Layer.succeed(VmRepository, vmRepositoryLiveShape); diff --git a/web/tests/admin-cloud-vm-health-route.test.ts b/web/tests/admin-cloud-vm-health-route.test.ts new file mode 100644 index 000000000000..7205b74d3472 --- /dev/null +++ b/web/tests/admin-cloud-vm-health-route.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { NextRequest } from "next/server"; + +import type { StoredVmHealthReport, VmHealthReport } from "../services/vms/health/types"; + +type StackUser = { + id: string; + primaryEmail: string | null; + primaryEmailVerified: boolean; + isAnonymous: boolean; + displayName?: string | null; +}; + +let currentUser: StackUser | null = null; +const directory = new Map(); +const getUser = mock(async (arg?: unknown) => (typeof arg === "string" ? directory.get(arg) ?? null : currentUser)); + +mock.module("../app/lib/stack", () => ({ + getStackServerApp: () => ({ getUser }), + isStackConfigured: () => true, + promoteStackUserFromAnonymousViaApi: async () => undefined, + stackServerApp: { getUser }, +})); + +let activeMembers = new Set(); +mock.module("../services/admin/members", () => ({ + findActiveAdminMember: async (email: string) => (activeMembers.has(email) ? { email } : null), +})); + +let snapshotReads = 0; +let stored: StoredVmHealthReport | null = null; +const realStaffView = await import("../services/vms/health/staffView"); +mock.module("../services/vms/health/staffView", () => ({ + ...realStaffView, + latestStoredVmHealthReport: async () => { + snapshotReads += 1; + return stored; + }, +})); + +const { GET } = await import("../app/api/admin/cloud-vm-health/route"); + +function report(): VmHealthReport { + const owner = { cloudVmId: "row-1", userId: "user-1", teamId: "team-1" }; + return { + version: 1, + generatedAt: "2026-10-12T12:00:00.000Z", + durationMs: 42_000, + stateCounts: { running: 2 }, + workloadCounts: { cmux_cloud: 2 }, + coverage: { inventoryComplete: true, running: 2, probed: 2, probeErrors: 0, skippedBudget: 0, skippedIdle: 0 }, + hung: [{ + vmId: "vm-hung", + workload: "cmux_cloud", + owner, + snapshot: "cmux-devbox", + size: { cpu: 4, memoryMib: 8192, storageMib: 20480 }, + firstSeenHungAt: "2026-10-12T11:00:00.000Z", + providerUpdatedAt: "2026-10-12T10:00:00.000Z", + dbUpdatedAt: "2026-10-12T10:00:00.000Z", + detail: "timeout after 10000ms", + }], + stuck: [], + transitions: [], + drift: [], + tls: null, + errors: [], + attention: { needed: true, reasons: [{ code: "hung_vms", count: 1 }] }, + }; +} + +function request() { + return new NextRequest("https://cmux.com/api/admin/cloud-vm-health"); +} + +function user(email: string, overrides: Partial = {}): StackUser { + return { id: `id-${email}`, primaryEmail: email, primaryEmailVerified: true, isAnonymous: false, ...overrides }; +} + +describe("GET /api/admin/cloud-vm-health authorization", () => { + beforeEach(() => { + currentUser = null; + activeMembers = new Set(); + snapshotReads = 0; + stored = { id: "snap-1", createdAt: new Date(Date.now() - 60_000), report: report() }; + directory.clear(); + directory.set("user-1", user("owner@example.com", { id: "user-1", displayName: "Owner" })); + }); + + test("signed-out callers get 401 and never read the snapshot", async () => { + const response = await GET(request()); + expect(response.status).toBe(401); + expect(snapshotReads).toBe(0); + }); + + test("anonymous callers get 401", async () => { + currentUser = user("anon@cmux.com", { isAnonymous: true }); + expect((await GET(request())).status).toBe(401); + expect(snapshotReads).toBe(0); + }); + + test("a regular user gets 403 and no owner data", async () => { + currentUser = user("someone@example.com"); + const response = await GET(request()); + expect(response.status).toBe(403); + const body = await response.text(); + expect(body).not.toContain("owner@example.com"); + expect(body).not.toContain("vm-hung"); + expect(snapshotReads).toBe(0); + }); + + test("an unverified company email gets 403", async () => { + currentUser = user("eve@cmux.com", { primaryEmailVerified: false }); + expect((await GET(request())).status).toBe(403); + expect(snapshotReads).toBe(0); + }); + + test("a lookalike company domain gets 403", async () => { + currentUser = user("eve@cmux.com.evil.example"); + expect((await GET(request())).status).toBe(403); + }); + + test("staff get the latest snapshot with owner emails, uncached", async () => { + currentUser = user("ops@manaflow.ai"); + const response = await GET(request()); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = await response.json() as { snapshot: { stale: boolean }; report: VmHealthReport; owners: Record }; + expect(body.snapshot.stale).toBe(false); + expect(body.report.hung[0]!.vmId).toBe("vm-hung"); + expect(body.owners["user-1"]!.email).toBe("owner@example.com"); + expect(snapshotReads).toBe(1); + }); + + test("an invited admin member with a verified email is staff", async () => { + currentUser = user("contractor@example.com"); + activeMembers.add("contractor@example.com"); + expect((await GET(request())).status).toBe(200); + }); + + test("a stale snapshot is reported as stale", async () => { + currentUser = user("ops@cmux.com"); + stored = { id: "snap-old", createdAt: new Date(Date.now() - 60 * 60_000), report: report() }; + const body = await (await GET(request())).json() as { snapshot: { stale: boolean } }; + expect(body.snapshot.stale).toBe(true); + }); +}); diff --git a/web/tests/vm-health-alerts.test.ts b/web/tests/vm-health-alerts.test.ts new file mode 100644 index 000000000000..bb9cead20af4 --- /dev/null +++ b/web/tests/vm-health-alerts.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; + +import type { AlertInput } from "../services/observability/alerts"; +import type { VmAlertStateStore } from "../services/observability/vmAlerts"; +import { deliverVmHealthAlert, VM_HEALTH_ALERT_KEY, vmHealthAlert } from "../services/observability/vmHealthAlerts"; +import type { VmHealthReport } from "../services/vms/health/types"; + +function report(overrides: Partial = {}): VmHealthReport { + return { + version: 1, + generatedAt: "2026-10-12T12:00:00.000Z", + durationMs: 1, + stateCounts: {}, + workloadCounts: {}, + coverage: { inventoryComplete: true, running: 0, probed: 0, probeErrors: 0, skippedBudget: 0, skippedIdle: 0 }, + hung: [], + stuck: [], + transitions: [], + drift: [], + tls: null, + errors: [], + attention: { needed: false, reasons: [] }, + ...overrides, + }; +} + +const hungReport = report({ + hung: [{ + vmId: "vm-1", workload: "app:chatmux2", owner: null, snapshot: null, + size: { cpu: null, memoryMib: null, storageMib: null }, + firstSeenHungAt: "2026-10-12T11:00:00.000Z", providerUpdatedAt: "2026-10-12T10:00:00.000Z", dbUpdatedAt: null, detail: "timeout", + }], +}); + +/** The durable store's contract: a claim succeeds when inactive, escalated, or 24 hours after the last page. */ +function memoryStore() { + const state = { active: false, severity: "warning", lastSentAt: null as Date | null, clears: 0 }; + const store: VmAlertStateStore = { + claim: async (input: AlertInput, now: Date) => { + const escalated = input.severity === "critical" && state.severity !== "critical"; + const reminder = !state.lastSentAt || now.getTime() - state.lastSentAt.getTime() >= 24 * 3600_000; + if (state.active && !escalated && !reminder) return null; + state.active = true; + state.severity = input.severity; + return "lease"; + }, + acknowledge: async (_key, _lease, now) => { + state.lastSentAt = now; + }, + clear: async () => { + state.active = false; + state.clears += 1; + }, + }; + return { state, store }; +} + +describe("vm health alert", () => { + test("hung VMs are critical and name the workloads; a clean report has no alert", () => { + const alert = vmHealthAlert(hungReport, "https://cmux.com/dashboard/ops/cloud-vms")!; + expect(alert.key).toBe(VM_HEALTH_ALERT_KEY); + expect(alert.severity).toBe("critical"); + expect(alert.body).toContain("1 app:chatmux2"); + expect(alert.body).toContain("https://cmux.com/dashboard/ops/cloud-vms"); + expect(vmHealthAlert(report(), null)).toBeNull(); + }); + + test("pages once per incident and at most once an hour when a VM flaps", async () => { + const { state, store } = memoryStore(); + const sent: AlertInput[] = []; + const send = async (input: AlertInput) => { + sent.push(input); + return { sent: true, configured: true }; + }; + const lastSentAt = async () => state.lastSentAt; + const at = (minutes: number) => new Date(Date.UTC(2026, 9, 12, 12, minutes)); + + expect(await deliverVmHealthAlert(hungReport, { now: at(0), store, send, lastSentAt })).toBe("sent"); + expect(await deliverVmHealthAlert(hungReport, { now: at(5), store, send, lastSentAt })).toBe("suppressed"); + // Clean, then hung again inside the hour: the state was not cleared, so no new page. + expect(await deliverVmHealthAlert(report(), { now: at(10), store, send, lastSentAt })).toBe("none"); + expect(state.clears).toBe(0); + expect(await deliverVmHealthAlert(hungReport, { now: at(15), store, send, lastSentAt })).toBe("suppressed"); + expect(sent).toHaveLength(1); + // A clean sweep an hour after the page clears it, and the next incident pages. + expect(await deliverVmHealthAlert(report(), { now: at(61), store, send, lastSentAt })).toBe("none"); + expect(state.clears).toBe(1); + expect(await deliverVmHealthAlert(hungReport, { now: at(66), store, send, lastSentAt })).toBe("sent"); + expect(sent).toHaveLength(2); + }); + + test("a blind sweep never clears a live alert", async () => { + const { state, store } = memoryStore(); + const send = async () => ({ sent: true, configured: true }); + const lastSentAt = async () => state.lastSentAt; + const at = (minutes: number) => new Date(Date.UTC(2026, 9, 12, 12, minutes)); + await deliverVmHealthAlert(hungReport, { now: at(0), store, send, lastSentAt }); + const blind = [ + report({ errors: [{ stage: "inventory", message: "503" }], coverage: { ...report().coverage, inventoryComplete: false } }), + report({ coverage: { ...report().coverage, skippedBudget: 30 } }), + ]; + for (const sweep of blind) { + expect(await deliverVmHealthAlert(sweep, { now: at(120), store, send, lastSentAt })).toBe("none"); + } + expect(state.clears).toBe(0); + expect(await deliverVmHealthAlert(report(), { now: at(125), store, send, lastSentAt })).toBe("none"); + expect(state.clears).toBe(1); + }); + + test("a failed delivery releases its lease so the next sweep retries", async () => { + const released: string[] = []; + const claims: string[] = []; + const store: VmAlertStateStore = { + claim: async () => { + claims.push("claim"); + return released.length >= claims.length - 1 ? `lease-${claims.length}` : null; + }, + acknowledge: async () => undefined, + clear: async () => undefined, + release: async (key, leaseId) => { + released.push(`${key}:${leaseId}`); + }, + }; + const failing = async () => ({ sent: false, configured: true, status: 500 }); + expect(await deliverVmHealthAlert(hungReport, { store, send: failing, lastSentAt: async () => null })).toBe("failed"); + expect(released).toEqual([`${VM_HEALTH_ALERT_KEY}:lease-1`]); + expect(await deliverVmHealthAlert(hungReport, { store, send: failing, lastSentAt: async () => null })).toBe("failed"); + }); + + test("a failing alert path never fails the sweep", async () => { + const { store } = memoryStore(); + const outcome = await deliverVmHealthAlert(hungReport, { + store, + send: async () => { + throw new Error("slack down"); + }, + lastSentAt: async () => null, + }); + expect(outcome).toBe("failed"); + }); +}); diff --git a/web/tests/vm-health-classify.test.ts b/web/tests/vm-health-classify.test.ts new file mode 100644 index 000000000000..a421e1bfe28a --- /dev/null +++ b/web/tests/vm-health-classify.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, test } from "bun:test"; + +import { classifyVmHealth, tlsRuleOwner, vmWorkload } from "../services/vms/health/classify"; +import { tlsRuleRef } from "../services/vms/drivers/freestyleHealthInventory"; +import { sanitizeDetail } from "../services/vms/health/sanitize"; +import type { ProviderInventoryVm, ProviderTlsRuleRef, VmHealthDbRow, VmProbeRecord } from "../services/vms/health/types"; + +const NOW = new Date("2026-10-12T12:00:00.000Z"); +const minutesAgo = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000).toISOString(); +const daysAgo = (days: number) => minutesAgo(days * 24 * 60); + +function vm(id: string, overrides: Partial = {}): ProviderInventoryVm { + return { + id, + state: "running", + metadata: { cmux: "cloud" }, + snapshot: "cmux-devbox-4x8", + cpu: 4, + memoryMib: 8192, + storageMib: 20480, + idleTimeoutSeconds: -1, + vpcIds: [], + createdAt: daysAgo(3), + updatedAt: daysAgo(1), + ...overrides, + }; +} + +function row(providerVmId: string, overrides: Partial = {}): VmHealthDbRow { + return { + id: `row-${providerVmId}`, + providerVmId, + userId: `user-${providerVmId}`, + ownerTeamId: `team-${providerVmId}`, + status: "running", + imageId: "devbox", + createdAt: new Date(daysAgo(3)), + updatedAt: new Date(daysAgo(1)), + ...overrides, + }; +} + +function classify(input: Partial[0]> & { vms?: ProviderInventoryVm[] }) { + const { vms, ...rest } = input; + return classifyVmHealth({ + now: NOW, + startedAt: new Date(NOW.getTime() - 30_000), + inventory: { vms: vms ?? [], complete: true }, + tlsRules: null, + dbRows: [], + probes: [], + previousHung: [], + errors: [], + ...rest, + }); +} + +describe("workload", () => { + test("cmux Cloud, other apps on the shared account, and everything else", () => { + expect(vmWorkload({ cmux: "cloud" })).toBe("cmux_cloud"); + expect(vmWorkload({ app: "chatmux2" })).toBe("app:chatmux2"); + expect(vmWorkload({})).toBe("other"); + }); +}); + +describe("hung VMs", () => { + test("a running VM whose probe timed out or hit a stale runtime is hung, with owner, image and size", () => { + const report = classify({ + vms: [vm("vm-a"), vm("vm-b", { metadata: { app: "chatmux2" } }), vm("vm-ok")], + dbRows: [row("vm-a")], + probes: [ + { vmId: "vm-a", outcome: "hung", latencyMs: 10_000, detail: "timeout after 10000ms" }, + { vmId: "vm-b", outcome: "hung", latencyMs: 40, detail: "409 conflict: vm runtime stale runtime instance responsive=false" }, + { vmId: "vm-ok", outcome: "ok", latencyMs: 30 }, + ], + }); + expect(report.hung.map((entry) => entry.vmId).sort()).toEqual(["vm-a", "vm-b"]); + const a = report.hung.find((entry) => entry.vmId === "vm-a")!; + expect(a.workload).toBe("cmux_cloud"); + expect(a.owner).toEqual({ cloudVmId: "row-vm-a", userId: "user-vm-a", teamId: "team-vm-a" }); + expect(a.snapshot).toBe("cmux-devbox-4x8"); + expect(a.size).toEqual({ cpu: 4, memoryMib: 8192, storageMib: 20480 }); + expect(a.firstSeenHungAt).toBe(NOW.toISOString()); + expect(a.dbUpdatedAt).toBe(daysAgo(1)); + const b = report.hung.find((entry) => entry.vmId === "vm-b")!; + expect(b.workload).toBe("app:chatmux2"); + expect(b.owner).toBeNull(); + expect(report.attention.needed).toBe(true); + expect(report.attention.reasons).toContainEqual({ code: "hung_vms", count: 2 }); + }); + + test("first-seen-hung time carries over from the previous sweep", () => { + const report = classify({ + vms: [vm("vm-a")], + probes: [{ vmId: "vm-a", outcome: "hung", latencyMs: 10_000, detail: "timeout" }], + previousHung: [{ vmId: "vm-a", firstSeenHungAt: minutesAgo(45) }, { vmId: "vm-recovered", firstSeenHungAt: minutesAgo(90) }], + }); + expect(report.hung).toHaveLength(1); + expect(report.hung[0]!.firstSeenHungAt).toBe(minutesAgo(45)); + }); + + test("probe errors and gone VMs are not hung; a few errors are noise, many make the sweep incomplete", () => { + const report = classify({ + vms: [vm("vm-a"), vm("vm-b")], + probes: [ + { vmId: "vm-a", outcome: "error", latencyMs: 3, detail: "429 rate limited" }, + { vmId: "vm-b", outcome: "gone", latencyMs: 3 }, + ], + }); + expect(report.hung).toEqual([]); + expect(report.coverage.probeErrors).toBe(1); + expect(report.attention).toEqual({ needed: false, reasons: [] }); + + const vms = Array.from({ length: 20 }, (_, index) => vm(`vm-${index}`)); + const many = classify({ vms, probes: vms.slice(0, 5).map((entry) => ({ vmId: entry.id, outcome: "error", latencyMs: 3, detail: "state read: 503" })) }); + expect(many.attention.reasons).toContainEqual({ code: "sweep_incomplete", unprobed: 5 }); + }); + + test("a VM paused after the listing is not probed, not hung, and not counted as probed", () => { + const report = classify({ + vms: [vm("vm-a")], + probes: [{ vmId: "vm-a", outcome: "not_running", latencyMs: 20 }], + previousHung: [{ vmId: "vm-a", firstSeenHungAt: minutesAgo(30) }], + }); + expect(report.hung).toEqual([]); + expect(report.coverage).toMatchObject({ probed: 0, skippedNotRunning: 1 }); + }); + + test("a hung VM this sweep could not re-probe stays hung with its first-seen time", () => { + const report = classify({ + vms: [vm("vm-a"), vm("vm-b"), vm("vm-c")], + probes: [ + { vmId: "vm-a", outcome: "skipped_budget" }, + { vmId: "vm-b", outcome: "error", latencyMs: 1, detail: "429" }, + { vmId: "vm-c", outcome: "ok", latencyMs: 1 }, + ], + previousHung: ["vm-a", "vm-b", "vm-c"].map((vmId) => ({ vmId, firstSeenHungAt: minutesAgo(30) })), + }); + expect(report.hung.map((entry) => `${entry.vmId}:${entry.firstSeenHungAt === minutesAgo(30)}`)).toEqual(["vm-a:true", "vm-b:true"]); + expect(report.hung[0]!.detail).toContain("not re-probed"); + }); + + test("a healthy fleet needs no attention", () => { + const report = classify({ + vms: [vm("vm-a"), vm("vm-p", { state: "paused" })], + dbRows: [row("vm-a"), row("vm-p", { status: "paused" })], + probes: [{ vmId: "vm-a", outcome: "ok", latencyMs: 20 }], + tlsRules: { totalCount: 10, rules: [], complete: true }, + }); + expect(report.attention).toEqual({ needed: false, reasons: [] }); + expect(report.stateCounts).toEqual({ running: 1, paused: 1 }); + }); +}); + +describe("stuck transitions", () => { + test("transitions seen past the threshold and old DB provisioning rows are stuck", () => { + const report = classify({ + vms: [ + vm("vm-pausing", { state: "pausing" }), + vm("vm-starting-fresh", { state: "starting" }), + vm("vm-starting", { state: "starting" }), + ], + previousTransitions: [ + { vmId: "vm-pausing", state: "pausing", firstSeenAt: minutesAgo(40) }, + { vmId: "vm-starting-fresh", state: "starting", firstSeenAt: minutesAgo(2) }, + { vmId: "vm-starting", state: "starting", firstSeenAt: minutesAgo(16) }, + ], + dbRows: [ + row("vm-prov", { status: "provisioning", updatedAt: new Date(minutesAgo(30)) }), + row("vm-prov-new", { status: "provisioning", updatedAt: new Date(minutesAgo(1)) }), + ], + }); + expect(report.stuck.map((entry) => `${entry.source}:${entry.vmId}:${entry.minutes}`)).toEqual([ + "provider:vm-pausing:40", + "db:vm-prov:30", + "provider:vm-starting:16", + ]); + expect(report.attention.reasons).toContainEqual({ code: "stuck_transitions", count: 3 }); + expect(report.transitions.map((entry) => entry.vmId).sort()).toEqual(["vm-pausing", "vm-starting", "vm-starting-fresh"]); + }); + + test("a transition is dated by sightings, never by the provider timestamp", () => { + // A paused VM that starts may keep its days-old pause time as updatedAt; + // that must not make a fresh transition look stuck on its first sighting. + const vms = [vm("vm-starting", { state: "starting", updatedAt: daysAgo(9) })]; + const first = classify({ vms }); + expect(first.stuck).toEqual([]); + expect(first.transitions).toEqual([{ vmId: "vm-starting", state: "starting", firstSeenAt: NOW.toISOString() }]); + const later = classify({ vms, previousTransitions: [{ vmId: "vm-starting", state: "starting", firstSeenAt: minutesAgo(20) }] }); + expect(later.stuck.map((entry) => `${entry.vmId}:${entry.minutes}`)).toEqual(["vm-starting:20"]); + // A different transition of the same VM starts a new clock. + const restarted = classify({ vms, previousTransitions: [{ vmId: "vm-starting", state: "pausing", firstSeenAt: minutesAgo(20) }] }); + expect(restarted.stuck).toEqual([]); + }); + + test("the threshold is configurable", () => { + const report = classify({ + vms: [vm("vm-pausing", { state: "pausing" })], + previousTransitions: [{ vmId: "vm-pausing", state: "pausing", firstSeenAt: minutesAgo(6) }], + thresholds: { stuckAfterMs: 5 * 60_000 }, + }); + expect(report.stuck).toHaveLength(1); + }); +}); + +describe("drift", () => { + test("DB running but missing at the provider is drift only when the inventory is complete", () => { + const dbRows = [row("vm-gone")]; + expect(classify({ dbRows }).drift.map((entry) => entry.kind)).toEqual(["provider_missing"]); + const partial = classifyVmHealth({ + now: NOW, startedAt: NOW, inventory: { vms: [], complete: false }, tlsRules: null, + dbRows, probes: [], previousHung: [], errors: [], + }); + expect(partial.drift).toEqual([]); + expect(partial.attention.reasons).toContainEqual({ code: "sweep_incomplete", unprobed: 0 }); + }); + + test("state mismatches in both directions", () => { + const report = classify({ + vms: [vm("vm-1", { state: "paused" }), vm("vm-2", { state: "running" }), vm("vm-3", { state: "stopped" })], + dbRows: [row("vm-1"), row("vm-2", { status: "paused" }), row("vm-3")], + }); + expect(report.drift.map((entry) => `${entry.vmId}:${entry.kind}`)).toEqual([ + "vm-2:db_paused_provider_running", + "vm-1:db_running_provider_paused", + "vm-3:db_running_provider_paused", + ]); + }); + + test("cmux VMs the DB does not track, or tracks as destroyed, are drift; other workloads are not", () => { + const report = classify({ + vms: [ + vm("vm-untracked"), + vm("vm-destroyed"), + vm("vm-chat", { metadata: { app: "chatmux2" } }), + vm("vm-stopped-untracked", { state: "stopped" }), + ], + dbRows: [row("vm-destroyed", { status: "destroyed", updatedAt: new Date(daysAgo(2)) })], + }); + expect(report.drift.map((entry) => `${entry.vmId}:${entry.kind}`)).toEqual([ + "vm-destroyed:db_destroyed_provider_present", + "vm-untracked:provider_untracked", + ]); + }); + + test("untracked cmux VMs are listed but do not by themselves need attention", () => { + // Another environment on the shared account tags its VMs cmux=cloud too. + const report = classify({ vms: [vm("vm-staging")] }); + expect(report.drift.map((entry) => entry.kind)).toEqual(["provider_untracked"]); + expect(report.attention).toEqual({ needed: false, reasons: [] }); + }); + + test("recent changes are in-flight lifecycle calls, not drift", () => { + const report = classify({ + vms: [vm("vm-new", { createdAt: minutesAgo(2), updatedAt: minutesAgo(2) }), vm("vm-pausing-now", { state: "paused", updatedAt: minutesAgo(1) })], + dbRows: [row("vm-pausing-now"), row("vm-just-created", { updatedAt: new Date(minutesAgo(1)) })], + }); + expect(report.drift).toEqual([]); + }); + + test("a running VM's activity-driven updatedAt does not hide drift", () => { + const report = classify({ + vms: [vm("vm-untracked", { updatedAt: minutesAgo(1) }), vm("vm-resumed", { updatedAt: minutesAgo(1) })], + dbRows: [row("vm-resumed", { status: "paused" })], + }); + expect(report.drift.map((entry) => `${entry.vmId}:${entry.kind}`)).toEqual([ + "vm-resumed:db_paused_provider_running", + "vm-untracked:provider_untracked", + ]); + }); +}); + +describe("TLS rule budget", () => { + const vms = [ + vm("vm-cmux", { vpcIds: ["vpc-cmux"] }), + vm("vm-chat", { metadata: { app: "chatmux2" } }), + vm("vm-paused-old", { state: "paused", updatedAt: daysAgo(10) }), + vm("vm-paused-new", { state: "paused", updatedAt: daysAgo(2) }), + ]; + const rule = (id: string, overrides: Partial = {}): ProviderTlsRuleRef => ({ id, managed: false, vmIds: [], vpcIds: [], ...overrides }); + + test("over the 200,000 limit, attributed by owner, with idle paused holders", () => { + const rules = [ + rule("r1", { vmIds: ["vm-cmux"] }), + rule("r2", { vmIds: ["vm-cmux"] }), + rule("r3", { vmIds: ["vm-chat"] }), + rule("r4"), + rule("r5", { vpcIds: ["vpc-cmux"] }), + rule("r6", { vpcIds: ["vpc-unknown"] }), + rule("r7", { vmIds: ["vm-deleted"] }), + rule("r8", { vmIds: ["vm-paused-old"] }), + rule("r9", { vmIds: ["vm-paused-old"] }), + rule("r10", { vmIds: ["vm-paused-new"] }), + rule("r11", { managed: true }), + ]; + const report = classify({ vms, tlsRules: { totalCount: 200_303, rules, complete: false } }); + const tls = report.tls!; + expect(tls.totalCount).toBe(200_303); + expect(tls.limit).toBe(200_000); + expect(tls.complete).toBe(false); + expect(tls.byOwner).toEqual({ + cmux_cloud: 6, + "app:chatmux2": 1, + account_level: 1, + vpc_unattributed: 1, + unknown_vm: 1, + managed: 1, + }); + expect(tls.pausedHolders.vmCount).toBe(1); + expect(tls.pausedHolders.ruleCount).toBe(2); + expect(tls.pausedHolders.top[0]).toMatchObject({ vmId: "vm-paused-old", rules: 2, workload: "cmux_cloud" }); + expect(report.attention.needed).toBe(true); + expect(report.attention.reasons).toContainEqual({ code: "tls_over_limit", count: 200_303, limit: 200_000 }); + }); + + test("near the limit is a warning that does not by itself need attention", () => { + const report = classify({ vms: [], tlsRules: { totalCount: 180_000, rules: [], complete: true } }); + expect(report.attention.reasons).toEqual([{ code: "tls_near_limit", count: 180_000, limit: 200_000 }]); + expect(report.attention.needed).toBe(false); + }); + + test("rule owner falls back from VM to network to account", () => { + const byId = new Map(vms.map((entry) => [entry.id, entry])); + const vpc = new Map([["vpc-cmux", "cmux_cloud"]]); + expect(tlsRuleOwner(rule("x", { vmIds: ["vm-chat"], vpcIds: ["vpc-cmux"] }), byId, vpc)).toBe("app:chatmux2"); + expect(tlsRuleOwner(rule("x", { vpcIds: ["vpc-cmux"] }), byId, vpc)).toBe("cmux_cloud"); + expect(tlsRuleOwner(rule("x"), byId, vpc)).toBe("account_level"); + }); + + test("the provider rule reduces to ids; domains and transforms are dropped", () => { + const ref = tlsRuleRef({ + id: "rule-1", + accountId: "acct", + action: "allow", + domain: "api.openai.com", + protocol: "http", + source: { vmId: "vm-src" }, + destination: { public: true }, + transform: [{ headers: { authorization: "***" } }], + dependencies: [{ kind: "vm", id: "vm-src" }, { kind: "vpc", id: "vpc-1" }], + createdAt: daysAgo(1), + updatedAt: daysAgo(1), + } as unknown as Parameters[0]); + expect(ref).toEqual({ id: "rule-1", managed: false, vmIds: ["vm-src"], vpcIds: ["vpc-1"] }); + expect(JSON.stringify(ref)).not.toContain("openai"); + }); +}); + +describe("probe detail", () => { + test("token-shaped text is redacted and the detail is bounded", () => { + const detail = sanitizeDetail(`409 conflict bearer abc123 ${"x".repeat(40)} ${"y ".repeat(300)}`); + expect(detail).not.toContain("abc123"); + expect(detail).not.toContain("x".repeat(40)); + expect(detail.length).toBeLessThanOrEqual(200); + }); +}); + +describe("report bounds", () => { + test("lists are capped so one incident cannot make a snapshot unbounded", () => { + const vms = Array.from({ length: 700 }, (_, index) => vm(`vm-${index}`)); + const probes: VmProbeRecord[] = vms.map((entry) => ({ vmId: entry.id, outcome: "hung", latencyMs: 10_000, detail: "timeout" })); + const report = classify({ vms, probes }); + expect(report.hung).toHaveLength(500); + // The reason keeps the true count while the stored list is capped. + expect(report.attention.reasons).toContainEqual({ code: "hung_vms", count: 700 }); + }); +}); diff --git a/web/tests/vm-health-inventory.test.ts b/web/tests/vm-health-inventory.test.ts new file mode 100644 index 000000000000..b3a83d9f01ad --- /dev/null +++ b/web/tests/vm-health-inventory.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import type { Freestyle } from "freestyle"; + +import { freestyleHealthInventory } from "../services/vms/drivers/freestyleHealthInventory"; + +type Route = (path: string, init: RequestInit) => Promise | Response; + +/** A Freestyle client double whose raw fetch answers by path and records every request. */ +function client(route: Route, lists: { vms?: (offset: number) => unknown; rules?: (offset: number) => unknown } = {}) { + const requests: string[] = []; + const instance = { + fetch: async (path: string, init: RequestInit) => { + requests.push(`${init.method ?? "GET"} ${path}`); + return route(path, init); + }, + vms: { list: async ({ offset }: { offset: number }) => lists.vms?.(offset) }, + tls: { rules: { list: async ({ offset }: { offset: number }) => lists.rules?.(offset) } }, + } as unknown as Freestyle; + return { requests, inventory: freestyleHealthInventory(() => instance) }; +} + +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +const stateIs = (state: string) => (path: string) => path === "/v5/vms/vm-1" ? json(200, { id: "vm-1", state }) : json(500, {}); + +describe("probeControl", () => { + test("a VM paused since the listing is never sent a file system call", async () => { + const { requests, inventory } = client(stateIs("paused")); + const probe = await inventory.probeControl("vm-1", { timeoutMs: 1_000 }); + expect(probe.outcome).toBe("not_running"); + expect(requests).toEqual(["GET /v5/vms/vm-1"]); + }); + + test("a running VM gets one GET state read and one GET fs/stat, nothing else", async () => { + const { requests, inventory } = client((path) => path.includes("/fs/stat") ? json(200, { isDirectory: true }) : json(200, { state: "running" })); + expect((await inventory.probeControl("vm-1", { timeoutMs: 1_000 })).outcome).toBe("ok"); + expect(requests).toEqual(["GET /v5/vms/vm-1", "GET /v5/vms/vm-1/fs/stat?path=%2F"]); + }); + + test("409 stale runtime and 5xx from fs/stat are hung; 401 and 429 are probe errors; 404 is gone", async () => { + const cases: Array<[number, string]> = [[409, "hung"], [502, "hung"], [504, "hung"], [401, "error"], [429, "error"], [404, "gone"]]; + for (const [status, outcome] of cases) { + const { inventory } = client((path) => path.includes("/fs/stat") + ? json(status, { code: "CONFLICT", message: "vm runtime stale runtime instance responsive=false" }) + : json(200, { state: "running" })); + const probe = await inventory.probeControl("vm-1", { timeoutMs: 1_000 }); + expect(`${status}:${probe.outcome}`).toBe(`${status}:${outcome}`); + if (probe.outcome === "hung") expect(probe.detail).toContain(`${status} CONFLICT`); + } + }); + + test("an unreadable state read is a probe error, never a paused VM", async () => { + for (const body of ["not json", JSON.stringify({ id: "vm-1" })]) { + const { requests, inventory } = client(() => new Response(body, { status: 200 })); + const probe = await inventory.probeControl("vm-1", { timeoutMs: 1_000 }); + expect(probe.outcome).toBe("error"); + expect(requests).toEqual(["GET /v5/vms/vm-1"]); + } + }); + + test("a control-plane outage on the state read is a probe error, not a hung VM", async () => { + const { requests, inventory } = client(() => json(503, { code: "UNAVAILABLE", message: "upstream" })); + const probe = await inventory.probeControl("vm-1", { timeoutMs: 1_000 }); + expect(probe.outcome).toBe("error"); + expect(requests).toHaveLength(1); + }); + + test("an fs/stat that never answers is hung at the timeout", async () => { + const { inventory } = client((path, init) => path.includes("/fs/stat") + ? new Promise((_, reject) => init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")))) + : json(200, { state: "running" })); + const probe = await inventory.probeControl("vm-1", { timeoutMs: 150 }); + expect(probe.outcome).toBe("hung"); + }); +}); + +describe("account listings", () => { + const vm = (id: string) => ({ id, state: "running", metadata: {}, resources: {}, vpcs: [], createdAt: "2026-10-01T00:00:00Z", updatedAt: "2026-10-01T00:00:00Z" }); + const rule = (id: string) => ({ id, dependencies: [], source: {}, destination: {} }); + const ids = (prefix: string, from: number, count: number) => Array.from({ length: count }, (_, index) => `${prefix}${from + index}`); + + test("VM paging dedupes a repeated VM and reports a shortfall as incomplete", async () => { + // 150 VMs; one was deleted mid-walk, so vm-100 shifted onto page one and was never seen. + const pages: Record = { + 0: { vms: ids("vm-", 0, 100).map(vm), totalCount: 150 }, + 100: { vms: [...ids("vm-", 99, 1), ...ids("vm-", 101, 49)].map(vm), totalCount: 150 }, + }; + const { inventory } = client(() => json(500, {}), { vms: (offset) => pages[offset] }); + const result = await inventory.listAccountVms({ deadlineAtMs: Date.now() + 10_000 }); + expect(result.vms).toHaveLength(149); + expect(result.complete).toBe(false); + }); + + test("TLS paging dedupes a rule repeated by a mid-walk create", async () => { + const pages: Record = { + 0: { rules: ids("r", 0, 100).map(rule), totalCount: 120 }, + 100: { rules: ids("r", 99, 20).map(rule), totalCount: 120 }, + }; + const { inventory } = client(() => json(500, {}), { rules: (offset) => pages[offset] }); + const result = await inventory.listAccountTlsRules({ deadlineAtMs: Date.now() + 10_000 }); + expect(result.rules).toHaveLength(119); + expect(result.totalCount).toBe(120); + expect(result.complete).toBe(false); + }); + + test("a TLS listing that fetched no page fails instead of reporting zero rules", async () => { + const { inventory } = client(() => json(500, {}), { rules: () => ({ rules: [], totalCount: 0 }) }); + await expect(inventory.listAccountTlsRules({ deadlineAtMs: Date.now() - 1 })).rejects.toThrow("first page"); + }); +}); diff --git a/web/tests/vm-health-sweep.test.ts b/web/tests/vm-health-sweep.test.ts new file mode 100644 index 000000000000..e8373d23aeac --- /dev/null +++ b/web/tests/vm-health-sweep.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { VmProviderGateway, type VmProviderGatewayShape } from "../services/vms/providerGateway"; +import { VmRepository, type VmRepositoryShape } from "../services/vms/repository"; +import { handleVmHealthCron } from "../services/vms/health/cron"; +import { sweepVmHealth, type VmHealthSweepOptions } from "../services/vms/health/sweep"; +import type { ProviderInventoryVm, StoredVmHealthReport, VmControlProbe, VmHealthReport } from "../services/vms/health/types"; + +const READ_ONLY_MEMBERS = new Set(["listAccountVms", "listAccountTlsRules", "probeVmControl"]); + +function vm(id: string, overrides: Partial = {}): ProviderInventoryVm { + return { + id, + state: "running", + metadata: { cmux: "cloud" }, + snapshot: "cmux-devbox", + cpu: 2, + memoryMib: 4096, + storageMib: 10240, + idleTimeoutSeconds: null, + vpcIds: [], + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...overrides, + }; +} + +type Harness = { + readonly forbiddenCalls: string[]; + readonly probed: string[]; + readonly snapshots: StoredVmHealthReport[]; + readonly run: (options: VmHealthSweepOptions) => Promise<{ report: VmHealthReport; snapshotId: string | null }>; +}; + +/** + * Gateway double: only the three read members exist. Any other member a sweep + * touches (pause, destroy, deleteFirewallRule, ...) is recorded as a forbidden + * call, which is how the tests prove the sweep is read-only. + */ +function harness(input: { + readonly vms: readonly ProviderInventoryVm[]; + readonly probe: (vmId: string, timeoutMs: number) => Effect.Effect; +}): Harness { + const forbiddenCalls: string[] = []; + const probed: string[] = []; + const snapshots: StoredVmHealthReport[] = []; + const reads: Partial = { + listAccountVms: () => Effect.succeed({ vms: input.vms, complete: true }), + listAccountTlsRules: () => Effect.succeed({ totalCount: 2303, rules: [], complete: true }), + probeVmControl: (_provider, vmId, options) => { + probed.push(vmId); + return input.probe(vmId, options.timeoutMs); + }, + }; + const gateway = new Proxy(reads, { + get(target, property) { + if (typeof property === "string" && READ_ONLY_MEMBERS.has(property)) return target[property as keyof typeof target]; + if (typeof property !== "string") return undefined; + return () => { + forbiddenCalls.push(property); + return Effect.die(new Error(`sweep called ${property}`)); + }; + }, + }) as VmProviderGatewayShape; + const repo = { + vmHealthDbRows: () => Effect.succeed([]), + latestVmHealthSnapshot: () => Effect.succeed(snapshots.at(-1) ?? null), + insertVmHealthSnapshot: (report: VmHealthReport) => Effect.sync(() => { + const id = `snap-${snapshots.length + 1}`; + snapshots.push({ id, createdAt: new Date(report.generatedAt), report }); + return { id }; + }), + pruneVmHealthSnapshots: () => Effect.succeed(0), + } as unknown as VmRepositoryShape; + const layer = Layer.merge(Layer.succeed(VmProviderGateway, gateway), Layer.succeed(VmRepository, repo)); + return { + forbiddenCalls, + probed, + snapshots, + run: (options) => Effect.runPromise(sweepVmHealth(options).pipe(Effect.provide(layer))), + }; +} + +/** A hung VM as the real driver sees it: no answer until the request's own timeout. */ +const hangUntilTimeout = (_vmId: string, timeoutMs: number) => + Effect.sleep(timeoutMs).pipe(Effect.as({ outcome: "hung", latencyMs: timeoutMs, detail: `timeout after ${timeoutMs}ms` })); + +describe("sweepVmHealth", () => { + test("is read-only toward the provider and stores one snapshot", async () => { + const h = harness({ + vms: [vm("vm-ok"), vm("vm-hung"), vm("vm-paused", { state: "paused" }), vm("vm-chat", { metadata: { app: "chatmux2" } })], + probe: (vmId) => Effect.succeed(vmId === "vm-hung" + ? { outcome: "hung", latencyMs: 10, detail: "409 conflict stale runtime instance responsive=false" } + : { outcome: "ok", latencyMs: 10 }), + }); + const result = await h.run({}); + expect(h.forbiddenCalls).toEqual([]); + // Paused VMs are never probed: a control-path request could wake them. + expect(h.probed.sort()).toEqual(["vm-chat", "vm-hung", "vm-ok"]); + expect(result.snapshotId).toBe("snap-1"); + expect(h.snapshots).toHaveLength(1); + expect(result.report.hung.map((entry) => entry.vmId)).toEqual(["vm-hung"]); + expect(result.report.tls?.totalCount).toBe(2303); + expect(result.report.attention.needed).toBe(true); + }); + + test("VMs with a finite idle timeout are not probed unless the operator opts in", async () => { + const h = harness({ vms: [vm("vm-idle", { idleTimeoutSeconds: 300 })], probe: () => Effect.succeed({ outcome: "ok", latencyMs: 1 }) }); + const skipped = await h.run({}); + expect(h.probed).toEqual([]); + expect(skipped.report.coverage.skippedIdle).toBe(1); + const opted = await h.run({ probeIdleVms: true }); + expect(h.probed).toEqual(["vm-idle"]); + expect(opted.report.coverage.skippedIdle).toBe(0); + }); + + test("first-seen-hung time survives into the next sweep", async () => { + const h = harness({ vms: [vm("vm-hung")], probe: () => Effect.succeed({ outcome: "hung", latencyMs: 1, detail: "timeout" }) }); + await h.run({}); + // Date the stored sighting in the past instead of waiting for time to pass. + const firstSeenHungAt = "2026-10-01T00:00:00.000Z"; + const previous = h.snapshots.at(-1)!; + h.snapshots.push({ ...previous, report: { ...previous.report, hung: previous.report.hung.map((entry) => ({ ...entry, firstSeenHungAt })) } }); + const second = await h.run({}); + expect(second.report.hung[0]!.firstSeenHungAt).toBe(firstSeenHungAt); + }); + + test("when every VM hangs, no probe starts past the deadline and the rest are reported skipped", async () => { + const vms = Array.from({ length: 50 }, (_, index) => vm(`vm-${index}`)); + const h = harness({ vms, probe: hangUntilTimeout }); + const budgetMs = 600; + const result = await h.run({ budgetMs, probeTimeoutMs: 200, probeConcurrency: 4 }); + const { coverage } = result.report; + expect(coverage.probed).toBeLessThan(50); + expect(coverage.probed).toBeGreaterThan(0); + expect(coverage.probed + coverage.skippedBudget).toBe(50); + expect(result.report.hung).toHaveLength(coverage.probed); + expect(result.report.attention.reasons).toContainEqual({ code: "sweep_incomplete", unprobed: coverage.skippedBudget }); + expect(h.snapshots).toHaveLength(1); + }); + + test("a provider listing failure is recorded, not thrown, and still stores a snapshot", async () => { + const stored: VmHealthReport[] = []; + const repo = { + latestVmHealthSnapshot: () => Effect.succeed(null), + insertVmHealthSnapshot: (report: VmHealthReport) => Effect.sync(() => { + stored.push(report); + return { id: "snap-x" }; + }), + } as unknown as VmRepositoryShape; + const gateway = { + listAccountVms: () => Effect.fail({ _tag: "VmProviderOperationError", operation: "listAccountVms", cause: new Error("503 upstream") }), + } as unknown as VmProviderGatewayShape; + const result = await Effect.runPromise(sweepVmHealth({}).pipe( + Effect.provide(Layer.merge(Layer.succeed(VmProviderGateway, gateway), Layer.succeed(VmRepository, repo))), + )); + expect(result.report.errors).toEqual([{ stage: "inventory", message: "VmProviderOperationError listAccountVms: 503 upstream" }]); + expect(result.report.attention.needed).toBe(true); + expect(stored).toHaveLength(1); + }); +}); + +describe("sweep storage and listing deadlines", () => { + test("a failed snapshot store still returns the report, so the cron can alert", async () => { + const repo = { + insertVmHealthSnapshot: () => Effect.fail({ _tag: "VmDatabaseError", operation: "insertVmHealthSnapshot", cause: new Error("relation does not exist") }), + } as unknown as VmRepositoryShape; + const gateway = { + listAccountVms: () => Effect.succeed({ vms: [vm("vm-hung")], complete: true }), + probeVmControl: () => Effect.succeed({ outcome: "hung", latencyMs: 1, detail: "timeout" }), + } as unknown as VmProviderGatewayShape; + const result = await Effect.runPromise(sweepVmHealth({}).pipe( + Effect.provide(Layer.merge(Layer.succeed(VmProviderGateway, gateway), Layer.succeed(VmRepository, repo))), + )); + expect(result.snapshotId).toBeNull(); + expect(result.storeError).toContain("insertVmHealthSnapshot"); + expect(result.report.hung).toHaveLength(1); + }); + + test("a listing that never answers is cut at the sweep deadline and recorded", async () => { + const gateway = { + listAccountVms: () => Effect.never, + listAccountTlsRules: () => Effect.never, + } as unknown as VmProviderGatewayShape; + const result = await Effect.runPromise(sweepVmHealth({ budgetMs: 200 }).pipe( + Effect.provide(Layer.merge(Layer.succeed(VmProviderGateway, gateway), Layer.succeed(VmRepository, {} as VmRepositoryShape))), + )); + expect(result.report.errors.map((error) => error.stage)).toEqual(["inventory", "tls_rules"]); + expect(result.report.attention.needed).toBe(true); + }); + + test("the stored report parses with the staff page schema", async () => { + const { cloudVmHealthSchema } = await import("../orpc/server/dashboard/schemas/ops"); + const h = harness({ vms: [vm("vm-ok"), vm("vm-hung")], probe: (vmId) => Effect.succeed(vmId === "vm-hung" + ? { outcome: "hung", latencyMs: 1, detail: "timeout" } + : { outcome: "ok", latencyMs: 1 }) }); + const { report } = await h.run({}); + const parsed = cloudVmHealthSchema.parse({ snapshot: { id: "s", createdAt: report.generatedAt, ageSeconds: 1, stale: false }, report, owners: {} }); + expect(parsed.report?.hung[0]?.vmId).toBe("vm-hung"); + }); +}); + +describe("GET /api/cron/vm-health", () => { + const previousSecret = process.env.CRON_SECRET; + beforeEach(() => { + process.env.CRON_SECRET = "test-cron-secret"; + }); + afterEach(() => { + if (previousSecret === undefined) delete process.env.CRON_SECRET; + else process.env.CRON_SECRET = previousSecret; + }); + + const cronRequest = (secret?: string) => new Request("https://cmux.com/api/cron/vm-health", { + headers: secret ? { authorization: `Bearer ${secret}` } : {}, + }); + + test("rejects a caller without the cron secret before sweeping", async () => { + let sweeps = 0; + const response = await handleVmHealthCron(cronRequest("wrong"), { + sweep: async () => { + sweeps += 1; + throw new Error("must not run"); + }, + alert: async () => "none", + env: {}, + }); + expect(response.status).toBe(401); + expect(sweeps).toBe(0); + }); + + test("with every probe hung, stops at the configured budget, reports skipped VMs, and alerts once", async () => { + const vms = Array.from({ length: 40 }, (_, index) => vm(`vm-${index}`)); + const h = harness({ vms, probe: hangUntilTimeout }); + const alerts: Array<{ hung: number; pageUrl: string | null }> = []; + const response = await handleVmHealthCron(cronRequest("test-cron-secret"), { + sweep: (options) => h.run(options).then((result) => ({ ...result, storeError: null, pruned: 0 })), + alert: async (report, pageUrl) => { + alerts.push({ hung: report.hung.length, pageUrl }); + return "sent"; + }, + env: { CMUX_VM_HEALTH_BUDGET_MS: "500", CMUX_VM_HEALTH_PROBE_TIMEOUT_MS: "150", CMUX_VM_HEALTH_PROBE_CONCURRENCY: "8" }, + }); + expect(response.status).toBe(200); + const body = await response.json() as { attentionNeeded: boolean; hung: number; coverage: { skippedBudget: number }; alert: string }; + expect(body.attentionNeeded).toBe(true); + expect(body.hung).toBeGreaterThan(0); + expect(body.coverage.skippedBudget).toBeGreaterThan(0); + expect(body.alert).toBe("sent"); + expect(alerts).toEqual([{ hung: body.hung, pageUrl: "https://cmux.com/dashboard/ops/cloud-vms" }]); + expect(h.forbiddenCalls).toEqual([]); + // The cron response carries counts, never VM owners. + expect(JSON.stringify(body)).not.toContain("user-"); + }); + + test("a failed snapshot store answers 500 after alerting", async () => { + const h = harness({ vms: [vm("vm-1")], probe: () => Effect.succeed({ outcome: "hung", latencyMs: 1, detail: "timeout" }) }); + let alerted = 0; + const response = await handleVmHealthCron(cronRequest("test-cron-secret"), { + sweep: (options) => h.run(options).then((result) => ({ ...result, snapshotId: null, storeError: "VmDatabaseError insertVmHealthSnapshot", pruned: 0 })), + alert: async () => { + alerted += 1; + return "sent"; + }, + env: {}, + }); + expect(response.status).toBe(500); + expect(alerted).toBe(1); + }); + + test("operator budget overrides are capped inside the route's maxDuration", async () => { + const { sweepOptionsFromEnv } = await import("../services/vms/health/cron"); + expect(sweepOptionsFromEnv({ CMUX_VM_HEALTH_BUDGET_MS: "9999999" }).budgetMs).toBe(240_000); + expect(sweepOptionsFromEnv({ CMUX_VM_HEALTH_BUDGET_MS: "2,000" }).budgetMs).toBeUndefined(); + }); +}); diff --git a/web/vercel.json b/web/vercel.json index f12c587022ad..b333c5840383 100644 --- a/web/vercel.json +++ b/web/vercel.json @@ -27,6 +27,10 @@ "path": "/api/cron/vm-alerts", "schedule": "*/5 * * * *" }, + { + "path": "/api/cron/vm-health", + "schedule": "4,9,14,19,24,29,34,39,44,49,54,59 * * * *" + }, { "path": "/api/cron/coderouter-alerts", "schedule": "2,7,12,17,22,27,32,37,42,47,52,57 * * * *" From 7a117d8b6d2833df41d496a9f36bc16b864e9931 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 04:12:55 -0700 Subject: [PATCH 040/183] Keep agent Running/Idle accurate when a Stop hook never arrives (#20564) * Settle stale agent Running states without a Stop hook Claude Code runs no Stop hook when Esc interrupts a turn, and a Stop hook that times out or fails is never delivered, so the sidebar kept showing Running. Claude Code reports its own activity as an OSC 7501 working record; when that record ends and the pane is still running after a short grace period, journal the turn end and show Idle. The Codex transcript monitor treated any closed connection as a deleted pane and quit within its 2s grace, and it replayed Stop on a connection it had held for the whole turn. Reconnect before deciding the pane is gone, and replay on a fresh connection. Co-Authored-By: Claude Opus 5.5 * Settle from the activity edge and let later hook reports win Review fixes: stamp the settlement at the moment Claude Code's working record ended and skip it when a hook reported after that edge, so a late Stop keeps its notification and a new turn keeps running. Keep one pending settlement per panel and key, restarted by each edge and cancelled when the agent works again. Let the journal project the lifecycle when the session is known. Make the grace wait injectable and document the program status exception. Co-Authored-By: Claude Opus 5.5 * Never settle a Waiting or subagent row from program status Dogfood: Claude backgrounded a command, its Stop left the row on Waiting, and the OSC 7501 Done edge then replaced Waiting with Idle. Only a row the hooks marked as foreground running is stale. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CLI/cmux.swift | 17 ++- .../Status/ProgramStatusAgentSettlement.swift | 52 +++++++++ .../ProgramStatusAgentSettlementTests.swift | 69 +++++++++++ Sources/Workspace+ProgramStatus.swift | 107 ++++++++++++++++++ Sources/Workspace.swift | 3 + docs/program-status.md | 7 ++ 6 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/ProgramStatusAgentSettlement.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/ProgramStatusAgentSettlementTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ee4d00f2ae7f..9b19a2d7e5d5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -34352,7 +34352,18 @@ struct CMUXCLI { } private func codexMonitorOwnerState(workspaceId: String, surfaceId: String?, client: SocketClient) -> CodexMonitorOwnerState { - guard client.connectionAppearsOpen() else { return client.isRelayBacked ? .unknown : .gone } + if !client.connectionAppearsOpen() { + // A failed request closes the client, so a closed connection is + // not proof the pane is gone. Reconnect and ask; only an app that + // refuses the connection ends the monitor. + client.close() + guard !client.isRelayBacked else { return .unknown } + do { + try client.connect() + } catch { + return .gone + } + } guard let payload = try? client.sendV2( method: "surface.list", params: ["workspace_id": workspaceId], @@ -38261,6 +38272,10 @@ export default { ) else { return } + // The monitor held this connection for the whole turn. Replay the + // Stop on a fresh one so a connection the app dropped or stalled + // cannot delay the Idle projection by a response timeout per call. + client.close() try runGenericAgentHookEvent( def: def, commandArgs: replay.commandArguments, diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/ProgramStatusAgentSettlement.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/ProgramStatusAgentSettlement.swift new file mode 100644 index 000000000000..e5ffd31373da --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/ProgramStatusAgentSettlement.swift @@ -0,0 +1,52 @@ +/// Decides when an agent's own OSC 7501 report proves its turn ended. +/// +/// Agent hooks write the sidebar's "Running" row, and a turn that ends without +/// its Stop hook leaves that row stuck: Claude Code runs no Stop hook when the +/// user interrupts a turn with Esc, and a Stop hook that times out or fails is +/// never delivered. Claude Code also reports its own activity as an OSC 7501 +/// `working` record. When that record leaves the working and blocked states, +/// the agent itself says the turn is over, so a pane still marked running can +/// settle without guessing from timers or output. +public struct ProgramStatusAgentSettlement: Sendable { + /// OSC 7501 `app` names that map to an agent status key. + private static let statusKeysByApp: [String: String] = [ + "claude-code": "claude_code", + ] + + public init() {} + + /// The agent status key an OSC 7501 app reports for, if any. + public func statusKey(forApp app: String?) -> String? { + guard let app else { return nil } + return Self.statusKeysByApp[app.lowercased()] + } + + /// Agent status keys whose app had a working or blocked record before a + /// change and has none after it. + /// + /// - Parameters: + /// - previous: The panel's records before the change. + /// - current: The panel's records after the change. + /// - Returns: The keys whose agent reported the end of its activity. + public func endedActivityStatusKeys( + previous: ProgramStatusRecordStore, + current: ProgramStatusRecordStore + ) -> Set { + activeStatusKeys(in: previous).subtracting(activeStatusKeys(in: current)) + } + + /// Whether the panel still has a working or blocked record for the key. + public func isActive(statusKey: String, in store: ProgramStatusRecordStore) -> Bool { + activeStatusKeys(in: store).contains(statusKey) + } + + private func activeStatusKeys(in store: ProgramStatusRecordStore) -> Set { + var keys = Set() + for record in store.records where record.state == .working || record.state == .blocked { + if let key = statusKey(forApp: store.effectiveApp(for: record)) { + keys.insert(key) + } + } + return keys + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/ProgramStatusAgentSettlementTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/ProgramStatusAgentSettlementTests.swift new file mode 100644 index 000000000000..72ede99274a8 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/ProgramStatusAgentSettlementTests.swift @@ -0,0 +1,69 @@ +import CmuxSidebar +import Testing + +@Suite("OSC 7501 agent turn settlement") +struct ProgramStatusAgentSettlementTests { + private let settlement = ProgramStatusAgentSettlement() + + private func store(_ reports: [ProgramStatusReport]) -> ProgramStatusRecordStore { + var store = ProgramStatusRecordStore() + for report in reports { store.apply(report) } + return store + } + + @Test func interruptClearEndsClaudeActivity() { + // Esc: Claude Code clears its working record and runs no Stop hook. + let working = store([ProgramStatusReport(state: .working, app: "claude-code")]) + var cleared = working + cleared.apply(ProgramStatusReport(state: .clear)) + #expect(settlement.endedActivityStatusKeys(previous: working, current: cleared) == ["claude_code"]) + #expect(!settlement.isActive(statusKey: "claude_code", in: cleared)) + } + + @Test func doneAndIdleReportsEndClaudeActivity() { + let working = store([ProgramStatusReport(state: .working, app: "claude-code")]) + for state in [ProgramStatusState.done, .idle, .error] { + var ended = working + ended.apply(ProgramStatusReport(state: state, app: "claude-code")) + #expect(settlement.endedActivityStatusKeys(previous: working, current: ended) == ["claude_code"]) + } + } + + @Test func blockedStaysActive() { + // A permission prompt is not the end of the turn. + let working = store([ProgramStatusReport(state: .working, app: "claude-code")]) + var blocked = working + blocked.apply(ProgramStatusReport(state: .blocked, kind: .permission, app: "claude-code")) + #expect(settlement.endedActivityStatusKeys(previous: working, current: blocked).isEmpty) + #expect(settlement.isActive(statusKey: "claude_code", in: blocked)) + } + + @Test func childExitDropEndsActivity() { + var store = store([ProgramStatusReport(state: .working, app: "claude-code")]) + let previous = store + store.dropTransient() + #expect(settlement.endedActivityStatusKeys(previous: previous, current: store) == ["claude_code"]) + } + + @Test func unknownAppsAndNewTurnsDoNotSettle() { + let other = store([ProgramStatusReport(state: .working, app: "cargo")]) + var otherDone = other + otherDone.apply(ProgramStatusReport(state: .done, app: "cargo")) + #expect(settlement.endedActivityStatusKeys(previous: other, current: otherDone).isEmpty) + + // A pane that was idle and starts working has not ended anything. + let idle = store([ProgramStatusReport(state: .done, app: "claude-code")]) + var working = idle + working.apply(ProgramStatusReport(state: .working, app: "claude-code")) + #expect(settlement.endedActivityStatusKeys(previous: idle, current: working).isEmpty) + #expect(settlement.isActive(statusKey: "claude_code", in: working)) + } + + @Test func inheritedAppNameCounts() { + let working = store([ + ProgramStatusReport(state: .done, id: "", app: "Claude-Code"), + ProgramStatusReport(state: .working, id: "turn"), + ]) + #expect(settlement.isActive(statusKey: "claude_code", in: working)) + } +} diff --git a/Sources/Workspace+ProgramStatus.swift b/Sources/Workspace+ProgramStatus.swift index 01d01fa75fa5..b7d6b5feceb1 100644 --- a/Sources/Workspace+ProgramStatus.swift +++ b/Sources/Workspace+ProgramStatus.swift @@ -1,3 +1,4 @@ +import CmuxAgentJournal import CmuxSidebar import Foundation @@ -11,8 +12,10 @@ extension Workspace { // reported program status have nothing to drop. A clear has nothing to remove either. if report.event == .promptStart || report.state == .clear, programStatusStoresByPanelId[panelId] == nil { return } + let previousStore = programStatusStoresByPanelId[panelId] ?? ProgramStatusRecordStore() let updatedStore = updateProgramStatusStore(panelId: panelId) { $0.apply(report) } guard let store = updatedStore else { return } + scheduleAgentTurnSettlement(panelId: panelId, previous: previousStore, current: store) CmuxEventBus.shared.publishProgramStatusChanged( workspaceId: id, surfaceId: panelId, @@ -23,8 +26,10 @@ extension Workspace { /// Removes working and blocked records after prompt-idle or child exit. func dropTransientProgramStatus(panelId: UUID) { + let previousStore = programStatusStoresByPanelId[panelId] ?? ProgramStatusRecordStore() let updatedStore = updateProgramStatusStore(panelId: panelId, createIfMissing: false) { $0.dropTransient() } guard let store = updatedStore else { return } + scheduleAgentTurnSettlement(panelId: panelId, previous: previousStore, current: store) CmuxEventBus.shared.publishProgramStatusChanged( workspaceId: id, surfaceId: panelId, @@ -192,4 +197,106 @@ extension Workspace { guard !text.isEmpty else { return nil } return String(text.prefix(512)) } + + /// How long an agent's own end-of-activity report waits for the turn's + /// Stop hook before the pane settles without it. A Stop that is merely + /// late keeps its notification and summary; one that never comes (an Esc + /// interrupt, a timed-out or failed hook) no longer leaves "Running". + static let agentTurnSettlementGrace: Duration = .seconds(3) + + /// The bounded wait before a settlement re-checks the pane. Tests replace + /// it to run the grace without real time. + nonisolated(unsafe) static var agentTurnSettlementWait: @Sendable (Duration) async throws -> Void = { duration in + try await Task.sleep(for: duration) + } + + /// Settles a pane whose agent reported the end of its activity over + /// OSC 7501 while its hook-driven lifecycle still says running. + /// + /// Each change for a panel restarts its pending settlement, so the grace + /// always runs from the latest edge, and a new working report cancels it. + private func scheduleAgentTurnSettlement( + panelId: UUID, + previous: ProgramStatusRecordStore, + current: ProgramStatusRecordStore + ) { + let settlement = ProgramStatusAgentSettlement() + for (taskKey, task) in agentTurnSettlementTasks where taskKey.hasPrefix(panelId.uuidString) { + let statusKey = String(taskKey.dropFirst(panelId.uuidString.count + 1)) + if settlement.isActive(statusKey: statusKey, in: current) { + task.cancel() + agentTurnSettlementTasks.removeValue(forKey: taskKey) + } + } + let endedKeys = settlement.endedActivityStatusKeys(previous: previous, current: current) + .filter { agentLifecycleStatesByPanelId[panelId]?[$0] == .running } + let edge = Date() + for key in endedKeys { + let taskKey = "\(panelId.uuidString)|\(key)" + agentTurnSettlementTasks[taskKey]?.cancel() + agentTurnSettlementTasks[taskKey] = Task { @MainActor [weak self] in + do { + try await Self.agentTurnSettlementWait(Self.agentTurnSettlementGrace) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + self.agentTurnSettlementTasks.removeValue(forKey: taskKey) + self.settleAgentTurnFromProgramStatus(key: key, panelId: panelId, edge: edge) + } + } + } + + /// Re-checks the pane after the grace, then settles it at the time the + /// agent's activity ended. Any hook report after that edge wins: a late + /// Stop already settled the turn, and a new prompt started another one. + func settleAgentTurnFromProgramStatus(key: String, panelId: UUID, edge: Date) { + guard panels[panelId] != nil, + agentLifecycleStatesByPanelId[panelId]?[key] == .running else { return } + // Only a foreground Running row is stale. A Waiting or subagent row + // means the turn ended with background work still live. + guard let reported = agentStatusEntriesByPanelId[panelId]?[key], + reported.workState == .running, + reported.timestamp <= edge else { return } + let store = programStatusStoresByPanelId[panelId] ?? ProgramStatusRecordStore() + guard !ProgramStatusAgentSettlement().isActive(statusKey: key, in: store) else { return } + let source = key == "claude_code" ? "claude" : key +#if DEBUG + cmuxDebugLog("agentTurn.settleFromProgramStatus panel=\(panelId.uuidString.prefix(8)) key=\(key)") +#endif + let sessionID = surfaceResumeBindingsByPanelId[panelId].flatMap { binding -> String? in + guard binding.isAgentHookBinding, + binding.kind?.lowercased() == source else { return nil } + return binding.checkpointId + } + let draft = sessionID.map { sessionID in + AgentJournalEventDraft( + kind: .turnCompleted, + occurredAtMs: Int64(edge.timeIntervalSince1970 * 1_000), + source: source, + agentKey: key, + sessionId: sessionID, + workspaceId: id.uuidString, + surfaceId: panelId.uuidString, + nativeEvent: "program_status_idle" + ) + } + if let draft, draft.validationProblem() == nil { + // The journal projects the lifecycle, so it and the reducer agree. + AgentJournalLifecycleCenter.shared.enqueueAppend(draft) + } else { + setAgentLifecycle(key: key, panelId: panelId, lifecycle: .idle) + } + setStatusEntry( + SidebarStatusEntry( + key: key, + value: String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle"), + icon: "pause.circle.fill", + color: "#8E8E93", + timestamp: edge + ), + key: key, + panelId: panelId + ) + } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2ba9aed3e4ee..4f604246166e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3194,6 +3194,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos var agentStatusEntriesByPanelId: [UUID: [String: SidebarStatusEntry]] = [:] var programStatusStoresByPanelId: [UUID: ProgramStatusRecordStore] = [:] var programStatusUrgencyByPanelId: [UUID: Int] = [:] + /// One pending agent turn settlement per panel and status key; see + /// `scheduleAgentTurnSettlement` in Workspace+ProgramStatus.swift. + var agentTurnSettlementTasks: [String: Task] = [:] var metadataBlocks: [String: SidebarMetadataBlock] { get { sidebarMetadata.metadataBlocks } set { sidebarMetadata.metadataBlocks = newValue } diff --git a/docs/program-status.md b/docs/program-status.md index 9fcbc11ad2c5..d9b49ab7cc24 100644 --- a/docs/program-status.md +++ b/docs/program-status.md @@ -32,3 +32,10 @@ label such as "Needs permission". Program status rows are terminal state, not agent state: they do not change agent hibernation, agent detection, or desktop notifications. Use `cmux notify` or OSC 9/99/777 for notifications. + +One exception keeps agent rows honest. Claude Code reports its turns as `app=claude-code` +records. When such a `working` or `blocked` record ends (a `done`, `idle`, `error` or `clear` +report, a prompt start, or the process exiting) while that pane's Claude Code row still says +Running, and no Claude Code hook reports anything for 3 seconds, cmux marks the turn finished and +shows Idle. This covers turns that end without a Stop hook, such as an Esc interrupt or a Stop hook +that timed out. It never sends a notification, and it only moves a pane from Running to Idle. From 787f7c48d27033fc1e6192463f5af90869f66ca1 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 04:25:50 -0700 Subject: [PATCH 041/183] Keep restored cmux ssh workspaces connecting instead of showing SSH errors (#20586) * Keep restored cmux ssh workspaces connecting while the attach retries A restored cmux ssh workspace made one attach attempt and published .error on its first failure, writing an SSH error status line, log line and notification. The terminal mirrors then reconnected the carrier on their own, but nothing moved the workspace back to connected, so the errors stayed after the connection came up. Retry retryable attach failures on a bounded backoff while the workspace stays connecting, retry at once when the machine link comes up, and park a reported failure so a later link-up still recovers it and retracts the error. Co-Authored-By: Claude Opus 5.5 * Bound restored SSH attach retries and settle cancelled or disconnected attempts Co-Authored-By: Claude Opus 5.5 * Fix the backoff sleeper's task type Co-Authored-By: Claude Opus 5.5 * Keep retrying a reported SSH restore failure so a returning host recovers Dogfood showed a host that stayed down past the retry schedule kept its error after it came back, because nothing else reconnected the link. After reporting a non-permanent failure, retry quietly every 30 seconds (a link-up still retries at once); success retracts the error. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Sources/RemoteTui/SSHTuiAttachRetry.swift | 136 ++++++++++++ .../SSHTuiWorkspaceCoordinator.swift | 203 ++++++++++++++++-- cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/SSHTuiAttachRetryTests.swift | 203 ++++++++++++++++++ 4 files changed, 530 insertions(+), 20 deletions(-) create mode 100644 Sources/RemoteTui/SSHTuiAttachRetry.swift create mode 100644 cmuxTests/SSHTuiAttachRetryTests.swift diff --git a/Sources/RemoteTui/SSHTuiAttachRetry.swift b/Sources/RemoteTui/SSHTuiAttachRetry.swift new file mode 100644 index 000000000000..19c4af2bcfe5 --- /dev/null +++ b/Sources/RemoteTui/SSHTuiAttachRetry.swift @@ -0,0 +1,136 @@ +import CmuxCloud +import CmuxSurfaceCatalogModel +import Foundation + +/// How a restored or reconnecting `cmux ssh` workspace retries its attach. +/// +/// After a relaunch the first attach often runs before the network is ready +/// (Tailscale, DNS or Wi-Fi still coming up). One failure there is not a +/// workspace failure, so a retryable error keeps the workspace `.connecting` +/// and retries after a short backoff. Only an error that retrying cannot fix, +/// or a run of failures that outlasts the backoff schedule, reaches the sidebar. +struct SSHTuiAttachRetryPolicy: Sendable { + /// Delay before each retry; the attach reports its error once these run out. + let delays: [Duration] + /// Cadence of the quiet retries that follow a reported, retryable error. + var reportedFailureRetryInterval: Duration = .seconds(30) + + static let standard = SSHTuiAttachRetryPolicy( + delays: [.seconds(1), .seconds(2), .seconds(4), .seconds(8), .seconds(15), .seconds(15)] + ) + + /// The delay before the next attempt after `failureCount` consecutive + /// failures ending in `error`, or nil when the attach should report it. + func retryDelay(afterFailure failureCount: Int, error: Error) -> Duration? { + guard Self.isRetryable(error), failureCount >= 1, failureCount <= delays.count else { return nil } + return delays[failureCount - 1] + } + + /// Errors worth another attempt while the workspace is still connecting. + static func isRetryable(_ error: Error) -> Bool { + if isPermanent(error) { return false } + // The carrier already waited out its own connect deadline (minutes, + // with internal retries); another fast lap only delays the report. + if case CloudMachineLink.LinkError.timedOut? = error as? CloudMachineLink.LinkError { return false } + return true + } + + /// Errors that no later attempt can fix, so a reported failure stops + /// retrying in the background and waits for Reconnect or a link-up. + static func isPermanent(_ error: Error) -> Bool { + if error is CancellationError { return true } + if let failure = error as? CloudDiagnosticFailure, failure == .unsupported { return true } + if case CloudMachineLink.LinkError.clientMissing? = error as? CloudMachineLink.LinkError { return true } + return CloudMachineAccessLoss(error: error) != nil + } + + /// Whether a waiting or failed attach should retry now instead of + /// waiting out its backoff. The provider's terminal mirrors keep + /// reconnecting the carrier on their own; once the machine link reports + /// connected, the workspace attach should follow immediately. A link that + /// was already connected when the attach failed does not trigger this, so + /// an attach that fails for another reason cannot spin on every catalog change. + static func shouldRetryNow(linkWasConnectedAtFailure: Bool, linkState: SurfaceLinkState?) -> Bool { + !linkWasConnectedAtFailure && linkState == .connected + } +} + +/// A backoff wait that a link-up event can end early. +@MainActor +final class SSHTuiAttachBackoff { + private var sleeper: Task? + /// A wake that arrived before the wait began ends that wait at once. + private var wakeRequested = false + + /// Waits `delay` on `clock`, or until `wake()`, or until the caller's task is cancelled. + func wait(_ delay: Duration, clock: any Clock) async { + if wakeRequested { + wakeRequested = false + return + } + let sleeper = Task { _ = try? await clock.sleep(for: delay) } + self.sleeper = sleeper + await withTaskCancellationHandler { + await sleeper.value + } onCancel: { + sleeper.cancel() + } + if self.sleeper == sleeper { self.sleeper = nil } + } + + func wake() { + if let sleeper { + sleeper.cancel() + } else { + wakeRequested = true + } + } +} + +/// Runs one attach with bounded retries. Kept separate from the coordinator +/// so the state transitions can be tested without an SSH carrier. +enum SSHTuiAttachRetry { + enum Outcome { + case connected + case failed(Error) + case cancelled + } + + /// Calls `attempt` until it succeeds, is cancelled, or `policy` gives up. + /// + /// `attempt` receives a predicate telling it whether a given error will be + /// retried, so it can keep a reserved pane pending instead of failing it + /// between attempts. `failed` runs before each backoff wait so the caller + /// can record whether the machine link was up when the attempt failed, + /// and `retrying` runs when that wait ends, before the next attempt. + @MainActor + static func run( + policy: SSHTuiAttachRetryPolicy, + clock: any Clock, + backoff: SSHTuiAttachBackoff, + failed: @MainActor (Error) -> Void = { _ in }, + retrying: @MainActor () -> Void = {}, + attempt: @MainActor (_ willRetry: (Error) -> Bool) async throws -> Void + ) async -> Outcome { + var failures = 0 + while true { + if Task.isCancelled { return .cancelled } + let nextFailureCount = failures + 1 + do { + try await attempt { policy.retryDelay(afterFailure: nextFailureCount, error: $0) != nil } + return .connected + } catch is CancellationError { + return .cancelled + } catch { + if Task.isCancelled { return .cancelled } + failures = nextFailureCount + guard let delay = policy.retryDelay(afterFailure: failures, error: error) else { + return .failed(error) + } + failed(error) + await backoff.wait(delay, clock: clock) + retrying() + } + } + } +} diff --git a/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift b/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift index 1dc33a24e006..98fd603aa587 100644 --- a/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift +++ b/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift @@ -15,12 +15,36 @@ final class SSHTuiWorkspaceCoordinator { private let paths: CloudTuiClientPaths private var attempts: [UUID: Task] = [:] private let agentStatus: SSHTuiAgentStatusProjector + private let retryPolicy: SSHTuiAttachRetryPolicy + private let clock: any Clock + /// Restored attaches that failed and are waiting to try again, either in + /// a backoff wait or parked after reporting their error. + private var recoveries: [UUID: Recovery] = [:] + private var catalogObserver: NSObjectProtocol? - init(catalog: SurfaceCatalog, clientURL: @escaping () -> URL?, paths: CloudTuiClientPaths) { + private struct Recovery { + weak var workspace: Workspace? + let configuration: WorkspaceRemoteConfiguration + let machine: SurfaceMachineID + /// Cleared when the link is later seen down, so its next link-up retries. + var linkWasConnectedAtFailure: Bool + /// Set while the attach waits out a backoff; nil once it has parked. + let backoff: SSHTuiAttachBackoff? + } + + init(catalog: SurfaceCatalog, clientURL: @escaping () -> URL?, paths: CloudTuiClientPaths, + retryPolicy: SSHTuiAttachRetryPolicy = .standard, clock: any Clock = ContinuousClock()) { self.catalog = catalog self.clientURL = clientURL self.paths = paths + self.retryPolicy = retryPolicy + self.clock = clock agentStatus = SSHTuiAgentStatusProjector(catalog: catalog) + catalogObserver = NotificationCenter.default.addObserver( + forName: SurfaceCatalog.didChangeNotification, object: catalog, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.retryRecoveriesWhoseLinkCameUp() } + } } nonisolated static func usesDurableCreationReceipt(machineID: String, restoring: Bool) -> Bool { @@ -48,25 +72,139 @@ final class SSHTuiWorkspaceCoordinator { return configuration } + /// Restores and reconnects an existing SSH workspace. + /// + /// The workspace stays `.connecting` while retryable failures back off, + /// so a relaunch that races network startup does not flash SSH errors. func connect(workspace: Workspace, configuration: WorkspaceRemoteConfiguration) { for projection in catalog.projections where projection.workspaceID == workspace.id && projection.resource.machine.isSSH { let provider = catalog.provider(for: projection.resource.machine) as? CmuxTuiSurfaceProvider _ = provider?.manualMirrorSessions[projection.panelID]?.retryConnection() } + startAttempt(workspace: workspace, configuration: configuration) + } + + private func startAttempt(workspace: Workspace, configuration: WorkspaceRemoteConfiguration) { attempts.removeValue(forKey: workspace.id)?.cancel() + recoveries.removeValue(forKey: workspace.id) let attemptID = UUID() workspace.sshTuiConnectionAttemptID = attemptID + if workspace.remoteConnectionState != .connecting { + workspace.applyRemoteConnectionStateUpdate(.connecting, detail: nil, target: configuration.displayTarget) + } + let machine = SurfaceMachineID(rawValue: SSHTuiConnection(configuration: configuration).id) attempts[workspace.id] = Task { [weak self, weak workspace] in guard let self, let workspace else { return } defer { if workspace.sshTuiConnectionAttemptID == attemptID { self.attempts[workspace.id] = nil } } + let backoff = SSHTuiAttachBackoff() + let outcome = await SSHTuiAttachRetry.run( + policy: self.retryPolicy, + clock: self.clock, + backoff: backoff, + failed: { [weak self, weak workspace] error in + guard let self, let workspace, workspace.sshTuiConnectionAttemptID == attemptID else { return } + sshTuiWorkspaceLogger.info("restored SSH attach failed, retrying: \(CloudMachineLink.errorText(error), privacy: .public)") + self.recoveries[workspace.id] = Recovery( + workspace: workspace, configuration: configuration, machine: machine, + linkWasConnectedAtFailure: self.catalog.machines[machine]?.linkState == .connected, + backoff: backoff + ) + }, + retrying: { [weak self, weak workspace] in + // A wait that ended takes no further wakes; a stale one + // would cut the next failure's backoff short. + guard let self, let workspace, + self.recoveries[workspace.id]?.backoff === backoff else { return } + self.recoveries.removeValue(forKey: workspace.id) + }, + attempt: { willRetry in + try await self.attach(workspace: workspace, configuration: configuration, attemptID: attemptID, + restoring: true, keepsReservationPending: willRetry) + } + ) + guard workspace.sshTuiConnectionAttemptID == attemptID else { return } + switch outcome { + case .connected: + self.recoveries.removeValue(forKey: workspace.id) + case .cancelled: + // Still current, so nothing replaced this attempt: remote + // connections were turned off. Do not leave it connecting. + self.recoveries.removeValue(forKey: workspace.id) + workspace.applyRemoteConnectionStateUpdate(.disconnected, detail: nil, target: configuration.displayTarget) + case .failed(let error): + workspace.applyRemoteConnectionStateUpdate(.error, detail: CloudMachineLink.errorText(error), target: configuration.displayTarget) + guard !SSHTuiAttachRetryPolicy.isPermanent(error) else { + // Park so a later link-up still recovers the workspace + // and retracts this error, without a manual Reconnect. + self.recoveries[workspace.id] = Recovery( + workspace: workspace, configuration: configuration, machine: machine, + linkWasConnectedAtFailure: self.catalog.machines[machine]?.linkState == .connected, + backoff: nil + ) + return + } + await self.retryQuietlyAfterReportedFailure(workspace: workspace, configuration: configuration, + machine: machine, attemptID: attemptID) + } + } + } + + /// Keeps a reported, retryable failure trying on a slow cadence while the + /// workspace stays `.error`, so a host that comes back recovers and the + /// error retracts without a manual Reconnect. A link-up ends each wait early. + private func retryQuietlyAfterReportedFailure(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, + machine: SurfaceMachineID, attemptID: UUID) async { + while !Task.isCancelled, workspace.sshTuiConnectionAttemptID == attemptID { + let backoff = SSHTuiAttachBackoff() + recoveries[workspace.id] = Recovery( + workspace: workspace, configuration: configuration, machine: machine, + linkWasConnectedAtFailure: catalog.machines[machine]?.linkState == .connected, + backoff: backoff + ) + await backoff.wait(retryPolicy.reportedFailureRetryInterval, clock: clock) + guard !Task.isCancelled, workspace.sshTuiConnectionAttemptID == attemptID else { return } + if recoveries[workspace.id]?.backoff === backoff { recoveries.removeValue(forKey: workspace.id) } do { - try await self.attach(workspace: workspace, configuration: configuration, attemptID: attemptID, restoring: true) - } catch is CancellationError { + try await attach(workspace: workspace, configuration: configuration, attemptID: attemptID, restoring: true) + return } catch { - guard workspace.sshTuiConnectionAttemptID == attemptID else { return } - workspace.applyRemoteConnectionStateUpdate(.error, detail: CloudMachineLink.errorText(error), target: configuration.displayTarget) + guard workspace.sshTuiConnectionAttemptID == attemptID, !(error is CancellationError) else { return } + if SSHTuiAttachRetryPolicy.isPermanent(error) { + workspace.applyRemoteConnectionStateUpdate(.error, detail: CloudMachineLink.errorText(error), target: configuration.displayTarget) + return + } + } + } + } + + /// The provider's terminal mirrors reconnect the carrier on their own. + /// When that brings the machine link up, a waiting or parked restore + /// attach retries at once so the workspace reports connected promptly. + private func retryRecoveriesWhoseLinkCameUp() { + guard !recoveries.isEmpty else { return } + for (workspaceID, recovery) in recoveries { + let linkState = catalog.machines[recovery.machine]?.linkState + guard SSHTuiAttachRetryPolicy.shouldRetryNow( + linkWasConnectedAtFailure: recovery.linkWasConnectedAtFailure, + linkState: linkState + ) else { + if linkState != .connected, recovery.linkWasConnectedAtFailure { + recoveries[workspaceID]?.linkWasConnectedAtFailure = false + } + continue + } + if let backoff = recovery.backoff { + // One early retry per failure; the next failure records a fresh link state. + recoveries.removeValue(forKey: workspaceID) + backoff.wake() + } else { + recoveries.removeValue(forKey: workspaceID) + guard let workspace = recovery.workspace, !workspace.isRetiredFromOwningTabManager, + workspace.remoteConfiguration.map({ SSHTuiConnection(configuration: $0).id }) == recovery.machine.rawValue + else { continue } + startAttempt(workspace: workspace, configuration: recovery.configuration) } } } @@ -85,6 +223,7 @@ final class SSHTuiWorkspaceCoordinator { func open(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, initialCommand: [String]? = nil, focus: Bool = true) async throws { attempts.removeValue(forKey: workspace.id)?.cancel() + recoveries.removeValue(forKey: workspace.id) let attemptID = UUID() workspace.sshTuiConnectionAttemptID = attemptID let restoring = workspace.remoteConfiguration != nil @@ -93,7 +232,29 @@ final class SSHTuiWorkspaceCoordinator { try await attach(workspace: workspace, configuration: configuration, attemptID: attemptID, initialCommand: initialCommand, restoring: restoring, focus: focus) } - private func attach(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, attemptID: UUID, initialCommand: [String]? = nil, restoring: Bool = false, focus: Bool = false) async throws { + /// `keepsReservationPending` names the errors the caller will retry, so a + /// reserved pane stays pending between attempts instead of failing. + private func attach(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, attemptID: UUID, initialCommand: [String]? = nil, restoring: Bool = false, focus: Bool = false, + keepsReservationPending: (Error) -> Bool = { _ in false }) async throws { + let held = ReservationHold() + do { + try await attachBody(workspace: workspace, configuration: configuration, attemptID: attemptID, + initialCommand: initialCommand, restoring: restoring, focus: focus, held: held) + } catch { + if workspace.sshTuiConnectionAttemptID == attemptID, let reservation = held.reservation, !keepsReservationPending(error) { + workspace.failReservedCloudTerminalPane(reservation, error: CloudDiagnosticFailure.network) + } + throw error + } + } + + /// The pane an attach reserved and has not yet handed to a projection. + private final class ReservationHold { + var reservation: CloudTerminalPaneReservation? + } + + private func attachBody(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, attemptID: UUID, initialCommand: [String]?, + restoring: Bool, focus: Bool, held: ReservationHold) async throws { let discardedLegacyDescriptor = configuration.restoredSSHSession != nil && Self.configurationForAttach(configuration).restoredSSHSession == nil let preparedConfiguration = Self.configurationForAttach(configuration) @@ -112,13 +273,7 @@ final class SSHTuiWorkspaceCoordinator { } let provider = try provider(connection: connection) let machine = provider.machine - var reservation = reserveInitialTerminal(workspace: workspace, machine: machine, configuration: configuration, focus: focus) - var completed = false - defer { - if !completed, workspace.sshTuiConnectionAttemptID == attemptID, let reservation { - workspace.failReservedCloudTerminalPane(reservation, error: CloudDiagnosticFailure.network) - } - } + held.reservation = reserveInitialTerminal(workspace: workspace, machine: machine, configuration: configuration, focus: focus) if let saved = configuration.restoredSSHSession, saved.sshSessionOwner != "cmux-tui" { // Use the same eligibility check as snapshot conversion, including // the legacy default when terminalTransport was not persisted. @@ -149,7 +304,6 @@ final class SSHTuiWorkspaceCoordinator { try requireCurrent(workspace: workspace, attemptID: attemptID) if !configuration.preserveAfterTerminalExit { workspace.applyRemoteConnectionStateUpdate(.connected, detail: nil, target: configuration.displayTarget) - completed = true return } let existing = catalog.projections.filter { $0.workspaceID == workspace.id && $0.resource.machine == machine } @@ -162,11 +316,11 @@ final class SSHTuiWorkspaceCoordinator { let group = try catalog.remoteWorkspaceGroup(machine: machine, workspaceID: remoteID) for placement in group.placements { let result = try await catalog.project(placement.resource, into: .workspace(id: workspace.id, placement: .tab), - focus: false, adopting: reservation) + focus: false, adopting: held.reservation) try requireCurrent(workspace: workspace, attemptID: attemptID) - if let pending = reservation { + if let pending = held.reservation { workspace.completeReservedCloudTerminalPane(pending, adoptedPanelID: result.projection.panelID) - reservation = nil + held.reservation = nil } } } else { @@ -201,11 +355,13 @@ final class SSHTuiWorkspaceCoordinator { } workspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: connection.id, isBase: false, remoteWorkspaceID: remoteID) let projected = try await catalog.project(resource.id, into: .workspace(id: workspace.id, placement: .tab), - focus: false, adopting: reservation) + focus: false, adopting: held.reservation) try requireCurrent(workspace: workspace, attemptID: attemptID) - if let reservation { workspace.completeReservedCloudTerminalPane(reservation, adoptedPanelID: projected.projection.panelID) } + if let reservation = held.reservation { + workspace.completeReservedCloudTerminalPane(reservation, adoptedPanelID: projected.projection.panelID) + held.reservation = nil + } } - completed = true workspace.applyRemoteConnectionStateUpdate(.connected, detail: nil, target: configuration.displayTarget) } @@ -266,6 +422,13 @@ final class SSHTuiWorkspaceCoordinator { func disconnect(workspace: Workspace) { workspace.sshTuiConnectionAttemptID = nil attempts.removeValue(forKey: workspace.id)?.cancel() + if recoveries.removeValue(forKey: workspace.id)?.backoff != nil { + // A backoff keeps the reserved pane pending for the next attempt. + // No attempt follows a disconnect, so show its failure card instead. + for pending in workspace.cloudPendingCreations.values where pending.machine.isSSH { + workspace.failReservedCloudTerminalPane(pending, error: CloudDiagnosticFailure.network) + } + } for projection in catalog.projections where projection.workspaceID == workspace.id && projection.resource.machine.isSSH { let provider = catalog.provider(for: projection.resource.machine) as? CmuxTuiSurfaceProvider _ = provider?.manualMirrorSessions[projection.panelID]?.cancelConnectionAttempt() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7097287c665a..707aaf486633 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3627,6 +3627,8 @@ EF70B7123200D1FB6F03DB26 /* SSHStartupManualReconnectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F73A5C0BB93507E18261385 /* SSHStartupManualReconnectTests.swift */; }; F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */; }; C5CEF76F24FA34BD48C15272 /* SSHTuiAgentStatusProjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F9E6690E10C81DFA480C03 /* SSHTuiAgentStatusProjector.swift */; }; + E4A16791C72EAE4C91238486 /* SSHTuiAttachRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A931B55E58FF471FA1E231AE /* SSHTuiAttachRetry.swift */; }; + 5C2E3186CB5DC54C9424C824 /* SSHTuiAttachRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62038FEBE991E6F6022BD52F /* SSHTuiAttachRetryTests.swift */; }; C7BB9987974451FAD2D5F467 /* SSHTuiLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93262C2928EC5E04F1BD4A0 /* SSHTuiLinkManager.swift */; }; 2ED21DB7F76E4F2F23FC4292 /* SSHTuiMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AB37AFFC13C8507E428527F /* SSHTuiMigrationTests.swift */; }; 687509121DABB2ADED3C0E9A /* SSHTuiOpenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 266B552D3FBFED20D1EDE625 /* SSHTuiOpenTests.swift */; }; @@ -8158,6 +8160,8 @@ 4F73A5C0BB93507E18261385 /* SSHStartupManualReconnectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHStartupManualReconnectTests.swift; sourceTree = ""; }; F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHStartupSignalLifecycleTests.swift; sourceTree = ""; }; A6F9E6690E10C81DFA480C03 /* SSHTuiAgentStatusProjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTui/SSHTuiAgentStatusProjector.swift"; sourceTree = ""; }; + A931B55E58FF471FA1E231AE /* SSHTuiAttachRetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTui/SSHTuiAttachRetry.swift"; sourceTree = ""; }; + 62038FEBE991E6F6022BD52F /* SSHTuiAttachRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SSHTuiAttachRetryTests.swift"; sourceTree = ""; }; E93262C2928EC5E04F1BD4A0 /* SSHTuiLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTui/SSHTuiLinkManager.swift"; sourceTree = ""; }; 0AB37AFFC13C8507E428527F /* SSHTuiMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SSHTuiMigrationTests.swift"; sourceTree = ""; }; 266B552D3FBFED20D1EDE625 /* SSHTuiOpenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SSHTuiOpenTests.swift"; sourceTree = ""; }; @@ -11830,6 +11834,7 @@ BA3197FBB0911D71D4EC1719 /* TerminalController+SSHTui.swift */, F0C06BAA2A8D5347C1E56F76 /* SSHTuiWorkspaceCoordinator.swift */, A6F9E6690E10C81DFA480C03 /* SSHTuiAgentStatusProjector.swift */, + A931B55E58FF471FA1E231AE /* SSHTuiAttachRetry.swift */, E93262C2928EC5E04F1BD4A0 /* SSHTuiLinkManager.swift */, 4DF11D8DA81D3BD618151AD3 /* RemoteTuiMachine.swift */, A6F3597E0DD3186E5BB2B528 /* RemoteTuiLinkManaging.swift */, @@ -13857,6 +13862,7 @@ DA688992ADFE3696B36EEC3D /* CloudStaleInventoryMaterializationTests.swift */, BB0DDF5F330E26DA03118A56 /* ClaudeTranscriptlessRestoreTests.swift */, 6F6A2754CDC4A872BCEFD3EA /* AgentAcknowledgedTurnNotificationTests.swift */, + 62038FEBE991E6F6022BD52F /* SSHTuiAttachRetryTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -16637,6 +16643,7 @@ FEAC00000000000000000001 /* SSHFileExplorerMutationCommand.swift in Sources */, E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */, C5CEF76F24FA34BD48C15272 /* SSHTuiAgentStatusProjector.swift in Sources */, + E4A16791C72EAE4C91238486 /* SSHTuiAttachRetry.swift in Sources */, C7BB9987974451FAD2D5F467 /* SSHTuiLinkManager.swift in Sources */, 0754B84093909FD27762E206 /* SSHTuiWorkspaceCoordinator.swift in Sources */, A11C00020000000000000001 /* StackAccountAvatarImageLoader.swift in Sources */, @@ -18849,6 +18856,7 @@ D6B29560DEE34B08842A143A /* SSHStartupManualReconnectTests+Fixtures.swift in Sources */, EF70B7123200D1FB6F03DB26 /* SSHStartupManualReconnectTests.swift in Sources */, F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */, + 5C2E3186CB5DC54C9424C824 /* SSHTuiAttachRetryTests.swift in Sources */, 2ED21DB7F76E4F2F23FC4292 /* SSHTuiMigrationTests.swift in Sources */, 687509121DABB2ADED3C0E9A /* SSHTuiOpenTests.swift in Sources */, B9B1DC92B6B88F225DAD7ABB /* SSHTuiPreflightTests.swift in Sources */, diff --git a/cmuxTests/SSHTuiAttachRetryTests.swift b/cmuxTests/SSHTuiAttachRetryTests.swift new file mode 100644 index 000000000000..94cf264aeff8 --- /dev/null +++ b/cmuxTests/SSHTuiAttachRetryTests.swift @@ -0,0 +1,203 @@ +import CmuxCloud +import CmuxSurfaceCatalogModel +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A restored `cmux ssh` workspace must stay connecting while its attach +/// retries, and report an error only once retrying cannot help. +@MainActor +@Suite("SSH cmux-tui restore attach retry", .serialized) +struct SSHTuiAttachRetryTests { + private struct TransientFailure: Error {} + + private let policy = SSHTuiAttachRetryPolicy(delays: [.seconds(1), .seconds(2)]) + + @Test("Retryable failures back off on the schedule, then give up") + func retryScheduleIsBounded() { + let error = CloudMachineLink.LinkError.failureMessage("ssh: Could not resolve hostname host") + #expect(policy.retryDelay(afterFailure: 1, error: error) == .seconds(1)) + #expect(policy.retryDelay(afterFailure: 2, error: error) == .seconds(2)) + #expect(policy.retryDelay(afterFailure: 3, error: error) == nil) + } + + @Test("Failures that no retry can fix report at once") + func permanentFailuresAreNotRetried() { + #expect(policy.retryDelay(afterFailure: 1, error: CloudDiagnosticFailure.unsupported) == nil) + #expect(policy.retryDelay(afterFailure: 1, error: CloudMachineLink.LinkError.clientMissing) == nil) + #expect(policy.retryDelay(afterFailure: 1, error: CancellationError()) == nil) + // The carrier already spent its connect deadline; report instead of another lap. + #expect(policy.retryDelay(afterFailure: 1, error: CloudMachineLink.LinkError.timedOut) == nil) + #expect(policy.retryDelay(afterFailure: 1, error: CloudMachineLink.LinkError.requestTimedOut) == .seconds(1)) + // A spent carrier deadline is reported, but still retried quietly afterwards. + #expect(!SSHTuiAttachRetryPolicy.isPermanent(CloudMachineLink.LinkError.timedOut)) + #expect(SSHTuiAttachRetryPolicy.isPermanent(CloudDiagnosticFailure.unsupported)) + #expect(SSHTuiAttachRetryPolicy.isPermanent(CloudMachineLink.LinkError.clientMissing)) + #expect(!SSHTuiAttachRetryPolicy.isPermanent(CloudMachineLink.LinkError.failureMessage("Connection refused"))) + } + + @Test("Only a link that came up after the failure retries early") + func linkUpRetriesOnlyAfterADownLink() { + #expect(SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: false, linkState: .connected)) + #expect(!SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: true, linkState: .connected)) + #expect(!SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: false, linkState: .connecting)) + #expect(!SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: false, linkState: .error)) + #expect(!SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: false, linkState: nil)) + } + + @Test("A transient failure retries after its backoff and connects without reporting") + func transientFailureConnectsOnRetry() async { + let clock = CloudReadManualClock() + let backoff = SSHTuiAttachBackoff() + var attempts = 0 + var keptPending: [Bool] = [] + var failures = 0 + var retries = 0 + let run = Task { @MainActor in + await SSHTuiAttachRetry.run( + policy: policy, clock: clock, backoff: backoff, + failed: { _ in failures += 1 }, + retrying: { retries += 1 }, + attempt: { willRetry in + attempts += 1 + if attempts == 1 { + keptPending.append(willRetry(TransientFailure())) + throw TransientFailure() + } + } + ) + } + await waitUntil { clock.pendingSleeperCount == 1 } + #expect(attempts == 1) + #expect(failures == 1) + clock.advance(by: .seconds(1)) + let outcome = await run.value + guard case .connected = outcome else { + Issue.record("expected connected, got \(outcome)") + return + } + #expect(attempts == 2) + #expect(retries == 1) + #expect(keptPending == [true]) + } + + @Test("Failures that outlast the schedule report the last error") + func exhaustedRetriesFail() async { + let clock = CloudReadManualClock() + let backoff = SSHTuiAttachBackoff() + var attempts = 0 + var lastKeptPending: Bool? + let run = Task { @MainActor in + await SSHTuiAttachRetry.run(policy: policy, clock: clock, backoff: backoff) { willRetry in + attempts += 1 + lastKeptPending = willRetry(TransientFailure()) + throw TransientFailure() + } + } + await waitUntil { clock.pendingSleeperCount == 1 } + clock.advance(by: .seconds(1)) + await waitUntil { attempts == 2 && clock.pendingSleeperCount == 1 } + clock.advance(by: .seconds(2)) + let outcome = await run.value + guard case .failed(let error) = outcome else { + Issue.record("expected failed, got \(outcome)") + return + } + #expect(error is TransientFailure) + #expect(attempts == 3) + // The final attempt fails its reserved pane instead of keeping it pending. + #expect(lastKeptPending == false) + } + + @Test("A permanent failure reports on the first attempt without waiting") + func permanentFailureFailsImmediately() async { + let clock = CloudReadManualClock() + var attempts = 0 + let outcome = await SSHTuiAttachRetry.run(policy: policy, clock: clock, backoff: SSHTuiAttachBackoff()) { _ in + attempts += 1 + throw CloudDiagnosticFailure.unsupported + } + guard case .failed = outcome else { + Issue.record("expected failed, got \(outcome)") + return + } + #expect(attempts == 1) + #expect(clock.pendingSleeperCount == 0) + } + + @Test("A link-up wake retries before the backoff elapses") + func wakeEndsBackoffEarly() async { + let clock = CloudReadManualClock() + let backoff = SSHTuiAttachBackoff() + var attempts = 0 + let run = Task { @MainActor in + await SSHTuiAttachRetry.run( + policy: SSHTuiAttachRetryPolicy(delays: [.seconds(600)]), clock: clock, backoff: backoff + ) { _ in + attempts += 1 + if attempts == 1 { throw TransientFailure() } + } + } + await waitUntil { clock.pendingSleeperCount == 1 } + backoff.wake() + let outcome = await run.value + guard case .connected = outcome else { + Issue.record("expected connected, got \(outcome)") + return + } + #expect(attempts == 2) + #expect(clock.now.offset == .zero) + } + + @Test("A wake that lands before the wait begins is not lost") + func wakeBeforeWaitIsKept() async { + let clock = CloudReadManualClock() + let backoff = SSHTuiAttachBackoff() + var attempts = 0 + let outcome = await SSHTuiAttachRetry.run( + policy: SSHTuiAttachRetryPolicy(delays: [.seconds(600)]), clock: clock, backoff: backoff, + failed: { _ in backoff.wake() } + ) { _ in + attempts += 1 + if attempts == 1 { throw TransientFailure() } + } + guard case .connected = outcome else { + Issue.record("expected connected, got \(outcome)") + return + } + #expect(attempts == 2) + } + + @Test("Cancelling the attach ends a backoff wait without reporting") + func cancellationEndsQuietly() async { + let clock = CloudReadManualClock() + let backoff = SSHTuiAttachBackoff() + var attempts = 0 + let run = Task { @MainActor in + await SSHTuiAttachRetry.run(policy: policy, clock: clock, backoff: backoff) { _ in + attempts += 1 + throw TransientFailure() + } + } + await waitUntil { clock.pendingSleeperCount == 1 } + run.cancel() + let outcome = await run.value + guard case .cancelled = outcome else { + Issue.record("expected cancelled, got \(outcome)") + return + } + #expect(attempts == 1) + } + + private func waitUntil(_ condition: () -> Bool) async { + for _ in 0..<10_000 where !condition() { + await Task.yield() + } + #expect(condition()) + } +} From aa7a34fb374565014750297bd0396e1df9341973 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 04:37:38 -0700 Subject: [PATCH 042/183] Add a Cloud button to the sidebar footer (#20563) * test: sidebar Cloud button reveals Machines and always reintroduces Cloud while off The welcome's seen marker gates the launch campaign; an explicit Cloud button press must still show the card every time Cloud is off. Co-Authored-By: Claude Opus 5.5 * feat: add a Cloud button to the sidebar footer A cloud icon sits right of Help and left of Upgrade. Each press reveals Cloud Machines in the right sidebar; while Cloud is off or signed out it also reopens the Cloud welcome, whose next step routes to sign-in, Upgrade or Enable. It follows Show Cloud and Device Icons in Sidebar and hides when managed policy disables Cloud. Co-Authored-By: Claude Opus 5.5 * fix: import CmuxCloud for the sidebar Cloud button and cover the seen marker Co-Authored-By: Claude Opus 5.5 * fix: even the visible gap between the Help and Cloud footer icons Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Resources/Localizable.xcstrings | 65 +++++++++++++ Sources/AppDelegate+NewCloudWorkspace.swift | 20 +++- .../Cloud/CloudWelcomeWindowController.swift | 8 ++ Sources/Cloud/SidebarCloudButtonAction.swift | 18 ++++ Sources/ContentView.swift | 6 +- ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 43 +++++++++ cmux.xcodeproj/project.pbxproj | 8 ++ cmuxTests/SidebarCloudButtonActionTests.swift | 94 +++++++++++++++++++ scripts/localization-allowed-omissions.json | 7 ++ 9 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 Sources/Cloud/SidebarCloudButtonAction.swift create mode 100644 cmuxTests/SidebarCloudButtonActionTests.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f7531997e503..370c77bb0b33 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -398140,6 +398140,71 @@ } } }, + "sidebar.cloud.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السحابة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nube" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cloud" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클라우드" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Хмара" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "云" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雲端" + } + } + } + }, "sidebar.help.button": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+NewCloudWorkspace.swift b/Sources/AppDelegate+NewCloudWorkspace.swift index 67e75349b409..0d06029514ba 100644 --- a/Sources/AppDelegate+NewCloudWorkspace.swift +++ b/Sources/AppDelegate+NewCloudWorkspace.swift @@ -236,7 +236,7 @@ extension AppDelegate { } } - private func cloudCreationGate() -> CloudCreationGate { + func cloudCreationGate() -> CloudCreationGate { CloudCreationGate.resolve( isAvailable: CloudMachinesFeature.isAvailable, isEnabled: CloudMachinesFeature.isEnabled, @@ -245,6 +245,24 @@ extension AppDelegate { ) } + /// The sidebar footer Cloud button: reveals Cloud Machines in the right + /// sidebar on every press (never toggles it closed), and while Cloud is off + /// or signed out also reopens the welcome card, whose next step routes to + /// sign-in, Upgrade or Enable from the account's plan. + func performSidebarCloudButtonAction(preferredWindow: NSWindow?) { + let window = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + SidebarCloudButtonAction( + isAvailable: { CloudMachinesFeature.isAvailable }, + isEnabled: { [weak self] in self?.cloudCreationGate() != .introduceCloud }, + revealMachines: { [weak self] in + _ = self?.focusRightSidebarInActiveMainWindow(mode: .machines, preferredWindow: window) + }, + presentIntroduction: { [weak self] in + self?.cloudWelcomeWindowController.presentIntroduction(over: window) + } + ).perform() + } + /// Cloud creation shortcuts and menu rows introduce Cloud while it is off /// or signed out. Returning users skip the card and take its next step; /// Enable then resumes `resume`. diff --git a/Sources/Cloud/CloudWelcomeWindowController.swift b/Sources/Cloud/CloudWelcomeWindowController.swift index 50e994d8bcef..634f6b436b20 100644 --- a/Sources/Cloud/CloudWelcomeWindowController.swift +++ b/Sources/Cloud/CloudWelcomeWindowController.swift @@ -164,6 +164,14 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { return true } + /// Reopens the introduction from the sidebar Cloud button. An explicit + /// press always shows the card, even after the one-time launch campaign + /// recorded it as seen, and still records it so the launch never repeats it. + func presentIntroduction(over parent: NSWindow?) { + Self.markCampaignSeen(in: defaults) + show(over: parent) + } + #if DEBUG /// Debug previews deliberately bypass the gate, but still count as seen. func presentForDebug( diff --git a/Sources/Cloud/SidebarCloudButtonAction.swift b/Sources/Cloud/SidebarCloudButtonAction.swift new file mode 100644 index 000000000000..d692bae66358 --- /dev/null +++ b/Sources/Cloud/SidebarCloudButtonAction.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Reveals Cloud on every press, introducing it explicitly while activation is off. +@MainActor +struct SidebarCloudButtonAction { + let isAvailable: () -> Bool + let isEnabled: () -> Bool + let revealMachines: () -> Void + let presentIntroduction: () -> Void + + func perform() { + guard isAvailable() else { return } + revealMachines() + if !isEnabled() { + presentIntroduction() + } + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 89390ab93b9b..0d6028bc6cd6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -15739,7 +15739,7 @@ struct SidebarFooterButtons: View { var body: some View { HStack(spacing: 4) { - if shows(.account) || shows(.mobileConnect) || shows(.help) { + if shows(.account) || shows(.mobileConnect) || shows(.help) || shows(.cloud) { HStack(spacing: 0) { if shows(.account), CmuxFeatureFlags.shared.isSidebarAccountButtonEnabled { SidebarAccountMenuButton() @@ -15750,6 +15750,10 @@ struct SidebarFooterButtons: View { if shows(.help) { SidebarHelpMenuButton(onSendFeedback: onSendFeedback) } + // Right of Help and left of Upgrade, per the footer design. + if shows(.cloud) { + SidebarCloudButton() + } } } // Command-hold reveal: appears immediately before Upgrade. It stays diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index 8f85f941d8f5..44d594c17532 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -1,6 +1,8 @@ import AppKit import CmuxAppKitSupportUI +import CmuxCloud import CmuxFoundation +import CmuxSettingsUI import CmuxSidebar import CmuxSidebarProviderKit import CmuxUpdater @@ -35,6 +37,10 @@ enum SidebarFooterButtonMetrics { static let profileIconSize = accountAndHelpVisualSize static let mobileIconSize: CGFloat = 12 static let helpIconSize = accountAndHelpVisualSize + static let cloudIconSize = accountAndHelpVisualSize + /// The cloud glyph is 2.5 pt wider than the 14 pt account and help glyphs, + /// so half that keeps the visible gap after Help equal to the one before it. + static let cloudLeadingInset: CGFloat = 1.5 static let hoverOpacity = 0.08 } @@ -75,6 +81,7 @@ enum SidebarFooterControl: CaseIterable, Equatable { case account case mobileConnect case help + case cloud case shortcutDiscovery case upgrade case extensions @@ -322,6 +329,42 @@ struct SidebarMobileConnectButton: View { } } +/// Opens Cloud Machines in the right sidebar, introducing Cloud first while +/// it is off. Follows the "Show Cloud and Device Icons in Sidebar" setting and +/// stays hidden when managed policy disables Cloud. +struct SidebarCloudButton: View { + @LiveSetting(\.sidebar.showCloudDeviceIcons) private var showsCloudDeviceIcons + @State private var anchorView: NSView? + private let title = String(localized: "sidebar.cloud.button", defaultValue: "Cloud") + + var body: some View { + if showsCloudDeviceIcons, CloudMachinesFeature.isAvailable { + Button { + AppDelegate.shared?.performSidebarCloudButtonAction(preferredWindow: anchorView?.window) + } label: { + SidebarFooterCircularIcon( + systemName: "cloud", + style: SidebarFooterCircularIconStyle.standard.resized(to: SidebarFooterButtonMetrics.cloudIconSize) + ) + .frame( + width: SidebarFooterButtonMetrics.buttonSize, + height: SidebarFooterButtonMetrics.buttonSize + ) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame( + width: SidebarFooterButtonMetrics.buttonSize, + height: SidebarFooterButtonMetrics.buttonSize + ) + .background(TitlebarControlAnchorView { anchorView = $0 }) + .safeHelp(title) + .hintableAccessibilityLabel(title) + .accessibilityIdentifier("SidebarCloudButton") + .padding(.leading, SidebarFooterButtonMetrics.cloudLeadingInset) + } + } +} + private struct SidebarFooterIconButtonStyleBody: View { let configuration: SidebarFooterIconButtonStyle.Configuration diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 707aaf486633..c8d8347139e9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3340,6 +3340,8 @@ 41C32603305047F29AA29D23 /* SidebarBonsplitTabWorkspaceDropView+Ownership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E424C90C324EFA98F5671E /* SidebarBonsplitTabWorkspaceDropView+Ownership.swift */; }; BB406674610D41C58244647A /* SidebarBonsplitTabWorkspaceDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AF802DEBFA433C9B779F5F /* SidebarBonsplitTabWorkspaceDropView.swift */; }; FE9F9435374CB344628420AB /* SidebarBoundedDisplayStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A99CD111C8F6166A22186B /* SidebarBoundedDisplayStringTests.swift */; }; + 4C537C8FEE6305828A74F1A6 /* SidebarCloudButtonAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A986CE6A5E2D281B2CE3C7E1 /* SidebarCloudButtonAction.swift */; }; + 9A720A7DE3190737E7D77170 /* SidebarCloudButtonActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8ACF56AB5811EEC9D0203F /* SidebarCloudButtonActionTests.swift */; }; 9BE45B94B05D471FA386203D /* SidebarCloudWorkspaceBadgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA5C526315D4C2B88B1CA55 /* SidebarCloudWorkspaceBadgeTests.swift */; }; 71ABCB8E029B4E69997C8EB4 /* SidebarCloudWorkspaceBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69EF4F23B8E9406D9821E930 /* SidebarCloudWorkspaceBadgeView.swift */; }; 5EB96EEB7197F4186489176F /* SidebarCompactAgentStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB5DF435EAE2705BA8C46AC /* SidebarCompactAgentStatusTests.swift */; }; @@ -7881,6 +7883,8 @@ 68E424C90C324EFA98F5671E /* SidebarBonsplitTabWorkspaceDropView+Ownership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/SidebarBonsplitTabWorkspaceDropView+Ownership.swift"; sourceTree = ""; }; C4AF802DEBFA433C9B779F5F /* SidebarBonsplitTabWorkspaceDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropView.swift; sourceTree = ""; }; C7A99CD111C8F6166A22186B /* SidebarBoundedDisplayStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarBoundedDisplayStringTests.swift"; sourceTree = ""; }; + A986CE6A5E2D281B2CE3C7E1 /* SidebarCloudButtonAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarCloudButtonAction.swift; sourceTree = ""; }; + 2B8ACF56AB5811EEC9D0203F /* SidebarCloudButtonActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarCloudButtonActionTests.swift"; sourceTree = ""; }; 9CA5C526315D4C2B88B1CA55 /* SidebarCloudWorkspaceBadgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarCloudWorkspaceBadgeTests.swift"; sourceTree = ""; }; 69EF4F23B8E9406D9821E930 /* SidebarCloudWorkspaceBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarCloudWorkspaceBadgeView.swift"; sourceTree = ""; }; ECB5DF435EAE2705BA8C46AC /* SidebarCompactAgentStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarCompactAgentStatusTests.swift"; sourceTree = ""; }; @@ -10014,6 +10018,7 @@ 5284AD113D5D3BC29608D4F4 /* InProcessMachineCreateLauncher.swift */, B4E85236BC4E03F5BA8B908C /* CoderouterAccountState.swift */, 5ECF12C0C620A0999268B8BA /* CloudTuiManualMirrorHandshake.swift */, + A986CE6A5E2D281B2CE3C7E1 /* SidebarCloudButtonAction.swift */, ); name = Cloud; path = Cloud; @@ -13862,6 +13867,7 @@ DA688992ADFE3696B36EEC3D /* CloudStaleInventoryMaterializationTests.swift */, BB0DDF5F330E26DA03118A56 /* ClaudeTranscriptlessRestoreTests.swift */, 6F6A2754CDC4A872BCEFD3EA /* AgentAcknowledgedTurnNotificationTests.swift */, + 2B8ACF56AB5811EEC9D0203F /* SidebarCloudButtonActionTests.swift */, 62038FEBE991E6F6022BD52F /* SSHTuiAttachRetryTests.swift */, ); path = cmuxTests; @@ -16458,6 +16464,7 @@ D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, 41C32603305047F29AA29D23 /* SidebarBonsplitTabWorkspaceDropView+Ownership.swift in Sources */, BB406674610D41C58244647A /* SidebarBonsplitTabWorkspaceDropView.swift in Sources */, + 4C537C8FEE6305828A74F1A6 /* SidebarCloudButtonAction.swift in Sources */, 71ABCB8E029B4E69997C8EB4 /* SidebarCloudWorkspaceBadgeView.swift in Sources */, 71D6C8FB21FA9335AC60ECD2 /* SidebarCompactStatusGlyph+Resolve.swift in Sources */, 7CB5B5D87E4CCA04CACEAEB0 /* SidebarCompactStatusGlyph.swift in Sources */, @@ -18774,6 +18781,7 @@ A4C6D5D1089BA194936B5100 /* SidebarAgentWorkStateTests.swift in Sources */, B804C0030000000000000003 /* SidebarAppKitRowCellTests.swift in Sources */, FE9F9435374CB344628420AB /* SidebarBoundedDisplayStringTests.swift in Sources */, + 9A720A7DE3190737E7D77170 /* SidebarCloudButtonActionTests.swift in Sources */, 9BE45B94B05D471FA386203D /* SidebarCloudWorkspaceBadgeTests.swift in Sources */, 5EB96EEB7197F4186489176F /* SidebarCompactAgentStatusTests.swift in Sources */, A12481700000000000000001 /* SidebarDerivedStateScaleTests.swift in Sources */, diff --git a/cmuxTests/SidebarCloudButtonActionTests.swift b/cmuxTests/SidebarCloudButtonActionTests.swift new file mode 100644 index 000000000000..0e49e7141405 --- /dev/null +++ b/cmuxTests/SidebarCloudButtonActionTests.swift @@ -0,0 +1,94 @@ +import AppKit +import CmuxSettings +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Sidebar Cloud button routing", .serialized, .exclusiveAppContext) +struct SidebarCloudButtonActionTests { + @Test("enabled Cloud reveals Machines on every press without opening welcome") + func enabledCloudKeepsMachinesOpen() { + var visible = false + var mode: RightSidebarMode = .files + var introductions = 0 + let action = SidebarCloudButtonAction( + isAvailable: { true }, + isEnabled: { true }, + revealMachines: { visible = true; mode = .machines }, + presentIntroduction: { introductions += 1 } + ) + action.perform() + #expect(visible) + #expect(mode == .machines) + action.perform() + #expect(visible) + #expect(mode == .machines) + #expect(introductions == 0) + } + + @Test("disabled Cloud reopens welcome despite the seen marker for every account route", + arguments: [CloudWelcomeNextStep.signIn, .upgrade, .enable, .openCloud]) + func disabledCloudAlwaysIntroduces(step: CloudWelcomeNextStep) throws { + let suite = "SidebarCloudButtonActionTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + CloudWelcomeWindowController.markCampaignSeen(in: defaults) + var nextSteps: [CloudWelcomeNextStep] = [] + let welcome = CloudWelcomeWindowController( + defaults: defaults, + nextStep: { step }, + performNextStep: { step, _ in nextSteps.append(step) } + ) + defer { welcome.dismiss() } + var revealed = 0 + let action = SidebarCloudButtonAction( + isAvailable: { true }, + isEnabled: { false }, + revealMachines: { revealed += 1 }, + presentIntroduction: { + #expect(revealed > 0) + welcome.presentIntroduction(over: nil) + } + ) + for _ in 0..<2 { + action.perform() + #expect(NSApp.windows.contains { + $0.identifier?.rawValue == "cmux.cloud.welcome" && $0.isVisible + }) + welcome.dismiss() + } + #expect(revealed == 2) + #expect(nextSteps.isEmpty) + #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) + } + + @Test("an explicit introduction records the seen marker so launch does not repeat it") + func introductionMarksWelcomeSeen() throws { + let suite = "SidebarCloudButtonActionTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let welcome = CloudWelcomeWindowController(defaults: defaults) + defer { welcome.dismiss() } + #expect(!CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) + welcome.presentIntroduction(over: nil) + #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) + #expect(!welcome.claimLaunchPresentation(appVersion: "0.65.1", policy: ManagedDevicePolicy(defaults: defaults))) + } + + @Test("managed Cloud restrictions do not reveal or introduce Cloud") + func unavailableCloudDoesNothing() { + var events = 0 + SidebarCloudButtonAction( + isAvailable: { false }, + isEnabled: { false }, + revealMachines: { events += 1 }, + presentIntroduction: { events += 1 } + ).perform() + #expect(events == 0) + } +} diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index 78e76ad91735..52fc4e4d16bf 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -767,6 +767,13 @@ "de": "Established UI or technical term with the same spelling in this locale; a translated entry is still required." } }, + "sidebar.cloud.button": { + "source": "Cloud", + "identityLocales": { + "de": "Cloud is the established German UI noun for the product area; an explicit translated entry remains required.", + "fr": "Cloud is the established French UI noun for the product area; an explicit translated entry remains required." + } + }, "command.cloudVM.subtitle": { "source": "Cloud", "identityLocales": { From de479d7ede3db9c17c4ca022b37993243e19f564 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:48:18 -0700 Subject: [PATCH 043/183] settings: labs.classicHome keeps classic Home one release (cx-ebm.59.5) Settings > Advanced > Labs > Classic Home (labs.classicHome, off): Home is the classic top page (the Chief tab in the hidden home workspace) instead of the home workspace with hq-ec's home-channels page in the left dock. SettingsApplier sets DesignSettings.classicHome, which HomeDock.classic reads (landed in 7ddd45dbf9c2); it takes effect when Home opens next. Agents may set it (looks only, like labs.previewFeatures). FROZEN paths (needs the coordinator token): schemas/settings/source/labs.json, schemas/settings/settings-schema.json, Packages/macOS/CmuxNext/Sources/ CmuxNextSettings/* (snapshot, parser, applier, agent policy, generated Labs descriptors, Localizable.xcstrings: 2 keys x 21 locales), docs/mdm/*, webviews/src/pages/settings/generated/strings.json. Also schemas/defaults/cmux-next.json. Schema and MDM exports come from the fleet export step 3f0505aa2b07 (run-swift-testing-suites.sh export-settings). --- .../CmuxConfigSnapshot+PreviewFeatures.swift | 10 + .../CmuxNextSettings/CmuxConfigSnapshot.swift | 5 +- .../CmuxNextSettings/Localizable.xcstrings | 262 ++++++++++++++++++ .../Schema/SettingsSchema+AgentPolicy.swift | 1 + .../SettingsSchema+Labs.generated.swift | 13 + .../CmuxNextSettings/SettingsApplier.swift | 1 + docs/mdm/com.manaflow.cmux.json | 6 + docs/mdm/com.manaflow.cmux.plist | 12 + docs/mdm/managed-preferences.md | 1 + schemas/defaults/cmux-next.json | 1 + schemas/settings/settings-schema.json | 51 +++- schemas/settings/source/labs.json | 21 ++ .../src/pages/settings/generated/strings.json | 42 +++ 13 files changed, 423 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PreviewFeatures.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PreviewFeatures.swift index 82624e5ecb6a..35e5e8ed23ff 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PreviewFeatures.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PreviewFeatures.swift @@ -12,6 +12,16 @@ extension CmuxConfigSnapshot { } return (on, nil) } + + /// Another Labs toggle (`labs.classicHome`): absent is `fallback`; a value that is not a bool + /// is `fallback` plus a diagnostic. + static func parseLabsToggle(_ root: JSONValue, path: [String], fallback: Bool) -> (Bool, SettingsDiagnostic?) { + guard let value = root.value(at: path) else { return (fallback, nil) } + guard let on = value.boolValue else { + return (fallback, SettingsDiagnostic(kind: .invalidValue, path: path.joined(separator: "."), message: "expected true or false")) + } + return (on, nil) + } } extension CmuxConfigSnapshot { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift index d406c8b1e7c5..21d0c20a8924 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift @@ -36,8 +36,8 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { public var browserShowBookmarksBar = false /// `home.attachments.keepLocation`; off (strip location) when unset. public var homeKeepLocation = false - /// `labs.previewFeatures`; off when unset. - public var previewFeatures = CmuxConfigSnapshot.previewFeaturesDefault + /// `labs.previewFeatures` and `labs.classicHome`; off when unset. + public var previewFeatures = CmuxConfigSnapshot.previewFeaturesDefault, classicHome = CmuxConfigSnapshot.classicHomeDefault /// `browser.hibernation`, `browser.hibernationExclusions`, `browser.hibernatePinnedTabs`. public var browserHibernation: BrowserHibernationSetting = .fallback /// `browser.links.*`: what modified link clicks do; Chrome's when unset. @@ -219,6 +219,7 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { snapshot.take(BookmarksBarSetting.parse(root), \.browserShowBookmarksBar) snapshot.take(HomeKeepLocationSetting.parse(root), \.homeKeepLocation) snapshot.take(Self.parsePreviewFeatures(root), \.previewFeatures) + snapshot.take(Self.parseLabsToggle(root, path: classicHomePath, fallback: classicHomeDefault), \.classicHome) snapshot.take(BrowserHibernationSetting.parse(root), \.browserHibernation) snapshot.take(BrowserLinkClickSetting.parse(root), \.browserLinkClicks) snapshot.take(TerminalLinkRouting.parse(root), \.terminalLinkRouting) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings index b86a58158cc9..f1a2ff4b4327 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings @@ -36289,6 +36289,268 @@ } } }, + "settings.labs.classicHome": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Classic Home" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الصفحة الرئيسية الكلاسيكية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Klasična početna" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Klassisk Hjem" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klassische Startseite" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicio clásico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accueil classique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Home classica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラシックホーム" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំព័រដើមបុរាណ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클래식 홈" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Klassisk Hjem" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Klasyczny ekran główny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Início clássico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Классическая главная" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าหลักแบบคลาสสิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasik Ana Sayfa" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Класична головна" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Trang chủ cổ điển" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "经典主页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "經典首頁" + } + } + } + }, + "settings.labs.classicHome.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الصفحة الرئيسية كصفحة كاملة بدلًا من مساحة عمل يكون فيها Chief مثبتًا على اليسار. يسري التغيير عند فتح الصفحة الرئيسية في المرة التالية." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži početnu kao cijelu stranicu umjesto radnog prostora s Chiefom usidrenim lijevo. Primjenjuje se kada se početna sljedeći put otvori." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis Hjem som en hel side i stedet for et arbejdsområde med Chief fastgjort til venstre. Træder i kraft, næste gang Hjem åbnes." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startseite als ganze Seite statt als Arbeitsbereich mit links angedocktem Chief anzeigen. Wirkt beim nächsten Öffnen der Startseite." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra Inicio como una página completa en lugar de un espacio de trabajo con Chief acoplado a la izquierda. Se aplica la próxima vez que se abra Inicio." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher l'Accueil en pleine page plutôt que comme un espace de travail avec Chief ancré à gauche. Prend effet à la prochaine ouverture de l'Accueil." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra la Home come pagina intera invece che come spazio di lavoro con Chief agganciato a sinistra. Ha effetto alla prossima apertura della Home." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホームを、Chief を左にドッキングしたワークスペースではなく、全画面のページとして表示します。次にホームを開いたときに反映されます。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បង្ហាញទំព័រដើមជាទំព័រពេញ ជំនួសឱ្យកន្លែងធ្វើការដែលមាន Chief ភ្ជាប់នៅខាងឆ្វេង។ មានប្រសិទ្ធភាពនៅពេលបើកទំព័រដើមលើកក្រោយ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chief가 왼쪽에 고정된 작업 공간 대신 홈을 전체 페이지로 표시합니다. 다음에 홈을 열 때 적용됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis Hjem som en hel side i stedet for et arbeidsområde med Chief festet til venstre. Trer i kraft neste gang Hjem åpnes." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokazuj ekran główny jako pełną stronę zamiast obszaru roboczego z Chiefem zadokowanym po lewej. Zmiana działa przy następnym otwarciu ekranu głównego." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostra o Início como uma página inteira em vez de um espaço de trabalho com o Chief encaixado à esquerda. Vale na próxima vez que o Início for aberto." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать главную как целую страницу, а не рабочее пространство с Chief, закреплённым слева. Применяется при следующем открытии главной." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงหน้าหลักเป็นหน้าเต็มแทนพื้นที่ทำงานที่มี Chief ปักหมุดอยู่ทางซ้าย มีผลเมื่อเปิดหน้าหลักครั้งถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ana Sayfa'yı, Chief'in solda sabitlendiği bir çalışma alanı yerine tam sayfa olarak göster. Ana Sayfa bir sonraki açılışta geçerli olur." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Показувати головну як цілу сторінку, а не робочий простір із Chief, закріпленим ліворуч. Застосовується під час наступного відкриття головної." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hiển thị Trang chủ dạng trang đầy đủ thay vì không gian làm việc có Chief neo bên trái. Có hiệu lực khi mở Trang chủ lần sau." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将主页显示为完整页面,而不是左侧停靠 Chief 的工作区。下次打开主页时生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將首頁顯示為完整頁面,而非左側停駐 Chief 的工作區。下次開啟首頁時生效。" + } + } + } + }, "settings.appearance.backdropArt": { "comment": "Optional public-domain backdrop art and its museum attribution.", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+AgentPolicy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+AgentPolicy.swift index 62645e31c8bd..a5f0e8d7372a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+AgentPolicy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+AgentPolicy.swift @@ -157,6 +157,7 @@ extension SettingsSchema { // cmux-browser mutes a workspace through settings.set as `script` (cmux-browser #614). "notifications.mutedWorkspaces", "labs.previewFeatures", + "labs.classicHome", "updates.notify", "updates.showWhatsNew", "announcements.enabled", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift index b71cf41dd254..995f488002a9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift @@ -5,6 +5,9 @@ extension CmuxConfigSnapshot { /// `labs.previewFeatures`: Surfaces still being shaped (the agent pane's session coverage label and its Pull requests view). Off by default, so a default app shows only finished surfaces. public nonisolated static let previewFeaturesPath = ["labs", "previewFeatures"] public nonisolated static let previewFeaturesDefault = false + /// `labs.classicHome`: Home as the classic top page (the Chief conversation full window) instead of the home workspace with the Chief's page in a left dock (hq-6d cx-ebm.59.5). Off by default; kept for one release. HomeDock.classic reads it through DesignSettings.classicHome. + public nonisolated static let classicHomePath = ["labs", "classicHome"] + public nonisolated static let classicHomeDefault = false } /// One group of `SettingsSchema` rows (its own type: the schema type's line budget is per type). @@ -23,6 +26,16 @@ nonisolated enum LabsSettingsSchema { default: .bool(CmuxConfigSnapshot.previewFeaturesDefault), keywords: ["labs", "preview", "experimental", "beta", "prototype"] ), + SettingDescriptor( + CmuxConfigSnapshot.classicHomePath, + section: .advanced, + group: group, + title: SettingsText.keyed("settings.labs.classicHome", "Classic Home"), + help: SettingsText.keyed("settings.labs.classicHome.help", "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next."), + kind: .toggle, + default: .bool(CmuxConfigSnapshot.classicHomeDefault), + keywords: ["home", "chief", "classic", "dock", "page"] + ), ] } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingsApplier.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingsApplier.swift index 46529ff09c4c..f859a46c6ea4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingsApplier.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingsApplier.swift @@ -43,6 +43,7 @@ public final class SettingsApplier { if design.agentPaneZoom != agentPaneZoom { design.agentPaneZoom = agentPaneZoom } if design.animationSpeed != snapshot.animationSpeed { design.animationSpeed = snapshot.animationSpeed } if design.paneAnimations != snapshot.paneAnimations { design.paneAnimations = snapshot.paneAnimations } + if design.classicHome != snapshot.classicHome { design.classicHome = snapshot.classicHome } if design.centerFocusedColumn != snapshot.centerFocusedColumn { design.centerFocusedColumn = snapshot.centerFocusedColumn } if design.stripScrollbar != snapshot.stripScrollbar { design.stripScrollbar = snapshot.stripScrollbar } if design.sidebarSections != snapshot.sidebarSections { design.sidebarSections = snapshot.sidebarSections } diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index cfa4f00ac6a5..5a23f510f633 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -1460,6 +1460,12 @@ "title" : "Keep Location in Photos and Videos", "type" : "boolean" }, + "labs.classicHome" : { + "default" : false, + "description" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next.", + "title" : "Classic Home", + "type" : "boolean" + }, "labs.previewFeatures" : { "default" : false, "description" : "Unfinished surfaces, such as the agent session's coverage label and Pull requests view.", diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index 39cb02a23fcb..ce9bfde53946 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -4470,6 +4470,18 @@ pfm_type boolean + + pfm_default + + pfm_description + Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next. + pfm_name + labs.classicHome + pfm_title + Classic Home + pfm_type + boolean + pfm_default diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index 63017852ecaa..fb9c3b0c3d8a 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -316,6 +316,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `notifications.attention.moveToTop` | boolean | `true` | | Move the Workspace to the Top. A notification moves its workspace to the top of the unpinned rows, unless you are looking at its pane. | | `notifications.mutedWorkspaces` | array | `[]` | | Muted Workspaces. Workspace ids whose notifications are silent. Mute a workspace from its sidebar row menu. | | `labs.previewFeatures` | boolean | `false` | | Show Preview Features. Unfinished surfaces, such as the agent session's coverage label and Pull requests view. | +| `labs.classicHome` | boolean | `false` | | Classic Home. Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next. | | `feed.github.enabled` | boolean | `false` | | Connect GitHub. Uses your gh login to read notifications and review requests on this Mac. Sign in with gh auth login first. | | `feed.github.pollIntervalSeconds` | real | `120` | 60 to 900 | Refresh Interval. Seconds between GitHub refreshes. Refresh in the Inbox runs immediately. | | `agentPane.links.outsideRoots` | string | `"open"` | `confirm`, `text`, `open` | Files Outside the Project. What a file link in a reply does when the file is outside the chat's folders. Keys and .env files never open. | diff --git a/schemas/defaults/cmux-next.json b/schemas/defaults/cmux-next.json index 3010f245e083..2af19526ad87 100644 --- a/schemas/defaults/cmux-next.json +++ b/schemas/defaults/cmux-next.json @@ -365,6 +365,7 @@ "setting.history.commandRetentionDays": 30, "setting.history.terminalCommands": false, "setting.home.attachments.keepLocation": false, + "setting.labs.classicHome": false, "setting.labs.previewFeatures": false, "setting.layout.centerFocusedColumn": "never", "setting.layout.closeFocus": "previousNeighbor", diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index 86e1c278e6c7..0a5d6c2e404d 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -897,6 +897,7 @@ "key" : "settings.group.labs", "rows" : [ "labs.previewFeatures", + "labs.classicHome", "appearance.experimentalControls" ], "title" : { @@ -22129,6 +22130,54 @@ }, "validation" : "portable" }, + { + "accepts" : [ + true, + false + ], + "agent_refusal" : null, + "agent_settable" : true, + "consumers" : [ + "cmux-next" + ], + "default" : false, + "default_label" : null, + "group" : { + "key" : "settings.group.labs", + "text" : "Labs" + }, + "help" : { + "key" : "settings.labs.classicHome.help", + "text" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next." + }, + "kept_on_reset_all" : false, + "key" : "labs.classicHome", + "keywords" : [ + "home", + "chief", + "classic", + "dock", + "page" + ], + "kind" : "toggle", + "page_hidden" : false, + "path" : [ + "labs", + "classicHome" + ], + "refuses" : [ + "true", + 1, + null + ], + "section" : "advanced", + "summary" : null, + "title" : { + "key" : "settings.labs.classicHome", + "text" : "Classic Home" + }, + "validation" : "portable" + }, { "accepts" : [ true, @@ -23532,7 +23581,7 @@ "validation" : "portable" } ], - "schema_hash" : "79e569d3cdd546084d1ee8033b78707b55524c03716992d0a53c7b6a4b473314", + "schema_hash" : "c093a5ba0fa4d5597c96e1b613248a60175f73028a271c2862636b61a9a2b2a2", "sections" : [ { "id" : "general", diff --git a/schemas/settings/source/labs.json b/schemas/settings/source/labs.json index fcaac2207c36..f3ad4088e060 100644 --- a/schemas/settings/source/labs.json +++ b/schemas/settings/source/labs.json @@ -30,6 +30,27 @@ "beta", "prototype" ] + }, + { + "key": "labs.classicHome", + "note": "Home as the classic top page (the Chief conversation full window) instead of the home workspace with the Chief's page in a left dock (hq-6d cx-ebm.59.5). Off by default; kept for one release. HomeDock.classic reads it through DesignSettings.classicHome.", + "title": { + "key": "settings.labs.classicHome", + "text": "Classic Home" + }, + "help": { + "key": "settings.labs.classicHome.help", + "text": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next." + }, + "kind": "toggle", + "default": false, + "keywords": [ + "home", + "chief", + "classic", + "dock", + "page" + ] } ] } diff --git a/webviews/src/pages/settings/generated/strings.json b/webviews/src/pages/settings/generated/strings.json index 224e846bca33..a92aa5658ee4 100644 --- a/webviews/src/pages/settings/generated/strings.json +++ b/webviews/src/pages/settings/generated/strings.json @@ -468,6 +468,8 @@ "settings.history.terminalCommands.summary": "Shows finished commands in History", "settings.home.keepLocation": "Keep Location in Photos and Videos", "settings.home.keepLocation.help": "When off, location data is removed from photos and videos before they are attached.", + "settings.labs.classicHome": "Classic Home", + "settings.labs.classicHome.help": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next.", "settings.labs.previewFeatures": "Show Preview Features", "settings.labs.previewFeatures.help": "Unfinished surfaces, such as the agent session's coverage label and Pull requests view.", "settings.layout.centerFocusedColumn": "Center Focused Column", @@ -1479,6 +1481,8 @@ "settings.history.terminalCommands.summary": "يعرض الأوامر المكتملة في السجل", "settings.home.keepLocation": "الاحتفاظ بالموقع في الصور ومقاطع الفيديو", "settings.home.keepLocation.help": "عند الإيقاف، تُزال بيانات الموقع من الصور ومقاطع الفيديو قبل إرفاقها.", + "settings.labs.classicHome": "الصفحة الرئيسية الكلاسيكية", + "settings.labs.classicHome.help": "عرض الصفحة الرئيسية كصفحة كاملة بدلًا من مساحة عمل يكون فيها Chief مثبتًا على اليسار. يسري التغيير عند فتح الصفحة الرئيسية في المرة التالية.", "settings.labs.previewFeatures": "إظهار ميزات المعاينة", "settings.labs.previewFeatures.help": "واجهات غير مكتملة، مثل تسمية تغطية جلسة الوكيل وعرض طلبات السحب.", "settings.layout.centerFocusedColumn": "توسيط العمود المحدد", @@ -2490,6 +2494,8 @@ "settings.history.terminalCommands.summary": "Prikazuje završene komande u Historiji", "settings.home.keepLocation": "Zadrži lokaciju u fotografijama i videozapisima", "settings.home.keepLocation.help": "Kada je isključeno, podaci o lokaciji uklanjaju se iz fotografija i videozapisa prije prilaganja.", + "settings.labs.classicHome": "Klasična početna", + "settings.labs.classicHome.help": "Prikaži početnu kao cijelu stranicu umjesto radnog prostora s Chiefom usidrenim lijevo. Primjenjuje se kada se početna sljedeći put otvori.", "settings.labs.previewFeatures": "Prikaži funkcije u pregledu", "settings.labs.previewFeatures.help": "Nedovršeni dijelovi, poput oznake pokrivenosti sesije agenta i prikaza zahtjeva za povlačenje.", "settings.layout.centerFocusedColumn": "Centriraj fokusiranu kolonu", @@ -3501,6 +3507,8 @@ "settings.history.terminalCommands.summary": "Viser færdige kommandoer i Historik", "settings.home.keepLocation": "Behold lokation i fotos og videoer", "settings.home.keepLocation.help": "Når den er slået fra, fjernes lokationsdata fra fotos og videoer, før de vedhæftes.", + "settings.labs.classicHome": "Klassisk Hjem", + "settings.labs.classicHome.help": "Vis Hjem som en hel side i stedet for et arbejdsområde med Chief fastgjort til venstre. Træder i kraft, næste gang Hjem åbnes.", "settings.labs.previewFeatures": "Vis forhåndsvisningsfunktioner", "settings.labs.previewFeatures.help": "Ufærdige flader, f.eks. agentsessionens dækningsetiket og visningen af pull requests.", "settings.layout.centerFocusedColumn": "Centrér kolonnen i fokus", @@ -4512,6 +4520,8 @@ "settings.history.terminalCommands.summary": "Zeigt beendete Befehle im Verlauf", "settings.home.keepLocation": "Ort in Fotos und Videos behalten", "settings.home.keepLocation.help": "Wenn aus, werden Ortsdaten aus Fotos und Videos entfernt, bevor sie angehängt werden.", + "settings.labs.classicHome": "Klassische Startseite", + "settings.labs.classicHome.help": "Startseite als ganze Seite statt als Arbeitsbereich mit links angedocktem Chief anzeigen. Wirkt beim nächsten Öffnen der Startseite.", "settings.labs.previewFeatures": "Vorschaufunktionen anzeigen", "settings.labs.previewFeatures.help": "Unfertige Oberflächen, etwa die Abdeckungsangabe der Agent-Sitzung und die Pull-Request-Ansicht.", "settings.layout.centerFocusedColumn": "Fokussierte Spalte zentrieren", @@ -5523,6 +5533,8 @@ "settings.history.terminalCommands.summary": "Muestra los comandos terminados en el Historial", "settings.home.keepLocation": "Mantener la ubicación en fotos y vídeos", "settings.home.keepLocation.help": "Si está desactivado, los datos de ubicación se eliminan de las fotos y los vídeos antes de adjuntarlos.", + "settings.labs.classicHome": "Inicio clásico", + "settings.labs.classicHome.help": "Muestra Inicio como una página completa en lugar de un espacio de trabajo con Chief acoplado a la izquierda. Se aplica la próxima vez que se abra Inicio.", "settings.labs.previewFeatures": "Mostrar funciones en vista previa", "settings.labs.previewFeatures.help": "Superficies sin terminar, como la etiqueta de cobertura de la sesión del agente y la vista de solicitudes de incorporación.", "settings.layout.centerFocusedColumn": "Centrar la columna enfocada", @@ -6534,6 +6546,8 @@ "settings.history.terminalCommands.summary": "Affiche les commandes terminées dans l’historique", "settings.home.keepLocation": "Conserver la position dans les photos et vidéos", "settings.home.keepLocation.help": "Si désactivé, les données de position sont supprimées des photos et vidéos avant leur ajout en pièce jointe.", + "settings.labs.classicHome": "Accueil classique", + "settings.labs.classicHome.help": "Afficher l'Accueil en pleine page plutôt que comme un espace de travail avec Chief ancré à gauche. Prend effet à la prochaine ouverture de l'Accueil.", "settings.labs.previewFeatures": "Afficher les fonctionnalités en aperçu", "settings.labs.previewFeatures.help": "Surfaces inachevées, comme l'étiquette de couverture de la session de l'agent et la vue des pull requests.", "settings.layout.centerFocusedColumn": "Centrer la colonne active", @@ -7545,6 +7559,8 @@ "settings.history.terminalCommands.summary": "Mostra i comandi completati nella Cronologia", "settings.home.keepLocation": "Mantieni la posizione in foto e video", "settings.home.keepLocation.help": "Se disattivato, i dati sulla posizione vengono rimossi da foto e video prima di allegarli.", + "settings.labs.classicHome": "Home classica", + "settings.labs.classicHome.help": "Mostra la Home come pagina intera invece che come spazio di lavoro con Chief agganciato a sinistra. Ha effetto alla prossima apertura della Home.", "settings.labs.previewFeatures": "Mostra funzioni in anteprima", "settings.labs.previewFeatures.help": "Superfici non finite, come l'etichetta di copertura della sessione dell'agente e la vista delle pull request.", "settings.layout.centerFocusedColumn": "Centra la colonna attiva", @@ -8556,6 +8572,8 @@ "settings.history.terminalCommands.summary": "完了したコマンドを履歴に表示", "settings.home.keepLocation": "写真とビデオの位置情報を残す", "settings.home.keepLocation.help": "オフのときは、写真とビデオを添付する前に位置情報を削除します。", + "settings.labs.classicHome": "クラシックホーム", + "settings.labs.classicHome.help": "ホームを、Chief を左にドッキングしたワークスペースではなく、全画面のページとして表示します。次にホームを開いたときに反映されます。", "settings.labs.previewFeatures": "プレビュー機能を表示", "settings.labs.previewFeatures.help": "エージェントセッションのカバレッジ表示やプルリクエスト表示など、未完成の画面。", "settings.layout.centerFocusedColumn": "フォーカス中のカラムを中央に", @@ -9567,6 +9585,8 @@ "settings.history.terminalCommands.summary": "បង្ហាញពាក្យបញ្ជាដែលបានបញ្ចប់ក្នុងប្រវត្តិ", "settings.home.keepLocation": "រក្សាទីតាំងក្នុងរូបថត និងវីដេអូ", "settings.home.keepLocation.help": "នៅពេលបិទ ទិន្នន័យទីតាំងត្រូវបានលុបចេញពីរូបថត និងវីដេអូ មុនពេលភ្ជាប់។", + "settings.labs.classicHome": "ទំព័រដើមបុរាណ", + "settings.labs.classicHome.help": "បង្ហាញទំព័រដើមជាទំព័រពេញ ជំនួសឱ្យកន្លែងធ្វើការដែលមាន Chief ភ្ជាប់នៅខាងឆ្វេង។ មានប្រសិទ្ធភាពនៅពេលបើកទំព័រដើមលើកក្រោយ។", "settings.labs.previewFeatures": "បង្ហាញមុខងារមើលជាមុន", "settings.labs.previewFeatures.help": "ផ្ទៃដែលមិនទាន់បញ្ចប់ ដូចជាស្លាកវិសាលភាពនៃវគ្គភ្នាក់ងារ និងទិដ្ឋភាពសំណើទាញ។", "settings.layout.centerFocusedColumn": "ដាក់ជួរឈរដែលផ្ដោតនៅកណ្ដាល", @@ -10578,6 +10598,8 @@ "settings.history.terminalCommands.summary": "완료된 명령을 기록에 표시", "settings.home.keepLocation": "사진 및 비디오의 위치 정보 유지", "settings.home.keepLocation.help": "끄면 사진과 비디오를 첨부하기 전에 위치 데이터를 제거합니다.", + "settings.labs.classicHome": "클래식 홈", + "settings.labs.classicHome.help": "Chief가 왼쪽에 고정된 작업 공간 대신 홈을 전체 페이지로 표시합니다. 다음에 홈을 열 때 적용됩니다.", "settings.labs.previewFeatures": "미리 보기 기능 표시", "settings.labs.previewFeatures.help": "에이전트 세션의 적용 범위 레이블과 풀 리퀘스트 보기처럼 아직 완성되지 않은 화면입니다.", "settings.layout.centerFocusedColumn": "포커스된 열 가운데 정렬", @@ -11589,6 +11611,8 @@ "settings.history.terminalCommands.summary": "Viser fullførte kommandoer i Historikk", "settings.home.keepLocation": "Behold posisjon i bilder og videoer", "settings.home.keepLocation.help": "Når den er av, fjernes posisjonsdata fra bilder og videoer før de legges ved.", + "settings.labs.classicHome": "Klassisk Hjem", + "settings.labs.classicHome.help": "Vis Hjem som en hel side i stedet for et arbeidsområde med Chief festet til venstre. Trer i kraft neste gang Hjem åpnes.", "settings.labs.previewFeatures": "Vis forhåndsvisningsfunksjoner", "settings.labs.previewFeatures.help": "Uferdige flater, for eksempel agentøktens dekningsetikett og visningen av pull-forespørsler.", "settings.layout.centerFocusedColumn": "Midtstill kolonnen i fokus", @@ -12600,6 +12624,8 @@ "settings.history.terminalCommands.summary": "Pokazuje zakończone polecenia w Historii", "settings.home.keepLocation": "Zachowaj lokalizację w zdjęciach i filmach", "settings.home.keepLocation.help": "Gdy wyłączone, dane lokalizacji są usuwane ze zdjęć i filmów przed ich załączeniem.", + "settings.labs.classicHome": "Klasyczny ekran główny", + "settings.labs.classicHome.help": "Pokazuj ekran główny jako pełną stronę zamiast obszaru roboczego z Chiefem zadokowanym po lewej. Zmiana działa przy następnym otwarciu ekranu głównego.", "settings.labs.previewFeatures": "Pokaż funkcje w wersji zapoznawczej", "settings.labs.previewFeatures.help": "Niedokończone elementy, takie jak etykieta pokrycia sesji agenta i widok pull requestów.", "settings.layout.centerFocusedColumn": "Wyśrodkuj aktywną kolumnę", @@ -13611,6 +13637,8 @@ "settings.history.terminalCommands.summary": "Mostra os comandos concluídos no Histórico", "settings.home.keepLocation": "Manter a localização em fotos e vídeos", "settings.home.keepLocation.help": "Quando desativado, os dados de localização são removidos de fotos e vídeos antes de serem anexados.", + "settings.labs.classicHome": "Início clássico", + "settings.labs.classicHome.help": "Mostra o Início como uma página inteira em vez de um espaço de trabalho com o Chief encaixado à esquerda. Vale na próxima vez que o Início for aberto.", "settings.labs.previewFeatures": "Mostrar recursos em prévia", "settings.labs.previewFeatures.help": "Superfícies inacabadas, como o rótulo de cobertura da sessão do agente e a visualização de pull requests.", "settings.layout.centerFocusedColumn": "Centralizar coluna em foco", @@ -14622,6 +14650,8 @@ "settings.history.terminalCommands.summary": "Показывает завершённые команды в истории", "settings.home.keepLocation": "Сохранять геопозицию в фото и видео", "settings.home.keepLocation.help": "Если выключено, данные о местоположении удаляются из фото и видео перед вложением.", + "settings.labs.classicHome": "Классическая главная", + "settings.labs.classicHome.help": "Показывать главную как целую страницу, а не рабочее пространство с Chief, закреплённым слева. Применяется при следующем открытии главной.", "settings.labs.previewFeatures": "Показывать предварительные функции", "settings.labs.previewFeatures.help": "Незавершённые элементы, например метка покрытия сеанса агента и представление запросов на слияние.", "settings.layout.centerFocusedColumn": "Центрировать активную колонку", @@ -15633,6 +15663,8 @@ "settings.history.terminalCommands.summary": "แสดงคำสั่งที่เสร็จแล้วในประวัติ", "settings.home.keepLocation": "เก็บตำแหน่งที่ตั้งไว้ในรูปภาพและวิดีโอ", "settings.home.keepLocation.help": "เมื่อปิด ข้อมูลตำแหน่งที่ตั้งจะถูกลบออกจากรูปภาพและวิดีโอก่อนแนบ", + "settings.labs.classicHome": "หน้าหลักแบบคลาสสิก", + "settings.labs.classicHome.help": "แสดงหน้าหลักเป็นหน้าเต็มแทนพื้นที่ทำงานที่มี Chief ปักหมุดอยู่ทางซ้าย มีผลเมื่อเปิดหน้าหลักครั้งถัดไป", "settings.labs.previewFeatures": "แสดงฟีเจอร์ตัวอย่าง", "settings.labs.previewFeatures.help": "ส่วนที่ยังไม่เสร็จ เช่น ป้ายความครอบคลุมของเซสชันเอเจนต์และมุมมองคำขอดึง", "settings.layout.centerFocusedColumn": "จัดคอลัมน์ที่โฟกัสไว้ตรงกลาง", @@ -16644,6 +16676,8 @@ "settings.history.terminalCommands.summary": "Biten komutları Geçmiş'te gösterir", "settings.home.keepLocation": "Fotoğraf ve videolarda konumu koru", "settings.home.keepLocation.help": "Kapalıyken, konum verileri fotoğraf ve videolar eklenmeden önce kaldırılır.", + "settings.labs.classicHome": "Klasik Ana Sayfa", + "settings.labs.classicHome.help": "Ana Sayfa'yı, Chief'in solda sabitlendiği bir çalışma alanı yerine tam sayfa olarak göster. Ana Sayfa bir sonraki açılışta geçerli olur.", "settings.labs.previewFeatures": "Önizleme Özelliklerini Göster", "settings.labs.previewFeatures.help": "Ajan oturumunun kapsam etiketi ve çekme istekleri görünümü gibi bitmemiş yüzeyler.", "settings.layout.centerFocusedColumn": "Odaktaki Sütunu Ortala", @@ -17655,6 +17689,8 @@ "settings.history.terminalCommands.summary": "Показує завершені команди в історії", "settings.home.keepLocation": "Зберігати геопозицію у фото й відео", "settings.home.keepLocation.help": "Якщо вимкнено, дані про геопозицію видаляються з фото й відео перед вкладенням.", + "settings.labs.classicHome": "Класична головна", + "settings.labs.classicHome.help": "Показувати головну як цілу сторінку, а не робочий простір із Chief, закріпленим ліворуч. Застосовується під час наступного відкриття головної.", "settings.labs.previewFeatures": "Показувати попередні функції", "settings.labs.previewFeatures.help": "Незавершені елементи, як-от позначка покриття сеансу агента та подання запитів на злиття.", "settings.layout.centerFocusedColumn": "Центрувати активну колонку", @@ -18666,6 +18702,8 @@ "settings.history.terminalCommands.summary": "Hiện các lệnh đã chạy xong trong Lịch sử", "settings.home.keepLocation": "Giữ vị trí trong ảnh và video", "settings.home.keepLocation.help": "Khi tắt, dữ liệu vị trí sẽ bị xóa khỏi ảnh và video trước khi đính kèm.", + "settings.labs.classicHome": "Trang chủ cổ điển", + "settings.labs.classicHome.help": "Hiển thị Trang chủ dạng trang đầy đủ thay vì không gian làm việc có Chief neo bên trái. Có hiệu lực khi mở Trang chủ lần sau.", "settings.labs.previewFeatures": "Hiện tính năng xem trước", "settings.labs.previewFeatures.help": "Các phần chưa hoàn thiện, như nhãn phạm vi của phiên tác tử và chế độ xem yêu cầu kéo.", "settings.layout.centerFocusedColumn": "Căn giữa cột đang chọn", @@ -19677,6 +19715,8 @@ "settings.history.terminalCommands.summary": "在历史记录中列出已完成的命令", "settings.home.keepLocation": "在照片和视频中保留位置", "settings.home.keepLocation.help": "关闭时,照片和视频在附加前会移除位置数据。", + "settings.labs.classicHome": "经典主页", + "settings.labs.classicHome.help": "将主页显示为完整页面,而不是左侧停靠 Chief 的工作区。下次打开主页时生效。", "settings.labs.previewFeatures": "显示预览功能", "settings.labs.previewFeatures.help": "尚未完成的界面,例如智能体会话的覆盖范围标签和拉取请求视图。", "settings.layout.centerFocusedColumn": "居中显示焦点列", @@ -20688,6 +20728,8 @@ "settings.history.terminalCommands.summary": "在歷史記錄中列出已完成的指令", "settings.home.keepLocation": "在照片和影片中保留位置", "settings.home.keepLocation.help": "關閉時,照片和影片在附加前會移除位置資料。", + "settings.labs.classicHome": "經典首頁", + "settings.labs.classicHome.help": "將首頁顯示為完整頁面,而非左側停駐 Chief 的工作區。下次開啟首頁時生效。", "settings.labs.previewFeatures": "顯示預覽功能", "settings.labs.previewFeatures.help": "尚未完成的介面,例如代理程式工作階段的涵蓋範圍標籤與提取要求檢視。", "settings.layout.centerFocusedColumn": "置中顯示焦點欄", From d813703bce2c51afaf7fbac6049b137c58e0c055 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:03:25 -0700 Subject: [PATCH 044/183] macOS analytics: identify signed-in Cloud user (#20565) * macOS analytics: identify signed-in Cloud user Identify usage analytics as the sign-in service user id after a Cloud sign-in or a restored session, and reset to a fresh anonymous id on sign-out. Rename the feature-flags window subtitle so it does not name the analytics vendor. * Address review: reset when the SDK names another id, release the observer loop --- Resources/Localizable.xcstrings | 18 +++---- Sources/Auth/MacAuthComposition.swift | 5 ++ Sources/Auth/PostHogIdentityObserver.swift | 50 ++++++++++++++++++++ Sources/InternalFlagsWindow.swift | 2 +- Sources/PostHogAnalytics.swift | 55 ++++++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 ++ 6 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 Sources/Auth/PostHogIdentityObserver.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 370c77bb0b33..1b3b8c2c270f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -212352,55 +212352,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "Inspect PostHog flag state and local overrides for this Mac." + "value": "Inspect remote feature flag state and local overrides for this Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "このMacのPostHogフラグ状態とローカル上書きを確認します。" + "value": "このMacのリモート機能フラグの状態とローカル上書きを確認します。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "查看此 Mac 的 PostHog 开关状态和本地覆盖项。" + "value": "查看此 Mac 的远程功能开关状态和本地覆盖项。" } }, "de": { "stringUnit": { "state": "translated", - "value": "PostHog-Flag-Status und lokale Überschreibungen für diesen Mac untersuchen." + "value": "Remote-Feature-Flag-Status und lokale Überschreibungen für diesen Mac untersuchen." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Inspecter l’état des drapeaux PostHog et les surcharges locales pour ce Mac." + "value": "Inspecter l’état des drapeaux de fonctionnalités distants et les surcharges locales pour ce Mac." } }, "ar": { "stringUnit": { "state": "translated", - "value": "افحص حالة ميزات PostHog والتجاوزات المحلية لجهاز Mac هذا." + "value": "افحص حالة علامات الميزات البعيدة والتجاوزات المحلية لجهاز Mac هذا." } }, "es": { "stringUnit": { "state": "translated", - "value": "Inspecciona el estado de los flags de PostHog y las anulaciones locales para esta Mac." + "value": "Inspecciona el estado de los flags de funciones remotos y las anulaciones locales para esta Mac." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "檢查此 Mac 的 PostHog 旗標狀態與本機覆蓋。" + "value": "檢查此 Mac 的遠端功能旗標狀態與本機覆蓋。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 Mac의 PostHog 플래그 상태 및 로컬 재정의를 검사합니다." + "value": "이 Mac의 원격 기능 플래그 상태 및 로컬 재정의를 검사합니다." } } } diff --git a/Sources/Auth/MacAuthComposition.swift b/Sources/Auth/MacAuthComposition.swift index 1e1854583c16..53ba63c8b4d3 100644 --- a/Sources/Auth/MacAuthComposition.swift +++ b/Sources/Auth/MacAuthComposition.swift @@ -31,6 +31,8 @@ struct MacAuthComposition { /// Reconciles Cloud transports with the coordinator's selected team. let cloudTeamScopeObserver: CloudTeamScopeObserver let teamScopeRecoveryTriggers: MacAuthTeamScopeRecoveryTriggers + /// Identifies usage analytics as the signed-in account; resets on sign-out. + let postHogIdentityObserver: PostHogIdentityObserver /// Build the auth graph. /// - Parameters: @@ -227,6 +229,7 @@ struct MacAuthComposition { browserSignIn: browserSignIn ) self.teamScopeRecoveryTriggers = MacAuthTeamScopeRecoveryTriggers(coordinator: coordinator) + self.postHogIdentityObserver = PostHogIdentityObserver(auth: coordinator) self.cloudTeamScopeObserver = CloudTeamScopeObserver(auth: coordinator) { isSameAccount in AppDelegate.shared?.prepareCloudVMAccessForTeamSwitch(isSameAccount: isSameAccount) } @@ -238,6 +241,8 @@ struct MacAuthComposition { cloudTeamScopeObserver.start() teamScopeRecoveryTriggers.start() coordinator.start() + // The observer awaits launch restore before it acts on any state. + postHogIdentityObserver.start() } /// Where the file-fallback token store persists, namespaced by bundle id diff --git a/Sources/Auth/PostHogIdentityObserver.swift b/Sources/Auth/PostHogIdentityObserver.swift new file mode 100644 index 000000000000..c8072dd987d9 --- /dev/null +++ b/Sources/Auth/PostHogIdentityObserver.swift @@ -0,0 +1,50 @@ +import CmuxAuthRuntime +import Foundation + +/// Keeps the usage analytics identity in step with the Cloud session. +/// +/// A signed-in session (fresh sign-in or a restored session at launch) +/// identifies analytics as the sign-in service user id, so the install's +/// anonymous events join the same person the web server records. Sign-out +/// resets analytics to a fresh anonymous id. The auth coordinator is the +/// source of truth; this observer only forwards account changes. +@MainActor +final class PostHogIdentityObserver { + private let auth: AuthCoordinator + private let analytics: PostHogAnalytics + private var observationTask: Task? + + init(auth: AuthCoordinator, analytics: PostHogAnalytics = .shared) { + self.auth = auth + self.analytics = analytics + } + + func start() { + observationTask?.cancel() + // Capture the collaborators, not `self`, so `deinit` can cancel the + // otherwise endless stream loop. + let auth = auth + let analytics = analytics + observationTask = Task { @MainActor in + // Before restore finishes the coordinator can report a transient + // signed-out state; acting on it would reset a valid identity. + await auth.awaitBootstrapped() + var previousAccountID: String?? + for await identity in auth.authenticatedSessionIdentities() { + guard !Task.isCancelled else { return } + let accountID = identity?.accountID + guard accountID != previousAccountID else { continue } + previousAccountID = .some(accountID) + if let accountID { + analytics.identify(accountID: accountID) + } else { + analytics.resetIdentity() + } + } + } + } + + deinit { + observationTask?.cancel() + } +} diff --git a/Sources/InternalFlagsWindow.swift b/Sources/InternalFlagsWindow.swift index bf185b63dd20..31a4139b64d7 100644 --- a/Sources/InternalFlagsWindow.swift +++ b/Sources/InternalFlagsWindow.swift @@ -68,7 +68,7 @@ private struct InternalFlagsView: View { .font(.title2.weight(.semibold)) Text(String( localized: "featureFlags.window.subtitle", - defaultValue: "Inspect PostHog flag state and local overrides for this Mac." + defaultValue: "Inspect remote feature flag state and local overrides for this Mac." )) .font(.callout) .foregroundStyle(.secondary) diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 61012bc8cf57..2b23ef40b063 100644 --- a/Sources/PostHogAnalytics.swift +++ b/Sources/PostHogAnalytics.swift @@ -21,6 +21,10 @@ final class PostHogAnalytics: @unchecked Sendable { private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC" private let lastActiveHourUTCKey = "posthog.lastActiveHourUTC" private let lastReportedCrashAtKey = "posthog.lastReportedCrashAt" + /// The Cloud account this install last identified as. Kept across launches + /// so a sign-out or account switch can reset the persisted identity even + /// when it happens in a later launch than the identify call. + private let identifiedAccountIDKey = "posthog.identifiedAccountID" private let workQueue: DispatchQueue private let workQueueSpecificKey = DispatchSpecificKey() @@ -132,6 +136,57 @@ final class PostHogAnalytics: @unchecked Sendable { } } + /// Merge this install's anonymous PostHog person into the signed-in Cloud + /// account. `accountID` is the sign-in service user id, the same value the + /// web server uses as `distinct_id`. Only the id is sent: no email or name. + /// A different previously identified account is reset first so two + /// accounts never share one person. No-op when telemetry is disabled. + func identify(accountID: String) { + guard !accountID.isEmpty else { return } + dispatchAsyncOnWorkQueue { [weak self] in + guard let self else { return } + self.startIfNeededOnWorkQueue() + guard self.didStart else { return } + let previous = self.userDefaults.string(forKey: self.identifiedAccountIDKey) + let distinctID = PostHogSDK.shared.getDistinctId() + if previous == accountID, distinctID == accountID { + return + } + // The SDK ignores `identify` while it is identified as another id, + // so reset first when the stored account or the SDK names someone + // else. An anonymous SDK keeps its id so this launch's events merge. + let sdkIdentifiedAsOther = distinctID != PostHogSDK.shared.getAnonymousId() + && distinctID != accountID + if (previous != nil && previous != accountID) || sdkIdentifiedAsOther { + self.resetPostHogIdentityOnWorkQueue() + } + PostHogSDK.shared.identify(accountID) + self.userDefaults.set(accountID, forKey: self.identifiedAccountIDKey) + self.flushPostHog() + } + } + + /// Drop the identified account after Cloud sign-out so later events use a + /// fresh anonymous id. No-op when this install never identified, or when + /// telemetry is disabled (the stored account then stays so a later launch + /// with telemetry on still resets before identifying someone else). + func resetIdentity() { + dispatchAsyncOnWorkQueue { [weak self] in + guard let self else { return } + guard self.userDefaults.string(forKey: self.identifiedAccountIDKey) != nil else { return } + self.startIfNeededOnWorkQueue() + guard self.didStart else { return } + self.resetPostHogIdentityOnWorkQueue() + self.userDefaults.removeObject(forKey: self.identifiedAccountIDKey) + } + } + + private func resetPostHogIdentityOnWorkQueue() { + PostHogSDK.shared.reset() + // `reset()` also clears registered super properties. + PostHogSDK.shared.register(Self.superProperties(infoDictionary: Bundle.main.infoDictionary ?? [:])) + } + /// Mirror a previous-run crash into PostHog Error Tracking as one /// `$exception` event per crash. The crash is detected from the /// `.ghosttycrash` artifact on the next launch, so the event is sent by diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c8d8347139e9..45085ff3a08c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2871,6 +2871,7 @@ A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; }; B8E9500A0000000000000001 /* PostHogAnalyticsPropertiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E9500A0000000000000002 /* PostHogAnalyticsPropertiesTests.swift */; }; + A7C0FF1D0000000000000001 /* PostHogIdentityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C0FF1D0000000000000002 /* PostHogIdentityObserver.swift */; }; B7F100060000000000000001 /* PreparedNotificationSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F100060000000000000002 /* PreparedNotificationSound.swift */; }; D1F0A00100000000000000B1 /* PresenceHeartbeatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000B2 /* PresenceHeartbeatClient.swift */; }; DE71CE00000000000000000A /* PresenceHeartbeatClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000009 /* PresenceHeartbeatClientTests.swift */; }; @@ -7421,6 +7422,7 @@ C037CA7A6B20B3F495BAB401 /* PortTerminalDeviceLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortTerminalDeviceLookup.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; B8E9500A0000000000000002 /* PostHogAnalyticsPropertiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsPropertiesTests.swift; sourceTree = ""; }; + A7C0FF1D0000000000000002 /* PostHogIdentityObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostHogIdentityObserver.swift; sourceTree = ""; }; B7F100060000000000000002 /* PreparedNotificationSound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedNotificationSound.swift; sourceTree = ""; }; D1F0A00100000000000000B2 /* PresenceHeartbeatClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PresenceHeartbeatClient.swift; sourceTree = ""; }; DE71CE000000000000000009 /* PresenceHeartbeatClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceHeartbeatClientTests.swift; sourceTree = ""; }; @@ -9485,6 +9487,7 @@ B5A5E5570000000000000002 /* BrowserAppSessionWeakReference.swift */, DEBDADADADADADADAD000002 /* DebugDogfoodCredentialResolver.swift */, 53750023A0B1C2D3E4F50023 /* MacAuthComposition.swift */, + A7C0FF1D0000000000000002 /* PostHogIdentityObserver.swift */, CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */, 13019B000000000000000001 /* HostAccountFlow+TeamSelection.swift */, A5C017000000000000000006 /* StackAccountAvatarView.swift */, @@ -16176,6 +16179,7 @@ B79500060000000000000001 /* PortScanPublicationState.swift in Sources */, 9262420B51295D47DC7065BD /* PortTerminalDeviceLookup.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, + A7C0FF1D0000000000000001 /* PostHogIdentityObserver.swift in Sources */, B7F100060000000000000001 /* PreparedNotificationSound.swift in Sources */, D1F0A00100000000000000B1 /* PresenceHeartbeatClient.swift in Sources */, D1F0A00100000000000000B3 /* PresenceSettings.swift in Sources */, From 825f5fbb0a7db76040e64aaf84cb2bbd2302520f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:03:30 -0700 Subject: [PATCH 045/183] analytics: tag synthetic Cloud traffic, add sign-up, VM ready and hourly usage events, fix iOS proxy and Stripe identity (#20581) * analytics: tag synthetic Cloud VM traffic, add sign-up, VM ready and hourly usage events, fix iOS proxy and Stripe identity * analytics: assert the team checkout actor id exactly * analytics: address review: checkout-only team actor, person-less deleted owners, stable uuids, ordered usage read, gate legacy account polls --- docs/posthog/billing-attribution.md | 14 ++ docs/posthog/cloud-product-analytics.md | 43 +++- web/app/api/analytics/events/route.ts | 44 ++++- web/app/api/billing/checkout/route.ts | 10 +- web/app/api/coderouter/accounts/route.ts | 28 +-- web/app/api/coderouter/vm-usage/team/route.ts | 3 + web/app/api/cron/vm-usage-analytics/route.ts | 28 +++ web/app/api/subrouter/accounts/route.ts | 22 ++- web/app/api/subrouter/teams/route.ts | 21 +- web/orpc/server/dashboard/teams.ts | 8 +- web/scripts/cloud-vm/smoke-vm-api.mjs | 3 + web/services/analytics/accountSignup.ts | 91 +++++++++ web/services/analytics/iosEventPolicy.ts | 48 ++++- web/services/analytics/serverEvents.ts | 93 ++++++++- web/services/analytics/stripeBilling.ts | 123 +++++++++--- web/services/analytics/syntheticAccounts.ts | 41 ++++ web/services/auth/stackWebhook.ts | 6 + web/services/billing/personalPortal.ts | 2 +- web/services/coderouter/viewAnalytics.ts | 17 ++ web/services/coderouter/vmMetrics.ts | 16 +- web/services/vms/observability.ts | 28 ++- web/services/vms/productAnalytics.ts | 59 +++++- web/services/vms/repository.ts | 3 +- web/services/vms/requestContext.ts | 20 ++ web/services/vms/routeHelpers.ts | 12 ++ web/services/vms/usageAnalytics.ts | 184 ++++++++++++++++++ web/services/vms/workflows.ts | 2 + web/tests/analytics-events-route.test.ts | 2 +- web/tests/billing-checkout-route.test.ts | 2 +- web/tests/hosted-subrouter-routes.test.ts | 2 +- web/tests/vm-request-telemetry.test.ts | 2 +- web/vercel.json | 4 + 32 files changed, 886 insertions(+), 95 deletions(-) create mode 100644 web/app/api/cron/vm-usage-analytics/route.ts create mode 100644 web/services/analytics/accountSignup.ts create mode 100644 web/services/analytics/syntheticAccounts.ts create mode 100644 web/services/coderouter/viewAnalytics.ts create mode 100644 web/services/vms/usageAnalytics.ts diff --git a/docs/posthog/billing-attribution.md b/docs/posthog/billing-attribution.md index 1a8fe863c064..1f2b1cdff8e1 100644 --- a/docs/posthog/billing-attribution.md +++ b/docs/posthog/billing-attribution.md @@ -75,3 +75,17 @@ Mac events also carry `channel` (`stable`, `nightly`, `dev`) next to `app_versio - Churn feedback by acquisition surface: `cmux_billing_subscription_deleted` keeps the original `checkout_source` from subscription metadata. Dashboard: cmuxterm-hq `scripts/posthog-billing-dashboard.py` ("Billing attribution"). + +## Identity of Stripe billing events (2026-10) + +Stripe billing events are sent only from production (`VERCEL_ENV=production`, +or `CMUX_SERVER_ANALYTICS_FORCE=1`) and carry `livemode` and `vercel_env`. +User-scope events are keyed by the Stack user. Team checkout events +(`cmux_billing_checkout_started`, `_completed`, `_expired`) are keyed by the +purchasing admin with the `stack_team` group, and the completed event sets +that admin's `billing_plan` to `team`. Every other team event (subscription +changes, invoices, refunds) keeps `distinct_id: stack-team:` but is +person-less (`$process_person_profile: false`), so it creates no person. +PostHog does not link person-less events to groups: break those down by the +`stack_team_id` property. Funnels built on the old `stack-team:` person for +team checkouts must switch to the admin person or the group. diff --git a/docs/posthog/cloud-product-analytics.md b/docs/posthog/cloud-product-analytics.md index 031d86a0ce52..a758b1d448fb 100644 --- a/docs/posthog/cloud-product-analytics.md +++ b/docs/posthog/cloud-product-analytics.md @@ -30,8 +30,10 @@ insert id because each row is an action. There are two machine-id forms and they must not be joined blindly: * `cloud_vm_*` lifecycle events use the internal `cloud_vms.id` UUID. -* `cloud_vm_request` uses the provider VM id from the URL (`/api/vm//...`). - It is a client-facing name and is not the internal UUID. +* `cloud_vm_request` and `cloud_vm_provision` use the provider VM id from the + URL (`/api/vm//...`), `vm_id_kind: provider`. A collection route + (create, base open) has no URL id; once the request wrote a ledger row, + `vm_id` is that internal UUID and `vm_id_kind: ledger`. * CodeRouter uses `coderouter_vm_id`, the internal UUID, when a route token is bound to a Cloud VM. @@ -59,9 +61,40 @@ Common properties: `product: cloud_vm`, `ledger_event`, internal `vm_id`, | `cloud_vm_port_opened` | `vm.open_port` | `port` | | `cloud_vm_base_opened` | `vm.base.opened` | `generation` | | `cloud_vm_base_reset` | `vm.base.reset` | `generation` | - -Request telemetry (`cloud_vm_request`, schema 2, and -`cloud_vm_provision`, schema 3) carries the request machine name, billing +| `cloud_vm_ready` | `vm.created` | `time_to_ready_ms` (machine row insert to `running`), `origin`; insert id `cloud_vm_ready:` | + +`cloud_vm_usage_hourly` is the cost signal. The `vm-usage-analytics` cron +(minute 5 of every hour) sends one event per machine that ran during the +previous whole UTC hour, from `cloud_vm_runtime_intervals`: `running_seconds`, +`vcpu`, `memory_gib`, `disk_gib` (the reserved shape; +`resources_estimated: true` for legacy rows without a reservation marker), +`plan_id`, `hour_start`. Insert id `cloud_vm_usage_hourly::` +makes a rerun of the same hour idempotent. At most 5,000 machines per run. + +## Synthetic traffic + +The Cloud VM canary (`.github/workflows/cloud-vm-canary.yml`, every five +minutes) and the smoke, dogfood, stress and startup-bench scripts in +`web/scripts/cloud-vm/` create a throwaway account +`cmux--+...@manaflow.dev` per run, create a +machine and destroy it within a minute. In October 2026 that was about 95% of +`cloud_vm_created` users. Every Cloud VM event, `cloud_vm_ready`, +`cloud_vm_usage_hourly`, `cloud_vm_request`, `cloud_vm_provision` and +`cmux_account_signed_up` of such an account carries `is_synthetic: true`. +Exclude it with `properties.is_synthetic != true` (the property is absent on +real traffic). + +## Accounts + +`cmux_account_signed_up` is sent when the Stack `user.created` webhook is +accepted (insert id `cmux_account_signed_up:`): `signup_source` +(`oauth_`, `password`, `email_code`, `passkey`, `anonymous`, +`unknown`), `signup_client` (`server` for probe accounts, `anonymous`, else +`unknown`: Stack does not record which app started the sign-up), and +`is_anonymous`. Person `$set_once`: `cmux_signed_up_at`. + +Request telemetry (`cloud_vm_request`, schema 3, and +`cloud_vm_provision`, schema 4) carries the request machine name, billing scope, client fields and the `stack_team` group. Paywall and limit failures (`vm_requires_pro`, `vm_active_limit_exceeded`, `vm_access_requires_pro`, `vm_create_credits_insufficient`) can therefore be broken down by plan and diff --git a/web/app/api/analytics/events/route.ts b/web/app/api/analytics/events/route.ts index 20c0e92e7dc0..d10e3d479244 100644 --- a/web/app/api/analytics/events/route.ts +++ b/web/app/api/analytics/events/route.ts @@ -21,11 +21,14 @@ import { withAccountDeletionAnalyticsForwardLease, } from "../../../../services/account/deletionLock"; import { + ANONYMOUS_SAFE_PROPERTIES, MAX_ANALYTICS_BATCH_EVENTS, MAX_ANALYTICS_EVENT_PROPERTIES, + MAX_ANALYTICS_LIST_PROPERTY_ITEMS, MAX_ANALYTICS_REQUEST_BYTES, POSTHOG_HOST, POSTHOG_PROJECT_KEY, + iosAnalyticsChannel, isAllowedAnalyticsEvent, } from "../../../../services/analytics/iosEventPolicy"; @@ -190,13 +193,15 @@ function sanitizeEvent(candidate: unknown): IncomingEvent | null { : {}; // Cap property fan-out so a malformed client can't push an unbounded property - // bag through the proxy. + // bag through the proxy. Values are scalars or short scalar lists + // (`product_ids`); nested objects are dropped. const properties: Record = {}; let count = 0; for (const [key, value] of Object.entries(rawProperties)) { if (count >= MAX_ANALYTICS_EVENT_PROPERTIES) break; - if (isScalar(value)) { - properties[key] = value; + const forwarded = forwardablePropertyValue(value); + if (forwarded !== undefined) { + properties[key] = forwarded; count += 1; } } @@ -209,10 +214,36 @@ function sanitizeEvent(candidate: unknown): IncomingEvent | null { }; } -function isScalar(value: unknown): boolean { +function isScalar(value: unknown): value is string | number | boolean { return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } +function forwardablePropertyValue(value: unknown): unknown { + if (isScalar(value)) return value; + if (!Array.isArray(value)) return undefined; + return value.filter(isScalar).slice(0, MAX_ANALYTICS_LIST_PROPERTY_ITEMS); +} + +/** + * The properties an event keeps on its way to PostHog. Authenticated events + * keep everything the sanitizer accepted. An unauthenticated event may be a + * stale queued event from a deleted account, so it keeps only the build and + * store facts in ANONYMOUS_SAFE_PROPERTIES, never account fields. Every event + * gets `channel` from its bundle id so production, beta, dev and third-party + * builds can be separated; nothing is dropped for its channel. + */ +function forwardedProperties(event: IncomingEvent, authenticated: boolean): Record { + const properties: Record = authenticated ? { ...event.properties } : {}; + if (!authenticated) { + for (const key of ANONYMOUS_SAFE_PROPERTIES) { + const value = event.properties[key]; + if (isScalar(value)) properties[key] = value; + } + } + properties.channel = iosAnalyticsChannel(event.properties.bundle_identifier); + return properties; +} + async function forwardToPostHog( events: readonly IncomingEvent[], userId: string | null, @@ -229,8 +260,9 @@ async function forwardToPostHog( const batch = events.map((event) => { // An unauthenticated queued event may contain stale account fields from a // session that was deleted after the tombstone preflight. Preserve only its - // event name and namespaced anonymous id so it cannot recreate account data. - const properties = userId ? { ...event.properties } : {}; + // event name, namespaced anonymous id and build facts so it cannot + // recreate account data. + const properties = forwardedProperties(event, userId !== null); const anonymousAlias = properties.$anon_distinct_id; if (typeof anonymousAlias === "string") { properties.$anon_distinct_id = anonymousPostHogDistinctID(anonymousAlias); diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts index b190fc5d5a89..12990a426f5e 100644 --- a/web/app/api/billing/checkout/route.ts +++ b/web/app/api/billing/checkout/route.ts @@ -425,6 +425,7 @@ async function stripePersonalCheckout( attribution, signedIn: !user.isAnonymous, existingStripeCustomer: Boolean(stripeBillingStatus.customerId), + ...checkoutLivemode(session), })); return NextResponse.redirect(session.url); } catch (error) { @@ -524,12 +525,14 @@ async function stripeTeamCheckout( } deferCheckoutAnalytics(() => captureBillingCheckoutStarted({ sessionId: session.id, - subject: { scope: "team", stackTeamId: resolvedTeamId }, + // The purchasing admin is the person; the team is the group. + subject: { scope: "team", stackTeamId: resolvedTeamId, actorUserId: stackUserId }, plan: "team", billingInterval: interval, attribution, signedIn: !user.isAnonymous, existingStripeCustomer: Boolean(stripeBillingStatus.customerId), + ...checkoutLivemode(session), })); return NextResponse.redirect(session.url); } catch (error) { @@ -592,6 +595,11 @@ function checkoutPrincipalId(value: unknown, kind: "user" | "team"): string { return value; } +/** Stripe's livemode for the checkout analytics event, when Stripe reported one. */ +function checkoutLivemode(session: { readonly livemode?: unknown }): { livemode?: boolean } { + return typeof session.livemode === "boolean" ? { livemode: session.livemode } : {}; +} + function usableCheckoutSession( value: unknown, ): value is { readonly id: string; readonly url: string } { diff --git a/web/app/api/coderouter/accounts/route.ts b/web/app/api/coderouter/accounts/route.ts index 5fffb5ebd72b..544034f09569 100644 --- a/web/app/api/coderouter/accounts/route.ts +++ b/web/app/api/coderouter/accounts/route.ts @@ -11,6 +11,7 @@ import { import { accountsWithUsage } from "../../../../services/coderouter/usage"; import { CodexSignatureError } from "../../../../services/coderouter/codexSignature"; import { captureCoderouterEvent } from "../../../../services/coderouter/analytics"; +import { isCoderouterViewOpenRequest } from "../../../../services/coderouter/viewAnalytics"; import { addCoderouterBreadcrumb, reportCoderouterFailure, @@ -47,18 +48,21 @@ async function handleGet(request: Request): Promise { timing("serialize", serializeMs), timing("total", performance.now() - startedAt), ].join(", "); - captureCoderouterEvent({ - event: "coderouter_account_status_viewed", - teamId: resolved.teamId, - properties: { - source: "native_api", - account_count: result.accounts.length, - account_error_count: result.accounts.filter( - (account) => "usageError" in account && Boolean(account.usageError), - ).length, - duration_ms: Math.round(performance.now() - startedAt), - }, - }); + // Clients poll this list; only the request that opened the view counts. + if (isCoderouterViewOpenRequest(request)) { + captureCoderouterEvent({ + event: "coderouter_account_status_viewed", + teamId: resolved.teamId, + properties: { + source: "native_api", + account_count: result.accounts.length, + account_error_count: result.accounts.filter( + (account) => "usageError" in account && Boolean(account.usageError), + ).length, + duration_ms: Math.round(performance.now() - startedAt), + }, + }); + } addCoderouterBreadcrumb("status", "Account status loaded", { account_count: result.accounts.length, duration_ms: Math.round(performance.now() - startedAt), diff --git a/web/app/api/coderouter/vm-usage/team/route.ts b/web/app/api/coderouter/vm-usage/team/route.ts index 5b4e801f7c35..10ac6278468d 100644 --- a/web/app/api/coderouter/vm-usage/team/route.ts +++ b/web/app/api/coderouter/vm-usage/team/route.ts @@ -4,6 +4,7 @@ import { listTeamMachines } from "../../../../../services/coderouter/teamMachine import { loadCoderouterTeamMachineMetrics } from "../../../../../services/coderouter/vmMetrics"; import { teamVmUsageResponse } from "../../../../../services/coderouter/vmUsageContract"; import { reportCoderouterFailure } from "../../../../../services/coderouter/observability"; +import { isCoderouterViewOpenRequest } from "../../../../../services/coderouter/viewAnalytics"; const JSON_HEADERS = { "cache-control": "no-store", @@ -25,9 +26,11 @@ async function handleGet(request: Request): Promise { { status: 503, headers: { ...JSON_HEADERS, "retry-after": "5" } }, ); } + // Clients poll machine usage; only the request that opened the view counts. const metrics = await loadCoderouterTeamMachineMetrics( resolved.teamId, "team_machines_api", + { captureView: isCoderouterViewOpenRequest(request) }, ); return Response.json(teamVmUsageResponse(resolved.teamId, metrics, owned), { headers: JSON_HEADERS, diff --git a/web/app/api/cron/vm-usage-analytics/route.ts b/web/app/api/cron/vm-usage-analytics/route.ts new file mode 100644 index 000000000000..7850d90d7524 --- /dev/null +++ b/web/app/api/cron/vm-usage-analytics/route.ts @@ -0,0 +1,28 @@ +import { authorizeCronRequest } from "../../../../services/cronAuth"; +import { reportCronFailure, runMonitoredCron } from "../../../../services/observability/cronMonitor"; +import { captureVmUsageHourly } from "../../../../services/vms/usageAnalytics"; + +export const maxDuration = 120; + +/** + * Hourly `cloud_vm_usage_hourly` events (services/vms/usageAnalytics.ts): + * one per machine that ran during the previous whole UTC hour. A failed + * delivery answers 503 so the Sentry monitor sees it; a rerun of the same + * hour is idempotent through the events' natural insert ids. + */ +export async function GET(request: Request): Promise { + if (!authorizeCronRequest(request).ok) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + + return runMonitoredCron("vm-usage-analytics", async () => { + try { + const summary = await captureVmUsageHourly(); + const ok = summary.failed === 0; + return Response.json({ ok, ...summary }, { status: ok ? 200 : 503 }); + } catch (err) { + reportCronFailure("vm-usage-analytics", err); + return Response.json({ error: "vm_usage_analytics_failed" }, { status: 500 }); + } + }); +} diff --git a/web/app/api/subrouter/accounts/route.ts b/web/app/api/subrouter/accounts/route.ts index aba902e3d8f1..335b588a0fe7 100644 --- a/web/app/api/subrouter/accounts/route.ts +++ b/web/app/api/subrouter/accounts/route.ts @@ -7,6 +7,7 @@ import { } from "../../../../services/subrouter/routeHelpers"; import { resolveSubrouterRequestContext } from "../../../../services/subrouter/requestContext"; import { captureCoderouterEvent } from "../../../../services/coderouter/analytics"; +import { isCoderouterViewOpenRequest } from "../../../../services/coderouter/viewAnalytics"; export async function GET(request: Request): Promise { @@ -17,15 +18,18 @@ export async function GET(request: Request): Promise { try { const tenant = await context.client.exchangeTeam(context.accessToken, context.team); const accounts = await context.client.listAccounts(tenant.tenantKey); - captureCoderouterEvent({ - event: "coderouter_account_status_viewed", - teamId: context.team.teamId, - properties: { - source: "legacy_dashboard", - account_count: accounts.length, - account_error_count: 0, - }, - }); + // The macOS accounts client polls this route; only an opened view counts. + if (isCoderouterViewOpenRequest(request)) { + captureCoderouterEvent({ + event: "coderouter_account_status_viewed", + teamId: context.team.teamId, + properties: { + source: "legacy_dashboard", + account_count: accounts.length, + account_error_count: 0, + }, + }); + } return jsonResponse({ teamId: context.team.teamId, accounts }); } catch (err) { return subrouterErrorResponse(err); diff --git a/web/app/api/subrouter/teams/route.ts b/web/app/api/subrouter/teams/route.ts index 354868ac1c44..4e27e596a7b8 100644 --- a/web/app/api/subrouter/teams/route.ts +++ b/web/app/api/subrouter/teams/route.ts @@ -22,6 +22,7 @@ import { serviceUnavailableResponse, } from "../../../../services/subrouter/routeHelpers"; import { captureCoderouterEvent } from "../../../../services/coderouter/analytics"; +import { isCoderouterViewOpenRequest } from "../../../../services/coderouter/viewAnalytics"; import { reportError } from "../../../../services/observability/report"; import { getStackServerApp } from "../../../lib/stack"; import { @@ -161,14 +162,18 @@ export async function organizationsGet(request: Request, }); } selectedTeamId ??= stackSelectedTeamId; - captureCoderouterEvent({ - event: "coderouter_organization_catalog_viewed", - ...(selectedTeamId ? { teamId: selectedTeamId } : {}), - properties: { - organization_count: teams.length, - has_selected_organization: selectedTeamId !== null, - }, - }); + // Every native auth refresh reads this catalog; only an opened + // organization picker counts as a view. + if (isCoderouterViewOpenRequest(request)) { + captureCoderouterEvent({ + event: "coderouter_organization_catalog_viewed", + ...(selectedTeamId ? { teamId: selectedTeamId } : {}), + properties: { + organization_count: teams.length, + has_selected_organization: selectedTeamId !== null, + }, + }); + } return jsonResponse({ selectedTeamId, teams }); }); } catch (error) { diff --git a/web/orpc/server/dashboard/teams.ts b/web/orpc/server/dashboard/teams.ts index 45c84c1b20a0..9f7e7002322d 100644 --- a/web/orpc/server/dashboard/teams.ts +++ b/web/orpc/server/dashboard/teams.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { GET as teamCatalogRoute, PATCH as selectTeamRoute } from "@/app/api/subrouter/teams/route"; import { acceptTeamInvitationCode } from "@/services/teams/accept"; +import { CODEROUTER_VIEW_OPEN_HEADER } from "@/services/coderouter/viewAnalytics"; import { createTeamForUser, deleteTeam, updateTeam } from "@/services/teams/admin"; import { loadTeamDetail } from "@/services/teams/detail"; import { inviteTeamMembers, resendTeamInvitation, revokeTeamInvitation } from "@/services/teams/invitations"; @@ -37,7 +38,12 @@ const OK = { ok: true } as const; const catalog = authed .output(teamCatalogSchema) .handler(async ({ context }) => - teamCatalogSchema.parse(await callRoute(context, teamCatalogRoute, { method: "GET", path: "/api/subrouter/teams" })) + teamCatalogSchema.parse(await callRoute(context, teamCatalogRoute, { + method: "GET", + path: "/api/subrouter/teams", + // A dashboard page loading its team picker is a real view. + headers: { [CODEROUTER_VIEW_OPEN_HEADER]: "1" }, + })) ); /** Persist the selected team in Stack Auth. */ diff --git a/web/scripts/cloud-vm/smoke-vm-api.mjs b/web/scripts/cloud-vm/smoke-vm-api.mjs index 757edcf58234..2076bca9a9d7 100755 --- a/web/scripts/cloud-vm/smoke-vm-api.mjs +++ b/web/scripts/cloud-vm/smoke-vm-api.mjs @@ -211,6 +211,9 @@ async function sessionHeaders(stackUser, expiresInMillis) { return { authorization: `Bearer ${tokens.accessToken}`, "x-stack-refresh-token": tokens.refreshToken, + // Names the probe in `cloud_vm_request.client_name`. The server also tags + // every event of this account `is_synthetic` from its smoke address. + "x-cmux-client": "cmux-cloud-smoke", }; } diff --git a/web/services/analytics/accountSignup.ts b/web/services/analytics/accountSignup.ts new file mode 100644 index 000000000000..bd127242ad1d --- /dev/null +++ b/web/services/analytics/accountSignup.ts @@ -0,0 +1,91 @@ +// `cmux_account_signed_up`: one server event per Stack account, emitted when +// Stack's `user.created` webhook is accepted. The webhook is the one place +// every account creation passes through (web sign-up, native sign-in that +// creates the account, anonymous checkout users, server-created probe +// accounts), and the natural insert id keeps a Svix redelivery from counting +// twice. +// +// Stack does not record which cmux client started the sign-up, so +// `signup_client` names only what the server can prove: a probe account +// (`server`), an anonymous account (`anonymous`), else `unknown`. Join the +// person's first client event (`ios_sign_in_completed`, the macOS sign-in +// events) for the app. +import { + captureServerEvent, + type ServerEventDependencies, + type ServerEventInput, +} from "./serverEvents"; +import { isSyntheticAccountEmail } from "./syntheticAccounts"; + +export const ACCOUNT_SIGNED_UP_EVENT = "cmux_account_signed_up"; + +export type SignupSource = + | "anonymous" + | `oauth_${string}` + | "passkey" + | "password" + | "email_code" + | "unknown"; + +/** Map a validated `user.created` payload to the sign-up event, or null without a user id. */ +export function accountSignedUpEvent(data: unknown, now: Date = new Date()): ServerEventInput | null { + const user = record(data); + const userId = typeof user?.id === "string" ? user.id : null; + if (!user || !userId) return null; + const synthetic = isSyntheticAccountEmail(stringValue(user.primary_email)); + const anonymous = user.is_anonymous === true; + const signedUpAtMs = typeof user.signed_up_at_millis === "number" && Number.isFinite(user.signed_up_at_millis) + ? user.signed_up_at_millis + : null; + const signedUpAt = signedUpAtMs === null ? now : new Date(signedUpAtMs); + return { + event: ACCOUNT_SIGNED_UP_EVENT, + distinctId: userId, + properties: { + signup_source: signupSource(user), + signup_client: synthetic ? "server" : anonymous ? "anonymous" : "unknown", + is_anonymous: anonymous, + is_synthetic: synthetic ? true : undefined, + schema_version: 1, + }, + setOnce: { cmux_signed_up_at: signedUpAt.toISOString() }, + insertId: `${ACCOUNT_SIGNED_UP_EVENT}:${userId}`, + timestamp: signedUpAt, + }; +} + +/** Best effort; never throws into the webhook. */ +export function captureAccountSignedUp( + data: unknown, + dependencies: Partial = {}, +): void { + try { + const event = accountSignedUpEvent(data, dependencies.now?.() ?? new Date()); + if (event) void captureServerEvent(event, dependencies); + } catch (error) { + console.warn("[analytics] account sign-up capture failed", { + error: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200), + }); + } +} + +/** How the account first authenticated, from the Stack user's enabled methods. */ +function signupSource(user: Record): SignupSource { + if (user.is_anonymous === true) return "anonymous"; + const provider = Array.isArray(user.oauth_providers) + ? stringValue(record(user.oauth_providers[0])?.id) + : null; + if (provider && /^[a-z0-9_-]{1,32}$/i.test(provider)) return `oauth_${provider.toLowerCase()}`; + if (user.passkey_auth_enabled === true) return "passkey"; + if (user.has_password === true) return "password"; + if (user.otp_auth_enabled === true) return "email_code"; + return "unknown"; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} diff --git a/web/services/analytics/iosEventPolicy.ts b/web/services/analytics/iosEventPolicy.ts index a928289f0034..f7960c454c96 100644 --- a/web/services/analytics/iosEventPolicy.ts +++ b/web/services/analytics/iosEventPolicy.ts @@ -47,6 +47,10 @@ const ALLOWED_EVENTS: ReadonlySet = new Set([ "ios_purchase_pending", "ios_restore_started", "ios_restore_completed", + // StoreKit catalog and purchase delivery (CmuxMobileBilling) + "ios_products_loaded", + "ios_products_failed", + "ios_purchase_succeeded", // App lifecycle + session "ios_app_first_launch", "ios_app_launched", @@ -87,7 +91,49 @@ const ALLOWED_EVENTS: ReadonlySet = new Set([ "ios_crash", ]); +// Event families the app grows without a server change: every event under +// one of these prefixes is accepted. The suffix grammar keeps the name a +// plain identifier. +const ALLOWED_EVENT_FAMILIES: readonly RegExp[] = [ + /^ios_onboarding_[a-z0-9_]{1,64}$/, + /^ios_email_verification_[a-z0-9_]{1,64}$/, + /^ios_mac_update_hint_[a-z0-9_]{1,64}$/, +]; + /** Whether the proxy will forward the given event name to PostHog. */ export function isAllowedAnalyticsEvent(name: unknown): name is string { - return typeof name === "string" && ALLOWED_EVENTS.has(name); + return typeof name === "string" && + (ALLOWED_EVENTS.has(name) || ALLOWED_EVENT_FAMILIES.some((family) => family.test(name))); +} + +/** Max entries kept from a list property (`product_ids`). */ +export const MAX_ANALYTICS_LIST_PROPERTY_ITEMS = 32; + +/** + * Properties forwarded for an unauthenticated event. An anonymous event may + * be a queued event from a session whose account was deleted, so it keeps + * only build and store facts that identify no person: which app (and so + * which channel) sent it, its version, and the StoreKit environment. + */ +export const ANONYMOUS_SAFE_PROPERTIES: ReadonlySet = new Set([ + "bundle_identifier", + "app_version", + "build_number", + "storekit_environment", +]); + +export type IosAnalyticsChannel = "production" | "beta" | "dev" | "third_party" | "unknown"; + +/** + * Release channel of the app that sent an event, from its bundle id: + * `com.cmux.app` is the App Store app, `dev.cmux.app.beta` is TestFlight beta, + * any other `dev.cmux.*` is a tagged dev build, and anything else is a build + * we do not ship. `unknown` when the event carried no bundle id. + */ +export function iosAnalyticsChannel(bundleIdentifier: unknown): IosAnalyticsChannel { + if (typeof bundleIdentifier !== "string" || bundleIdentifier.length === 0) return "unknown"; + if (bundleIdentifier === "com.cmux.app") return "production"; + if (bundleIdentifier === "dev.cmux.app.beta") return "beta"; + if (bundleIdentifier.startsWith("dev.cmux.")) return "dev"; + return "third_party"; } diff --git a/web/services/analytics/serverEvents.ts b/web/services/analytics/serverEvents.ts index ba89a522899a..5c5c3a8d4995 100644 --- a/web/services/analytics/serverEvents.ts +++ b/web/services/analytics/serverEvents.ts @@ -11,7 +11,7 @@ // // Delivery is best effort: bounded timeout, one retry on transient failure, // deferred past the response with `after()`, never throws into the caller. -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { after } from "next/server"; import { isAnalyticsTestRun, POSTHOG_HOST, POSTHOG_PROJECT_KEY } from "./iosEventPolicy"; @@ -39,6 +39,11 @@ export type ServerEventInput = { */ readonly insertId?: string; readonly timestamp?: Date; + /** + * Send without a person profile (`$process_person_profile: false`) for a + * key that is not a real person (a deleted account placeholder). + */ + readonly personless?: boolean; }; export type ServerEventTask = () => Promise; @@ -99,24 +104,32 @@ export function serverEventPayload( const distinctId = normalizedOpaqueId(input.distinctId); if (!distinctId) return null; const teamId = normalizedOpaqueId(input.teamId); + const insertId = normalizedInsertId(input.insertId); const properties: Record = { ...cleanProperties(input.properties), $lib: SERVER_EVENT_LIB, - $insert_id: normalizedInsertId(input.insertId) ?? randomUUID(), + $insert_id: insertId ?? randomUUID(), // Server IPs are Vercel's, not the user's; never geolocate them. $geoip_disable: true, }; if (teamId) properties.$groups = { [STACK_TEAM_GROUP]: teamId }; - const set = cleanProperties(input.set); - if (Object.keys(set).length > 0) properties.$set = set; - const setOnce = cleanProperties(input.setOnce); - if (Object.keys(setOnce).length > 0) properties.$set_once = setOnce; + if (input.personless) { + properties.$process_person_profile = false; + } else { + const set = cleanProperties(input.set); + if (Object.keys(set).length > 0) properties.$set = set; + const setOnce = cleanProperties(input.setOnce); + if (Object.keys(setOnce).length > 0) properties.$set_once = setOnce; + } return { api_key: POSTHOG_PROJECT_KEY, event: input.event, distinct_id: distinctId, timestamp: (input.timestamp ?? now).toISOString(), properties, + // PostHog deduplicates on the event uuid, not `$insert_id`. A natural key + // maps to a stable uuid so a retry or replay of the same fact collapses. + ...(insertId ? { uuid: uuidFromKey(insertId) } : {}), }; } @@ -174,6 +187,56 @@ export function captureServerEvent( return delivery; } +/** Events per `/batch/` request; PostHog accepts far larger bodies, this keeps each request small. */ +export const SERVER_EVENT_BATCH_SIZE = 200; + +export type ServerEventBatchResult = { + readonly sent: number; + readonly failed: number; +}; + +/** + * Deliver many product events now, in `/batch/` requests of + * SERVER_EVENT_BATCH_SIZE, and report how many were accepted. For cron jobs + * that own their run and must await delivery before answering; request paths + * use `captureServerEvent`. Same gating as `captureServerEvent`; never throws. + */ +export async function captureServerEventBatch( + inputs: readonly ServerEventInput[], + dependencies: Partial = {}, +): Promise { + const deps: ServerEventDependencies = { ...defaultDependencies, ...dependencies }; + if (isAnalyticsTestRun(deps.env) && !dependencies.fetch) return { sent: 0, failed: 0 }; + if (!serverAnalyticsEnabled(deps.env)) return { sent: 0, failed: 0 }; + const posthogHost = configuredPosthogHost(deps.env); + if (!isAllowedPosthogHost(posthogHost, deps.env)) return { sent: 0, failed: inputs.length }; + const now = deps.now(); + const batch = inputs.flatMap((input) => { + const payload = serverEventPayload(input, now); + if (!payload) return []; + // The key travels once at the top of the batch body. + const event: Record = { ...payload }; + delete event.api_key; + return [event]; + }); + let sent = 0; + let failed = inputs.length - batch.length; + for (let start = 0; start < batch.length; start += SERVER_EVENT_BATCH_SIZE) { + const chunk = batch.slice(start, start + SERVER_EVENT_BATCH_SIZE); + try { + await deliver(JSON.stringify({ api_key: POSTHOG_PROJECT_KEY, batch: chunk }), deps.fetch, posthogHost, "/batch/"); + sent += chunk.length; + } catch (error: unknown) { + failed += chunk.length; + console.warn("[analytics] server event batch delivery failed", { + events: chunk.length, + error: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200), + }); + } + } + return { sent, failed }; +} + export function isSecurePosthogHost(host: string): boolean { try { const url = new URL(host); @@ -207,12 +270,17 @@ function configuredPosthogHost(env: Record): string return (env.POSTHOG_HOST ?? POSTHOG_HOST).replace(/\/$/, ""); } -async function deliver(body: string, fetchImpl: typeof fetch, posthogHost: string): Promise { +async function deliver( + body: string, + fetchImpl: typeof fetch, + posthogHost: string, + path: "/capture/" | "/batch/" = "/capture/", +): Promise { let lastError: unknown; for (let attempt = 0; attempt < 2; attempt += 1) { let response: Response; try { - response = await fetchImpl(`${posthogHost}/capture/`, { + response = await fetchImpl(`${posthogHost}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body, @@ -251,6 +319,15 @@ function normalizedOpaqueId(value: string | null | undefined): string | null { return trimmed && /^[A-Za-z0-9_-]{1,128}$/.test(trimmed) ? trimmed : null; } +/** A stable RFC 4122 version-5-shaped uuid from a natural key. */ +function uuidFromKey(key: string): string { + const bytes = createHash("sha1").update(`cmux-server-event:${key}`).digest().subarray(0, 16); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + function normalizedInsertId(value: string | undefined): string | null { const trimmed = value?.trim(); if (!trimmed) return null; diff --git a/web/services/analytics/stripeBilling.ts b/web/services/analytics/stripeBilling.ts index 1c2a80e568bf..acb1fab23f45 100644 --- a/web/services/analytics/stripeBilling.ts +++ b/web/services/analytics/stripeBilling.ts @@ -6,6 +6,7 @@ import { POSTHOG_HOST, POSTHOG_PROJECT_KEY, } from "./iosEventPolicy"; +import { serverAnalyticsEnabled, STACK_TEAM_GROUP } from "./serverEvents"; import { checkoutAttributionFromMetadata, checkoutAttributionProperties, @@ -26,6 +27,12 @@ export type StripeBillingAnalyticsSubject = | { readonly scope: "team"; readonly stackTeamId: string; + /** + * The Stack user who acted for the team (the purchasing admin). Team + * events are keyed by this person with the team as a group; without + * one they are recorded against the team without creating a person. + */ + readonly actorUserId?: string | null; readonly isActive?: boolean; readonly status?: string; }; @@ -40,13 +47,15 @@ export async function captureStripeBillingEvent( subject: StripeBillingAnalyticsSubject, postHogFetch?: typeof fetch, ): Promise { - const mapped = mappedBillingEvent(event, subject); + const actorSubject = withEventActor(subject, event); + const mapped = mappedBillingEvent(event, actorSubject); if (!mapped) return; await captureBillingPayload({ name: mapped.name, insertId: event.id, - subject, + subject: actorSubject, + livemode: event.livemode, properties: mapped.properties, }, postHogFetch); } @@ -57,11 +66,14 @@ export async function captureBillingPlanSwitchStarted(input: { fromPlan: string | null; targetPlan: string; attribution: CheckoutAttribution; + /** Stripe's `livemode` of the portal session. */ + livemode?: boolean; }, postHogFetch?: typeof fetch): Promise { await captureBillingPayload({ name: "cmux_billing_plan_switch_started", insertId: input.sessionId, subject: { scope: "user", stackUserId: input.userId }, + livemode: input.livemode, properties: { from_plan: input.fromPlan, target_plan: input.targetPlan, ...checkoutAttributionProperties(input.attribution) }, }, postHogFetch); } @@ -78,6 +90,8 @@ export async function captureBillingCheckoutStarted( readonly signedIn: boolean; /** True when the principal already had a Stripe customer (lapsed or canceled). */ readonly existingStripeCustomer: boolean; + /** Stripe's `livemode` of the Checkout Session. */ + readonly livemode?: boolean; }, postHogFetch?: typeof fetch, ): Promise { @@ -85,6 +99,7 @@ export async function captureBillingCheckoutStarted( name: "cmux_billing_checkout_started", insertId: `checkout-started:${input.sessionId}`, subject: input.subject, + livemode: input.livemode, properties: { source: "checkout_route", billing_scope: input.subject.scope, @@ -143,7 +158,8 @@ function commonBillingProperties( common.stack_user_id = subject.stackUserId; } else { common.stack_team_id = subject.stackTeamId; - common.$groups = { stack_team: subject.stackTeamId }; + common.actor_user_id = normalizedStackId(subject.actorUserId) ?? undefined; + common.$groups = { [STACK_TEAM_GROUP]: subject.stackTeamId }; } return common; } @@ -277,12 +293,61 @@ function refundEvent( }; } -function subjectDistinctId(subject: StripeBillingAnalyticsSubject): string | null { - const principalId = subject.scope === "user" - ? subject.stackUserId - : subject.stackTeamId; - if (typeof principalId !== "string" || principalId.trim().length === 0) return null; - return subject.scope === "user" ? principalId : `stack-team:${principalId}`; +/** + * Who a billing event is about. A user-scope event is the user's. A team + * event with an actor (checkout) is the acting admin's, with the team as the + * `stack_team` group, so it joins that person's funnel. A team event with no + * actor (renewals, invoices, refunds, subscription changes) keeps the team + * key but must not create a fake person, so it is sent with + * `$process_person_profile: false`. PostHog does not link person-less events + * to groups, so team rollups of those events break down by the + * `stack_team_id` property instead of the group. + */ +function subjectIdentity( + subject: StripeBillingAnalyticsSubject, +): { readonly distinctId: string; readonly personless: boolean } | null { + if (subject.scope === "user") { + const userId = normalizedStackId(subject.stackUserId); + return userId ? { distinctId: userId, personless: false } : null; + } + const teamId = normalizedStackId(subject.stackTeamId); + if (!teamId) return null; + const actorUserId = normalizedStackId(subject.actorUserId); + return actorUserId + ? { distinctId: actorUserId, personless: false } + : { distinctId: `stack-team:${teamId}`, personless: true }; +} + +/** Checkout events happen while the purchasing admin is present. */ +const ACTOR_EVENT_TYPES: ReadonlySet = new Set([ + "checkout.session.completed", + "checkout.session.async_payment_succeeded", + "checkout.session.expired", +]); + +/** + * The purchasing admin of a team checkout (team checkout writes + * `stackUserId` into the session metadata). Only checkout events take it: + * renewals and refunds arrive months later, possibly after that admin + * deleted their account, and Stripe metadata is never scrubbed, so keying + * them to the admin could recreate a deleted PostHog person. An explicit + * actor on the subject wins. + */ +function withEventActor( + subject: StripeBillingAnalyticsSubject, + event: Stripe.Event, +): StripeBillingAnalyticsSubject { + if (subject.scope !== "team" || normalizedStackId(subject.actorUserId)) return subject; + if (!ACTOR_EVENT_TYPES.has(event.type)) return subject; + const metadata = (event.data.object as { readonly metadata?: Record | null }).metadata; + const actor = metadata?.stackUserId; + const actorUserId = normalizedStackId(typeof actor === "string" ? actor : null); + return actorUserId ? { ...subject, actorUserId } : subject; +} + +function normalizedStackId(value: string | null | undefined): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed.length > 0 ? trimmed : null; } async function captureBillingPayload( @@ -290,29 +355,41 @@ async function captureBillingPayload( readonly name: string; readonly insertId: string; readonly subject: StripeBillingAnalyticsSubject; + /** Stripe's `livemode`; test-mode traffic stays in the project, tagged. */ + readonly livemode?: boolean; readonly properties: Record; }, postHogFetch?: typeof fetch, ): Promise { - // The production transport is unavailable in test runs. An explicitly - // injected fetch remains a deliberate unit-test seam for payload mapping; - // normal callers omit it and fail closed here. - if (isAnalyticsTestRun() && !postHogFetch) return; + // The production transport is unavailable in test runs, and preview or + // development deployments never reach the production project unless the + // shared force flag is set. An explicitly injected fetch remains a + // deliberate unit-test seam for payload mapping. + if (!postHogFetch && (isAnalyticsTestRun() || !serverAnalyticsEnabled(process.env))) return; const fetchImpl = postHogFetch ?? fetch; - const distinctId = subjectDistinctId(input.subject); - if (!distinctId) return; + const identity = subjectIdentity(input.subject); + if (!identity) return; + const properties: Record = { + distinct_id: identity.distinctId, + $insert_id: input.insertId, + ...(input.subject.scope === "team" + ? { $groups: { [STACK_TEAM_GROUP]: input.subject.stackTeamId } } + : {}), + ...input.properties, + livemode: input.livemode, + vercel_env: process.env.VERCEL_ENV ?? "development", + }; + if (identity.personless) { + // No person to update: person properties would be dropped anyway. + properties.$process_person_profile = false; + delete properties.$set; + delete properties.$set_once; + } const body = JSON.stringify({ api_key: POSTHOG_PROJECT_KEY, event: input.name, - properties: { - distinct_id: distinctId, - $insert_id: input.insertId, - ...(input.subject.scope === "team" - ? { $groups: { stack_team: input.subject.stackTeamId } } - : {}), - ...input.properties, - }, + properties, }); for (let attempt = 0; attempt < 2; attempt += 1) { try { diff --git a/web/services/analytics/syntheticAccounts.ts b/web/services/analytics/syntheticAccounts.ts new file mode 100644 index 000000000000..314220db3250 --- /dev/null +++ b/web/services/analytics/syntheticAccounts.ts @@ -0,0 +1,41 @@ +// Synthetic (probe) accounts in product analytics. +// +// The Cloud VM canary (.github/workflows/cloud-vm-canary.yml, every five +// minutes) and the manual smoke, dogfood, stress and startup-bench scripts +// under web/scripts/cloud-vm/ each mint a throwaway Stack user named +// `cmux--+@manaflow.dev`, create a +// machine, and destroy it within a minute. Those rows are real production +// traffic and stay in PostHog (they prove the create path works), but every +// product count must be able to exclude them, so their events carry +// `is_synthetic: true`. +// +// The address is the authoritative marker: only the team controls the +// manaflow.dev mailbox, and the scripts set the address server-side. + +const SYNTHETIC_ACCOUNT_EMAIL = /^cmux-(?:prod|staging)-(?:smoke|stress|bench)\+[^@\s]*@manaflow\.dev$/i; + +/** Whether a Stack primary email belongs to a probe account. */ +export function isSyntheticAccountEmail(email: string | null | undefined): boolean { + return typeof email === "string" && SYNTHETIC_ACCOUNT_EMAIL.test(email.trim()); +} + +// Ledger rows are mirrored after the response, sometimes outside the request +// that authenticated the user. Remember probe user ids seen by this instance +// so those rows are tagged too. Bounded: the canary mints one user per run. +const MAX_REMEMBERED_SYNTHETIC_USERS = 512; +const syntheticUserIds = new Set(); + +/** Record that an authenticated user is a probe account. */ +export function rememberSyntheticUser(userId: string): void { + if (syntheticUserIds.has(userId)) return; + if (syntheticUserIds.size >= MAX_REMEMBERED_SYNTHETIC_USERS) { + const oldest = syntheticUserIds.values().next().value; + if (oldest !== undefined) syntheticUserIds.delete(oldest); + } + syntheticUserIds.add(userId); +} + +/** Whether this instance authenticated `userId` as a probe account. */ +export function isRememberedSyntheticUser(userId: string | null | undefined): boolean { + return typeof userId === "string" && syntheticUserIds.has(userId); +} diff --git a/web/services/auth/stackWebhook.ts b/web/services/auth/stackWebhook.ts index d93654af5b0e..b2d76afd4a62 100644 --- a/web/services/auth/stackWebhook.ts +++ b/web/services/auth/stackWebhook.ts @@ -2,6 +2,7 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import type { HexclaveInboxStore } from "./hexclave/inbox"; import { hexclaveEventTarget, type HexclaveSyncDependencies } from "./hexclave/sync"; import { parseHexclaveWebhook, type HexclaveWebhookEvent } from "./hexclave/webhookEvents"; +import { captureAccountSignedUp } from "../analytics/accountSignup"; /** * Hexclave (formerly Stack Auth) webhooks, delivered by Svix. Every event @@ -77,6 +78,8 @@ export type StackWebhookDependencies = { readonly scheduleDrain?: () => void; readonly nowSeconds?: () => number; readonly log?: StackWebhookLog; + /** Product analytics for a new account (`cmux_account_signed_up`). */ + readonly captureSignup?: (data: unknown) => void; }; const defaultLog: StackWebhookLog = (level, message, fields) => { @@ -173,6 +176,9 @@ async function enqueueEvent( return json(503, { error: "inbox_unavailable", retryable: true }, { "retry-after": "30" }); } log("info", "stack_webhook_queued", { svixId, eventType: event.type }); + // Accepted exactly when the inbox stored it; a redelivery reuses the + // event's natural insert id, so the account is counted once. + if (event.type === "user.created") (dependencies.captureSignup ?? captureAccountSignedUp)(event.data); dependencies.scheduleDrain?.(); return json(200, { received: true, queued: event.type }); } diff --git a/web/services/billing/personalPortal.ts b/web/services/billing/personalPortal.ts index 2a32c949dce3..129ab1a3716d 100644 --- a/web/services/billing/personalPortal.ts +++ b/web/services/billing/personalPortal.ts @@ -27,7 +27,7 @@ export async function personalPortalSession(input: { }); if (canSwitch) await captureBillingPlanSwitchStarted({ sessionId: session.id, userId: input.userId, fromPlan: status.activePlanId, - targetPlan: input.target, attribution: input.attribution, + targetPlan: input.target, attribution: input.attribution, livemode: session.livemode, }); return session; } diff --git a/web/services/coderouter/viewAnalytics.ts b/web/services/coderouter/viewAnalytics.ts new file mode 100644 index 000000000000..e401b5bba0db --- /dev/null +++ b/web/services/coderouter/viewAnalytics.ts @@ -0,0 +1,17 @@ +// When a CodeRouter read counts as a product view. +// +// The native clients fetch the account list, the organization catalog and +// team machine usage on timers and on every auth refresh. Counting each fetch +// as a `coderouter_*_viewed` event produced about 1.3M events per event name +// in two weeks from roughly 100 people, so those reads are not views. A +// client marks the one request it makes when a person actually opens the +// view with this header, and only that request emits the event. The web +// dashboard sends it when a page loads the team catalog; server-rendered +// dashboard reads that do not pass through these routes keep emitting. + +export const CODEROUTER_VIEW_OPEN_HEADER = "x-cmux-view-open"; + +/** True when the client says this request opened a view (not a refresh or poll). */ +export function isCoderouterViewOpenRequest(request: Request): boolean { + return request.headers.get(CODEROUTER_VIEW_OPEN_HEADER)?.trim() === "1"; +} diff --git a/web/services/coderouter/vmMetrics.ts b/web/services/coderouter/vmMetrics.ts index 9b5864cbf6e7..04a0dbc87798 100644 --- a/web/services/coderouter/vmMetrics.ts +++ b/web/services/coderouter/vmMetrics.ts @@ -200,13 +200,19 @@ export async function loadCoderouterVmMetrics( export async function loadCoderouterTeamMachineMetrics( authorizedTeamId: string, surface: CoderouterVmUsageSurface = "team_machines_api", + options: { + /** False for a polled refresh: only an opened view emits `coderouter_vm_usage_viewed`. */ + readonly captureView?: boolean; + } = {}, ): Promise { const metrics = await cachedTeamMachineMetrics(authorizedTeamId); - captureCoderouterEvent({ - event: "coderouter_vm_usage_viewed", - teamId: authorizedTeamId, - properties: { surface, outcome: metrics.kind }, - }); + if (options.captureView !== false) { + captureCoderouterEvent({ + event: "coderouter_vm_usage_viewed", + teamId: authorizedTeamId, + properties: { surface, outcome: metrics.kind }, + }); + } return metrics; } diff --git a/web/services/vms/observability.ts b/web/services/vms/observability.ts index 061905e636b6..d579e75771d3 100644 --- a/web/services/vms/observability.ts +++ b/web/services/vms/observability.ts @@ -198,13 +198,24 @@ export function isPolledVmOperation(operation: string): boolean { /** * Identity and billing scope shared by every PostHog row a VM request emits: - * the machine addressed, the plan and billing team, and the `stack_team` - * group so account-level dashboards join billing and usage. `$groups` is a - * nested object, hence the wider value type. + * the machine addressed, the plan and billing team, the `stack_team` group so + * account-level dashboards join billing and usage, and `is_synthetic` for + * probe accounts. `$groups` is a nested object, hence the wider value type. */ function requestScopeProperties(context: VmRequestContext): Record> { const properties: Record> = {}; - if (context.vmId) properties.vm_id = context.vmId; + // `vm_id` is the provider id from the URL. A collection route (create, base + // open) has none; the ledger id it wrote names the machine instead, the + // internal UUID `cloud_vm_created.vm_id` carries. `vm_id_kind` says which. + if (context.vmId) { + properties.vm_id = context.vmId; + properties.vm_id_kind = "provider"; + } else if (context.ledgerVmId) { + properties.vm_id = context.ledgerVmId; + properties.vm_id_kind = "ledger"; + } + // Probe accounts (the Cloud VM canary): kept, but excludable. + if (context.synthetic) properties.is_synthetic = true; if (context.planId) properties.plan_id = context.planId; if (context.billingCustomerType) properties.billing_customer_type = context.billingCustomerType; if (context.billingTeamId) { @@ -220,8 +231,10 @@ export const VM_REQUEST_POSTHOG_EVENT = "cloud_vm_request"; * Schema 2 (2026-09): adds `vm_id`, `plan_id`, `billing_customer_type`, * `billing_team_id` and the `stack_team` group; moves * `approve_cmux_remote_enrollment` successes out of the event. + * Schema 3 (2026-10): `vm_id` falls back to the ledger id on collection + * routes (create, base open); adds `is_synthetic` for probe accounts. */ -export const VM_REQUEST_POSTHOG_SCHEMA_VERSION = 2; +export const VM_REQUEST_POSTHOG_SCHEMA_VERSION = 3; type PostHogExceptionEntry = { readonly type: string; @@ -624,8 +637,9 @@ export function captureVmProvisionOutcome( // A missing code on a 5xx (a response that bypassed vmErrorResponse) is // still an operator fault; isOperatorFaultVmError treats every 5xx as one. operator_fault: isOperatorFaultVmError({ error: code ?? "", status }), - // Schema 3: plan, billing team and the stack_team group. - schema_version: 3, + // Schema 3: plan, billing team and the stack_team group. Schema 4: the + // ledger vm_id on collection routes and `is_synthetic`. + schema_version: 4, $insert_id: randomUUID(), $geoip_disable: true, }; diff --git a/web/services/vms/productAnalytics.ts b/web/services/vms/productAnalytics.ts index 9392849ebbf6..aabc0afbf374 100644 --- a/web/services/vms/productAnalytics.ts +++ b/web/services/vms/productAnalytics.ts @@ -22,7 +22,9 @@ import { type ServerEventInput, type ServerEventScalar, } from "../analytics/serverEvents"; +import { isRememberedSyntheticUser } from "../analytics/syntheticAccounts"; import type { VmRepositoryShape, VmUsageEventInput } from "./repository"; +import { currentVmRequestContext, noteVmRequestLedgerVmId } from "./requestContext"; export const VM_PRODUCT_ANALYTICS_SCHEMA_VERSION = 1; @@ -152,6 +154,7 @@ export function vmProductEventFromLedger( plan_id: planId, billing_team_id: input.billingTeamId ?? undefined, schema_version: VM_PRODUCT_ANALYTICS_SCHEMA_VERSION, + is_synthetic: isSyntheticLedgerRow(input) ? true : undefined, ...METADATA_PICKERS[input.eventType](metadata), }; if (input.eventType === "vm.destroyed" && input.vmCreatedAt) { @@ -177,6 +180,44 @@ export function vmProductEventFromLedger( }; } +/** PostHog event for a machine that reached `running` and accepts connections. */ +export const VM_READY_POSTHOG_EVENT = "cloud_vm_ready"; + +/** + * `cloud_vm_ready`: the machine is usable (its `mark_running` committed, + * which is when the create workflow writes `vm.created`). `time_to_ready_ms` + * runs from the machine row's insert, the moment the request was accepted, + * so it covers provider provisioning and boot. Null for rows that are not a + * successful create, or when the machine's `createdAt` is unknown. + */ +export function vmReadyEventFromLedger( + input: VmUsageEventInput, + now: Date = new Date(), +): ServerEventInput | null { + if (input.eventType !== "vm.created" || !input.vmId || !input.vmCreatedAt) return null; + const timeToReadyMs = now.getTime() - input.vmCreatedAt.getTime(); + if (!Number.isFinite(timeToReadyMs) || timeToReadyMs < 0) return null; + const metadata = input.metadata ?? {}; + return { + event: VM_READY_POSTHOG_EVENT, + distinctId: input.userId, + teamId: input.billingTeamId, + properties: { + product: "cloud_vm", + vm_id: input.vmId, + plan_id: normalizedPlan(input.billingPlanId), + provider: input.provider, + origin: enumValue(metadata.origin, ["create", "restore", "fork", "base"]) ?? "create", + time_to_ready_ms: Math.round(timeToReadyMs), + billing_team_id: input.billingTeamId ?? undefined, + is_synthetic: isSyntheticLedgerRow(input) ? true : undefined, + schema_version: VM_PRODUCT_ANALYTICS_SCHEMA_VERSION, + }, + insertId: `${VM_READY_POSTHOG_EVENT}:${input.vmId}`, + timestamp: now, + }; +} + export type VmProductCapture = (input: VmUsageEventInput) => void; /** Default capture: map the ledger row and hand it to the shared sender. */ @@ -184,9 +225,19 @@ export function captureVmProductEvent( input: VmUsageEventInput, dependencies: Partial = {}, ): void { - const event = vmProductEventFromLedger(input, dependencies.now?.() ?? new Date()); - if (!event) return; - void captureServerEvent(event, dependencies); + const now = dependencies.now?.() ?? new Date(); + for (const event of [vmProductEventFromLedger(input, now), vmReadyEventFromLedger(input, now)]) { + if (event) void captureServerEvent(event, dependencies); + } +} + +/** + * A probe account's row: the request that wrote it authenticated a probe + * account, or this instance did earlier (rows mirrored after the response). + */ +function isSyntheticLedgerRow(input: VmUsageEventInput): boolean { + const context = currentVmRequestContext(); + return (context?.synthetic === true && context.userId === input.userId) || isRememberedSyntheticUser(input.userId); } /** @@ -200,6 +251,8 @@ export function withVmProductAnalytics( ): VmRepositoryShape { const safeCapture = (input: VmUsageEventInput): void => { try { + // The request's PostHog rows name the machine this row is about. + noteVmRequestLedgerVmId(input.vmId); capture(input); } catch (error) { console.warn("[analytics] cloud vm product capture failed", { diff --git a/web/services/vms/repository.ts b/web/services/vms/repository.ts index 11f2a91d3cc2..58d63db67654 100644 --- a/web/services/vms/repository.ts +++ b/web/services/vms/repository.ts @@ -806,7 +806,8 @@ export type VmUsageEventInput = { readonly metadata?: Record; /** * The machine's `createdAt`, supplied by destroy sites so analytics can - * report the machine's lifetime. Not persisted: the ledger row already + * report the machine's lifetime, and by create-success sites for + * `cloud_vm_ready.time_to_ready_ms`. Not persisted: the ledger row already * joins to `cloud_vms` by `vmId`. */ readonly vmCreatedAt?: Date | null; diff --git a/web/services/vms/requestContext.ts b/web/services/vms/requestContext.ts index f801152c7a9f..9dac55843f9a 100644 --- a/web/services/vms/requestContext.ts +++ b/web/services/vms/requestContext.ts @@ -58,6 +58,15 @@ export type VmRequestContext = { * client uses), read from the URL so every per-machine event joins to it. */ vmId?: string; + /** + * The ledger id (`cloud_vms.id`) of the machine this request created or + * touched, recorded when the request writes its first usage-ledger row. + * Collection routes (create, base open) have no `vmId` in the URL, so this + * is how their PostHog rows still name the machine. + */ + ledgerVmId?: string; + /** The caller is a probe account (services/analytics/syntheticAccounts.ts). */ + synthetic?: boolean; /** * Billing scope of the caller. Set from the authenticated user, then * refined by entitlement resolution when a route resolves one. @@ -88,6 +97,17 @@ export function annotateVmRequestBilling(billing: { context.planId = planId || undefined; } +/** + * Note the ledger id of the machine a request wrote a usage row for, once. + * The first row wins: a create writes `vm.create.requested` for the new row + * before anything else. + */ +export function noteVmRequestLedgerVmId(vmId: string | null | undefined): void { + const context = storage.getStore(); + if (!context || context.ledgerVmId || !vmId) return; + context.ledgerVmId = vmId; +} + /** * The provider vm id segment of a `/api/vm//...` path, or undefined for * collection routes (`/api/vm`, `/api/vm/base/...`, `/api/vm/leases/...`). diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index d47635712847..6f6498073c43 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -47,6 +47,7 @@ import { import { recordSpanTiming } from "./timings"; import { authProviderErrorResponse } from "./authErrors"; import { goCapacityConstraint } from "./goUsage"; +import { isSyntheticAccountEmail, rememberSyntheticUser } from "../analytics/syntheticAccounts"; import { captureVmRequestOutcome, isPolledVmOperation, @@ -195,6 +196,7 @@ export async function withAuthedVmApiRoute( recordSpanTiming(span, "auth", authDurationMs); if (!user) return finalize(unauthorized()); requestContext.userId = user.id; + markSyntheticVmCaller(requestContext, user); requestContext.operationId = cloudOperationId(request.headers.get("x-cmux-operation-id")); if (requestContext.operationId && !isPolledVmOperation(operation)) { requestContext.progress = new CloudOperationProgress(user.id, requestContext.operationId); @@ -236,6 +238,16 @@ export async function withAuthedVmApiRoute( )); } +/** + * Tag a probe account's request so every PostHog row it produces carries + * `is_synthetic`, including ledger rows mirrored after the response. + */ +function markSyntheticVmCaller(context: VmRequestContext, user: AuthedUser): void { + if (!isSyntheticAccountEmail(user.primaryEmail)) return; + context.synthetic = true; + rememberSyntheticUser(user.id); +} + function scheduleTraceFlush(): void { const flush = () => forceFlushTraces(); try { diff --git a/web/services/vms/usageAnalytics.ts b/web/services/vms/usageAnalytics.ts new file mode 100644 index 000000000000..b6f18ad5bde4 --- /dev/null +++ b/web/services/vms/usageAnalytics.ts @@ -0,0 +1,184 @@ +// `cloud_vm_usage_hourly`: the cost signal. Once an hour, one PostHog event +// per machine that ran during the previous whole UTC hour, with the seconds it +// ran and the shape it reserved. Multiplied by provider list rates it gives +// cost per machine, plan and team; against net revenue it gives margin. +// +// Running time comes from `cloud_vm_runtime_intervals`, which a database +// trigger opens and closes with every status change (the Go plan meters from +// it), so a lost request or event cannot skew it. A machine that ran for part +// of the hour reports only that part; paused time is not running time. +// +// Volume is one event per running machine per hour, capped at +// VM_USAGE_HOURLY_MAX_MACHINES per run. A run that hits the cap reports it and +// the overflow is not sent. The natural insert id makes a retried run of the +// same hour idempotent. +import { and, asc, eq, gt, isNull, lt, or } from "drizzle-orm"; + +import { cloudDb } from "../../db/client"; +import { cloudVmRuntimeIntervals, cloudVms, hexclaveUsers } from "../../db/schema"; +import { + captureServerEventBatch, + serverAnalyticsEnabled, + type ServerEventDependencies, + type ServerEventInput, +} from "../analytics/serverEvents"; +import { isSyntheticAccountEmail } from "../analytics/syntheticAccounts"; +import { + hasVmResourceReservationMetadata, + vmResourceReservationFromMetadata, +} from "./machineSpec"; + +export const VM_USAGE_HOURLY_EVENT = "cloud_vm_usage_hourly"; +export const VM_USAGE_HOURLY_SCHEMA_VERSION = 1; +/** Hard cap on events per run; far above today's fleet. */ +export const VM_USAGE_HOURLY_MAX_MACHINES = 5_000; + +const HOUR_MS = 60 * 60 * 1000; +const MB_PER_GIB = 1024; + +/** One runtime interval that overlaps the hour, joined with its machine. */ +export type VmUsageIntervalRow = { + readonly vmId: string; + readonly userId: string; + readonly billingTeamId: string | null; + readonly billingPlanId: string | null; + readonly provider: string; + readonly providerMetadata: Record | null; + readonly primaryEmail: string | null; + readonly startedAt: Date; + readonly endedAt: Date | null; +}; + +export type VmUsageHourWindow = { readonly start: Date; readonly end: Date }; + +/** The previous whole UTC hour before `now`. */ +export function previousUtcHour(now: Date): VmUsageHourWindow { + const end = new Date(Math.floor(now.getTime() / HOUR_MS) * HOUR_MS); + return { start: new Date(end.getTime() - HOUR_MS), end }; +} + +/** + * Fold interval rows into one event per machine. Pure: the window, the rows + * and the cap fully decide the result. + */ +export function vmUsageHourlyEvents( + rows: readonly VmUsageIntervalRow[], + window: VmUsageHourWindow, + maxMachines: number = VM_USAGE_HOURLY_MAX_MACHINES, +): { readonly events: ServerEventInput[]; readonly machines: number; readonly truncated: number } { + const byVm = new Map(); + for (const row of rows) { + const overlapMs = Math.min(row.endedAt?.getTime() ?? window.end.getTime(), window.end.getTime()) - + Math.max(row.startedAt.getTime(), window.start.getTime()); + if (overlapMs <= 0) continue; + const entry = byVm.get(row.vmId); + if (entry) entry.runningMs += overlapMs; + else byVm.set(row.vmId, { row, runningMs: overlapMs }); + } + const machines = [...byVm.values()]; + const events = machines.slice(0, maxMachines).map(({ row, runningMs }) => usageEvent(row, runningMs, window)); + return { events, machines: machines.length, truncated: Math.max(0, machines.length - maxMachines) }; +} + +/** Account deletion re-owns shared-team machines to this placeholder user id. */ +const DELETED_ACCOUNT_USER_ID = "deleted-account"; + +function usageEvent(row: VmUsageIntervalRow, runningMs: number, window: VmUsageHourWindow): ServerEventInput { + const reservation = vmResourceReservationFromMetadata(row.providerMetadata); + const hourStart = window.start.toISOString(); + // A machine of a deleted account still costs money, but its placeholder + // owner is not a person: send it person-less so no fake person collects + // every deleted user's usage. + const deletedOwner = row.userId === DELETED_ACCOUNT_USER_ID; + return { + event: VM_USAGE_HOURLY_EVENT, + distinctId: row.userId, + personless: deletedOwner, + teamId: row.billingTeamId, + properties: { + product: "cloud_vm", + vm_id: row.vmId, + plan_id: row.billingPlanId?.trim().toLowerCase().slice(0, 40) || undefined, + provider: row.provider, + billing_team_id: row.billingTeamId ?? undefined, + hour_start: hourStart, + // Overlapping intervals of one machine cannot exceed the hour. + running_seconds: Math.min(3600, Math.round(runningMs / 1000)), + vcpu: reservation.vcpus, + memory_gib: reservation.memoryMb / MB_PER_GIB, + disk_gib: reservation.diskMb / MB_PER_GIB, + // Legacy rows without a reservation marker report the default shape. + resources_estimated: !hasVmResourceReservationMetadata(row.providerMetadata), + is_synthetic: isSyntheticAccountEmail(row.primaryEmail) ? true : undefined, + schema_version: VM_USAGE_HOURLY_SCHEMA_VERSION, + }, + insertId: `${VM_USAGE_HOURLY_EVENT}:${row.vmId}:${hourStart}`, + // Stamped at the end of the hour it describes, so a daily rollup puts + // the usage on the day it happened. + timestamp: new Date(window.end.getTime() - 1), + }; +} + +/** Row bound of one read: a machine has at most a few intervals in an hour. */ +const MAX_INTERVAL_ROWS = VM_USAGE_HOURLY_MAX_MACHINES * 4; + +/** + * Interval rows overlapping the window, ordered by machine so a capped read + * drops whole machines. There is no index on `ended_at`: this is a scan of + * the interval table once an hour, which is small next to `cloud_vms`; add a + * partial index if it grows past that. + */ +async function loadUsageIntervals(window: VmUsageHourWindow): Promise { + const rows = await cloudDb() + .select({ + vmId: cloudVms.id, + userId: cloudVms.userId, + billingTeamId: cloudVms.billingTeamId, + billingPlanId: cloudVms.billingPlanId, + provider: cloudVms.provider, + providerMetadata: cloudVms.providerMetadata, + primaryEmail: hexclaveUsers.primaryEmail, + startedAt: cloudVmRuntimeIntervals.startedAt, + endedAt: cloudVmRuntimeIntervals.endedAt, + }) + .from(cloudVmRuntimeIntervals) + .innerJoin(cloudVms, eq(cloudVms.id, cloudVmRuntimeIntervals.vmId)) + .leftJoin(hexclaveUsers, eq(hexclaveUsers.id, cloudVms.userId)) + .where(and( + lt(cloudVmRuntimeIntervals.startedAt, window.end), + or(isNull(cloudVmRuntimeIntervals.endedAt), gt(cloudVmRuntimeIntervals.endedAt, window.start)), + )) + .orderBy(asc(cloudVmRuntimeIntervals.vmId), asc(cloudVmRuntimeIntervals.startedAt)) + .limit(MAX_INTERVAL_ROWS); + return rows; +} + +export type VmUsageHourlySummary = { + readonly hourStart: string; + /** False outside production (no force flag): nothing was read or sent. */ + readonly enabled: boolean; + readonly machines: number; + readonly sent: number; + readonly failed: number; + readonly truncated: number; + /** The interval read hit its row bound; machines after the last row were not read. */ + readonly rowLimitReached?: boolean; +}; + +/** Emit the previous hour's usage. Throws only when the database read fails. */ +export async function captureVmUsageHourly( + now: Date = new Date(), + dependencies: Partial = {}, +): Promise { + const window = previousUtcHour(now); + const hourStart = window.start.toISOString(); + if (!serverAnalyticsEnabled(dependencies.env ?? process.env)) { + return { hourStart, enabled: false, machines: 0, sent: 0, failed: 0, truncated: 0 }; + } + const rows = await loadUsageIntervals(window); + const { events, machines, truncated } = vmUsageHourlyEvents(rows, window); + const result = await captureServerEventBatch(events, dependencies); + // A full read may have cut machines after the last one returned. + const rowLimitReached = rows.length >= MAX_INTERVAL_ROWS; + return { hourStart, enabled: true, machines, truncated, rowLimitReached, ...result }; +} diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index 0504332252d9..e4c63818bf41 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -5829,6 +5829,8 @@ function createSuccessUsageEvents(input: CreateSuccessEventInput, running: Cloud eventType: "vm.created", provider: running.provider, imageId: running.imageId, + // Not persisted; analytics derives `cloud_vm_ready.time_to_ready_ms`. + vmCreatedAt: running.createdAt, metadata: { idempotencyKeySet: !!input.idempotencyKey, imageVersion: running.imageVersion, diff --git a/web/tests/analytics-events-route.test.ts b/web/tests/analytics-events-route.test.ts index 684b836b4d8a..582b52335417 100644 --- a/web/tests/analytics-events-route.test.ts +++ b/web/tests/analytics-events-route.test.ts @@ -187,7 +187,7 @@ describe("iOS analytics events route", () => { const [event] = forwardedPostHogBatch(); expect(event.distinct_id).not.toBe(deletedUserID); expect(event.distinct_id).toStartWith("ios-anon-sha256:"); - expect(event.properties).toEqual({}); + expect(event.properties).toEqual({ channel: "unknown" }); }); test("namespaces the anonymous alias on an authenticated identify event", async () => { diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts index d313844dae9a..d709e9666884 100644 --- a/web/tests/billing-checkout-route.test.ts +++ b/web/tests/billing-checkout-route.test.ts @@ -951,7 +951,7 @@ describe("billing checkout route", () => { expect(captureBillingCheckoutStarted).toHaveBeenCalledTimes(1); expect(captureBillingCheckoutStarted).toHaveBeenCalledWith({ sessionId: CHECKOUT_SESSION_ID, - subject: { scope: "team", stackTeamId: TEAM_ID }, + subject: { scope: "team", stackTeamId: TEAM_ID, actorUserId: SIGNED_IN_USER_ID }, plan: "team", billingInterval: "month", attribution: expect.objectContaining({ source: "unknown", client: "web" }), diff --git a/web/tests/hosted-subrouter-routes.test.ts b/web/tests/hosted-subrouter-routes.test.ts index 9996069674f4..6949c5e36640 100644 --- a/web/tests/hosted-subrouter-routes.test.ts +++ b/web/tests/hosted-subrouter-routes.test.ts @@ -438,7 +438,7 @@ describe("hosted Subrouter account routes", () => { nested: { accessToken: "must-not-leak" }, }]; - const response = await accountsRoute.GET(request("/api/subrouter/accounts")); + const response = await accountsRoute.GET(request("/api/subrouter/accounts", { headers: { "x-cmux-view-open": "1" } })); const text = await response.text(); expect(response.status).toBe(200); diff --git a/web/tests/vm-request-telemetry.test.ts b/web/tests/vm-request-telemetry.test.ts index 39504c9365d7..28240097e979 100644 --- a/web/tests/vm-request-telemetry.test.ts +++ b/web/tests/vm-request-telemetry.test.ts @@ -272,7 +272,7 @@ describe("cloud_vm_request capture", () => { client_name: "cmux-mac", client_version: "1.2.3", client_channel: "nightly", - schema_version: 2, + schema_version: 3, }); expect(exception.properties).toMatchObject({ trace_id: TRACE_ID, diff --git a/web/vercel.json b/web/vercel.json index b333c5840383..cfdae8289f21 100644 --- a/web/vercel.json +++ b/web/vercel.json @@ -70,6 +70,10 @@ { "path": "/api/cron/apple-notifications", "schedule": "17,47 * * * *" + }, + { + "path": "/api/cron/vm-usage-analytics", + "schedule": "5 * * * *" } ] } From 1d86039c35a7a53a6efdf4839bef7af47a8c44e4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 03:31:06 -0700 Subject: [PATCH 046/183] test(acpmux): start modes that do not ask from a session, an approved preset, and peered prompts Behavioral tests at the person-gate boundary (real daemon over its unix socket, and two peered hubs): - a session in a no-ask mode starts a session in its own mode; a caller that is no session, a stronger or other-family mode, a parent named in params and a wrong or swapped token are refused - a preset the person approved starts in its mode from the CLI; an edit after approval drops it; only the person approves or sets a no-ask mode - the refusal names the host to approve on and the preset - a peered watcher sees a pending prompt; a non-person allow is refused Co-Authored-By: Claude Opus 5.5 --- .../crates/acpmux/tests/peer_integration.rs | 81 +++ .../crates/acpmux/tests/person_auto_start.rs | 513 ++++++++++++++++++ 2 files changed, 594 insertions(+) create mode 100644 cmux-tui/crates/acpmux/tests/person_auto_start.rs diff --git a/cmux-tui/crates/acpmux/tests/peer_integration.rs b/cmux-tui/crates/acpmux/tests/peer_integration.rs index c25ae1b20c8a..44bd02e37ebe 100644 --- a/cmux-tui/crates/acpmux/tests/peer_integration.rs +++ b/cmux-tui/crates/acpmux/tests/peer_integration.rs @@ -584,3 +584,84 @@ async fn every_answer_for_a_peers_prompt_goes_to_the_peer_within_a_hop_limit() { .await; assert_eq!(decided["msg"]["outcome"]["optionId"], "no", "{decided}"); } + +/// A pending prompt that only the person may allow reaches every app that +/// watches the session, also through a peer: a watcher on A that never +/// attached B's session gets `_acpmux/permission_pending` with B's decision +/// nonce and run, so A's app can show it and notify. An allow from a +/// connection that is not the person, without B's proof, is refused on B +/// and the prompt stays pending; B's proof counts. +#[tokio::test] +async fn peered_watcher_sees_a_pending_prompt_and_only_the_persons_proof_allows_it() { + const B_KEY: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + let b = hub(config(PermissionPolicy::Ask)).await; + b.person.install_spawn_key(B_KEY).unwrap(); + let (port, peer_token) = listen_with_peer_token(b.clone(), "watchpend").await; + let mut cb = plain(b.clone()).await; + let s = cb + .call( + method::SESSION_NEW, + json!({"cwd": std::env::temp_dir(), "mcpServers": [], "_meta": {"acpmux": {"name": "far"}}}), + ) + .await + .unwrap(); + let remote_id = s["sessionId"].as_str().unwrap().to_owned(); + cb.call(method::MUX_ATTACH, json!({"sessionId": remote_id, "limit": 0})).await.unwrap(); + let a = hub(config(PermissionPolicy::Ask)).await; + a.add_peer("b", &format!("ws://127.0.0.1:{port}"), Some("tok".into()), Some(peer_token), true) + .await + .unwrap(); + // A's watcher: watches everything, attaches nothing, is not the person. + let mut watcher = plain(a.clone()).await; + watcher.call(method::MUX_WATCH, json!({"enabled": true})).await.unwrap(); + + // The prompt is raised on B by B's own client. + cb.next += 1; + let prompt_id = cb.next; + cb.tx + .send( + Message::request( + prompt_id, + method::SESSION_PROMPT, + json!({"sessionId": remote_id, "prompt": [{"type": "text", "text": "ask: deploy"}]}), + ) + .to_line(), + ) + .await + .unwrap(); + let pending = + watcher.wait(method::MUX_PERMISSION_PENDING, |p| p["sessionId"] == remote_id).await; + let perm = pending["permissionId"].as_str().expect("the prompt's id").to_owned(); + let nonce = pending["decisionNonce"].as_str().expect("B's decision nonce").to_owned(); + let instance = pending["personInstance"].as_str().expect("B's daemon run").to_owned(); + assert_eq!(pending["peer"], "b", "{pending}"); + assert_eq!(pending["request"]["toolCall"]["title"], "deploy", "{pending}"); + + // Not the person, no proof: refused on B; still pending there. + let allow = |meta: Value| { + json!({"sessionId": remote_id, "permissionId": perm, "optionId": "yes", + "_meta": {"acpmux": meta}}) + }; + let e = watcher.call(method::MUX_PERMISSION_RESPOND, allow(json!({}))).await.unwrap_err(); + assert!(e.contains("approve this on the Mac app"), "{e}"); + let info = cb.call(method::MUX_INFO, json!({"sessionId": remote_id})).await.unwrap(); + assert_eq!(info["pending"][0]["permissionId"], perm.as_str(), "{info}"); + + // The person's proof (made by the app that holds B's key) counts. + let good = + acpmux::hub::person::decision_proof(B_KEY, &instance, &remote_id, &perm, "yes", &nonce) + .unwrap(); + watcher + .call( + method::MUX_PERMISSION_RESPOND, + allow(json!({"decisionNonce": nonce, "personDecision": good})), + ) + .await + .unwrap(); + let decided = cb + .wait(method::MUX_EVENT, |p| { + p["kind"] == "permission_decision" && p["sessionId"] == remote_id + }) + .await; + assert_eq!(decided["msg"]["outcome"]["optionId"], "yes", "{decided}"); +} diff --git a/cmux-tui/crates/acpmux/tests/person_auto_start.rs b/cmux-tui/crates/acpmux/tests/person_auto_start.rs new file mode 100644 index 000000000000..a1126d226b25 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/person_auto_start.rs @@ -0,0 +1,513 @@ +//! A session that starts in a mode that does not ask, from the CLI, a script +//! or an agent (`hub/person.rs`, "Starting in a mode that does not ask"). +//! The real daemon binary over its real unix socket: +//! +//! - (a) inherit: a caller that is itself an acpmux session, proven by the +//! caller token acpmux put in that session's env (never by a claim in +//! params), may start a session of its own family in its own current mode, +//! never another mode that does not ask; +//! - (b) a preset the person approved once (`approveMode`) starts in its +//! mode from any client, until the preset changes; +//! - (d) every refusal names the host to approve on and the preset to use. +#![cfg(unix)] + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader}; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + +const FAKE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); +const REASON: &str = "permission.person_required"; +const KEY: &str = "5e1f0c2a9b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f"; + +fn person_proof(key: &str, nonce: &str, connection: &str) -> Option { + use cmux_local_auth::frontend_proof::{hex, hmac_sha256, unhex}; + let key = unhex::<32>(key)?; + let mut message = b"acpmux-person-v1\0unix\0".to_vec(); + message.extend_from_slice(nonce.as_bytes()); + message.push(0); + message.extend_from_slice(connection.as_bytes()); + Some(hex(&hmac_sha256(&key, &message))) +} + +/// The host the refusal names: this machine's host name without `.local`. +fn host_label() -> String { + let mut buf = [0u8; 256]; + // SAFETY: a writable buffer of the given length. + let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; + assert_eq!(rc, 0, "gethostname"); + let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len()); + let name = String::from_utf8_lossy(&buf[..end]).trim().to_owned(); + name.strip_suffix(".local").map(str::to_owned).unwrap_or(name) +} + +struct Rpc { + lines: tokio::io::Lines>, + wr: tokio::net::unix::OwnedWriteHalf, + next: i64, + notes: Vec, +} + +impl Rpc { + async fn connect(path: &Path) -> Self { + let s = tokio::net::UnixStream::connect(path).await.expect("connect to daemon socket"); + let (rd, wr) = s.into_split(); + Self { lines: tokio::io::BufReader::new(rd).lines(), wr, next: 0, notes: Vec::new() } + } + + /// A client that only says `initialize` (the CLI outside any session). + async fn plain(path: &Path) -> Self { + Self::with_init(path, json!({"protocolVersion": 1})).await + } + + /// A client whose `initialize` carries `params` (the CLI inside a + /// session presents its caller token there). + async fn with_init(path: &Path, params: Value) -> Self { + let mut c = Self::connect(path).await; + let r = c.call("initialize", params).await; + assert!(r.get("error").is_none(), "initialize: {r}"); + c + } + + /// The CLI inside session `id`, as `Client::connect` presents it. + async fn in_session(path: &Path, id: &str, token: &str) -> Self { + Self::with_init( + path, + json!({"protocolVersion": 1, + "_meta": {"acpmux": {"callerSession": {"sessionId": id, "token": token}}}}), + ) + .await + } + + /// The app: `initialize`, then the person proof. + async fn person(path: &Path, key: &str) -> Self { + let mut c = Self::connect(path).await; + let r = c.call("initialize", json!({"protocolVersion": 1})).await; + let challenge = r["result"]["_meta"]["acpmux"]["personChallenge"].clone(); + let proof = person_proof( + key, + challenge["nonce"].as_str().unwrap(), + challenge["connection"].as_str().unwrap(), + ) + .unwrap(); + let r = c.call("_acpmux/person_prove", json!({"proof": proof})).await; + assert_eq!(r["result"]["person"], true, "{r}"); + c + } + + async fn line(&mut self) -> Value { + let line = tokio::time::timeout(Duration::from_secs(30), self.lines.next_line()) + .await + .expect("daemon answered in time") + .unwrap() + .expect("daemon closed the socket"); + serde_json::from_str(&line).unwrap() + } + + async fn send(&mut self, method: &str, params: Value) -> i64 { + self.next += 1; + let line = json!({"jsonrpc": "2.0", "id": self.next, "method": method, "params": params}); + self.wr.write_all(format!("{line}\n").as_bytes()).await.unwrap(); + self.next + } + + async fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.send(method, params).await; + self.reply(id).await + } + + async fn reply(&mut self, id: i64) -> Value { + if let Some(i) = self.notes.iter().position(|n| n.get("id") == Some(&json!(id))) { + return self.notes.remove(i); + } + loop { + let v = self.line().await; + if v.get("id") == Some(&json!(id)) { + return v; + } + self.notes.push(v); + } + } + + async fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params).await; + assert!(r.get("error").is_none(), "{method} failed: {r}"); + r["result"].clone() + } + + async fn note(&mut self, method: &str, pick: impl Fn(&Value) -> bool) -> Value { + if let Some(i) = self.notes.iter().position(|n| n["method"] == method && pick(&n["params"])) + { + return self.notes.remove(i)["params"].clone(); + } + loop { + let v = self.line().await; + if v["method"] == method && pick(&v["params"]) { + return v["params"].clone(); + } + self.notes.push(v); + } + } +} + +struct Daemon { + child: std::process::Child, + socket: PathBuf, + dir: PathBuf, +} + +impl Daemon { + /// The real daemon with the app's key on `--person-key-fd 3`. The fake + /// harness asks in `normal` and `strict`; any other mode does not ask. + fn start(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("aas-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("work")).unwrap(); + let dir = std::fs::canonicalize(&dir).unwrap(); + std::fs::write( + dir.join("config.json"), + json!({ + "harnesses": { + "fake": {"argv": ["python3", FAKE]}, + "mirror": {"argv": ["python3", FAKE], "family": "mirror"}, + }, + "defaultHarness": "fake", + "permissionPolicy": "ask", + "webAskingModes": {"fake": ["normal", "strict"], "mirror": ["normal", "strict"]}, + }) + .to_string(), + ) + .unwrap(); + let socket = dir.join("s.sock"); + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")); + cmd.args(["daemon", "run", "--memory", "--ready-fd", "1", "--log", "warn"]) + .args(["--person-key-fd", "3"]) + .env("ACPMUX_HOME", &dir) + .env("ACPMUX_SOCKET", &socket) + .env("ACPMUX_LOGIN_ENV", "0") + .env_remove("ACPMUX_SESSION_ID") + .env_remove("ACPMUX_SESSION_TOKEN") + .env_remove("XPC_SERVICE_NAME") + .env_remove("CLAUDECODE") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + let mut fds = [-1i32; 2]; + // SAFETY: a fresh pipe; both ends are owned here. + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let line = format!("{KEY}\n"); + // SAFETY: the write end is open; the line fits the pipe buffer. + let n = unsafe { libc::write(fds[1], line.as_ptr().cast(), line.len()) }; + assert_eq!(n as usize, line.len()); + // SAFETY: closing our own write end. + unsafe { libc::close(fds[1]) }; + let read_end = fds[0]; + // SAFETY: only dup2 between fork and exec (async-signal-safe). + unsafe { + cmd.pre_exec(move || { + if libc::dup2(read_end, 3) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = cmd.spawn().unwrap(); + // SAFETY: the child has its own copy on fd 3. + unsafe { libc::close(fds[0]) }; + let stdout = child.stdout.take().unwrap(); + let ready = BufReader::new(stdout) + .lines() + .map_while(Result::ok) + .any(|line| serde_json::from_str::(&line).is_ok_and(|v| v["ready"] == true)); + assert!(ready, "the daemon exited before its ready line"); + Daemon { child, socket, dir } + } + + fn work(&self) -> PathBuf { + self.dir.join("work") + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + // SAFETY: the pid of the child this test spawned and still owns. + unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn reason(v: &Value) -> &str { + v["error"]["data"]["reason"].as_str().unwrap_or_default() +} + +fn message(v: &Value) -> &str { + v["error"]["message"].as_str().unwrap_or_default() +} + +/// `session/new` params on `harness` with `_meta.acpmux` fields `meta`. +fn new_params(d: &Daemon, meta: Value) -> Value { + json!({"cwd": d.work(), "mcpServers": [], "_meta": {"acpmux": meta}}) +} + +fn current_mode(new_result: &Value) -> &str { + new_result["modes"]["currentModeId"].as_str().unwrap_or_default() +} + +async fn session_count(c: &mut Rpc) -> usize { + let v = c.ok("_acpmux/sessions", json!({})).await; + v["sessions"].as_array().map_or(0, Vec::len) +} + +/// The person's session on `harness` in `mode`, and the caller token its +/// agent sees in its env (`ACPMUX_SESSION_TOKEN`). +async fn parent_in(app: &mut Rpc, d: &Daemon, harness: &str, mode: &str) -> (String, String) { + let s = app.ok("session/new", new_params(d, json!({"harness": harness}))).await; + let id = s["sessionId"].as_str().unwrap().to_owned(); + app.ok("_acpmux/attach", json!({"sessionId": id})).await; + app.ok("session/set_mode", json!({"sessionId": id, "modeId": mode})).await; + let rid = app + .send( + "session/prompt", + json!({"sessionId": id, "prompt": [{"type": "text", "text": "env: ACPMUX_SESSION_TOKEN"}]}), + ) + .await; + let chunk = app + .note("session/update", |p| { + p["sessionId"] == id.as_str() && p["update"]["sessionUpdate"] == "agent_message_chunk" + }) + .await; + let text = chunk["update"]["content"]["text"].as_str().unwrap_or_default().to_owned(); + let done = app.reply(rid).await; + assert!(done.get("error").is_none(), "{done}"); + let token = text.strip_prefix("ACPMUX_SESSION_TOKEN=").unwrap_or_default().to_owned(); + assert_eq!(token.len(), 64, "the session's agent gets a caller token: {text:?}"); + // acpmux never shows the token to clients (only the agent's own reply, + // which printed it here, carries it). + let mut info = app.ok("_acpmux/info", json!({"sessionId": id})).await; + for echoed in ["lastMessage", "preview"] { + info.as_object_mut().unwrap().remove(echoed); + } + assert!(!info.to_string().contains(&token), "{info}"); + (id, token) +} + +#[tokio::test] +async fn auto_start_a_session_in_a_no_ask_mode_starts_a_session_in_its_own_mode() { + let d = Daemon::start("inherit"); + let mut app = Rpc::person(&d.socket, KEY).await; + let (parent, token) = parent_in(&mut app, &d, "fake", "bypass").await; + let mut agent = Rpc::in_session(&d.socket, &parent, &token).await; + let child = + agent.ok("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))).await; + assert_eq!(current_mode(&child), "bypass", "{child}"); + let info = agent.ok("_acpmux/info", json!({"sessionId": child["sessionId"]})).await; + assert_eq!(info["modes"]["currentModeId"], "bypass", "{info}"); + // An asking mode needs no caller at all. + let mut cli = Rpc::plain(&d.socket).await; + let asking = + cli.ok("session/new", new_params(&d, json!({"harness": "fake", "mode": "strict"}))).await; + assert_eq!(current_mode(&asking), "strict", "{asking}"); +} + +#[tokio::test] +async fn auto_start_a_caller_that_is_no_session_needs_the_person_for_a_mode_that_does_not_ask() { + let d = Daemon::start("nosession"); + let mut app = Rpc::person(&d.socket, KEY).await; + let _ = parent_in(&mut app, &d, "fake", "bypass").await; + let mut cli = Rpc::plain(&d.socket).await; + let before = session_count(&mut cli).await; + let r = + cli.call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); + // Refused before anything starts. + assert_eq!(session_count(&mut cli).await, before); + // The person may. + let s = + app.ok("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))).await; + assert_eq!(current_mode(&s), "bypass", "{s}"); +} + +#[tokio::test] +async fn auto_start_a_session_never_starts_one_in_another_mode_that_does_not_ask() { + let d = Daemon::start("stronger"); + let mut app = Rpc::person(&d.socket, KEY).await; + let (bypass, bypass_token) = parent_in(&mut app, &d, "fake", "bypass").await; + let (asking, asking_token) = parent_in(&mut app, &d, "fake", "normal").await; + let mut agent = Rpc::in_session(&d.socket, &bypass, &bypass_token).await; + // Another mode that does not ask: refused. + let r = + agent.call("session/new", new_params(&d, json!({"harness": "fake", "mode": "yolo"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); + // Its own mode on another family (mode ids are per family): refused. + let r = agent + .call("session/new", new_params(&d, json!({"harness": "mirror", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + // A session that asks starts nothing that does not ask. + let mut asker = Rpc::in_session(&d.socket, &asking, &asking_token).await; + let r = asker + .call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + // A caller that left its no-ask mode inherits nothing any more. + app.ok("session/set_mode", json!({"sessionId": bypass, "modeId": "normal"})).await; + let r = agent + .call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_a_caller_session_named_in_params_or_with_a_wrong_token_counts_for_nothing() { + let d = Daemon::start("spoof"); + let mut app = Rpc::person(&d.socket, KEY).await; + let (parent, token) = parent_in(&mut app, &d, "fake", "bypass").await; + // A wrong token at initialize. + let mut wrong = Rpc::in_session(&d.socket, &parent, &"0".repeat(64)).await; + let r = wrong + .call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + // The parent named in the request's params, even with its real token: + // only the connection's own initialize binds a caller. + let mut cli = Rpc::plain(&d.socket).await; + let spoofed = new_params( + &d, + json!({"harness": "fake", "mode": "bypass", "parentSessionId": parent, + "callerSession": {"sessionId": parent, "token": token}}), + ); + let r = cli.call("session/new", spoofed).await; + assert_eq!(reason(&r), REASON, "{r}"); + // Another session's token under this session's id. + let (other, other_token) = parent_in(&mut app, &d, "fake", "normal").await; + assert_ne!(other, parent); + let mut swapped = Rpc::in_session(&d.socket, &parent, &other_token).await; + let r = swapped + .call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_a_preset_the_person_approved_starts_in_its_mode_from_the_cli() { + let d = Daemon::start("preset"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}, "approveMode": true}), + ) + .await; + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["mode"], "bypass", "{view}"); + assert_eq!(view["modeApproved"], true, "{view}"); + let s = cli.ok("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(current_mode(&s), "bypass", "{s}"); + // The approval covers the preset's own mode, never another one. + let r = + cli.call("session/new", new_params(&d, json!({"preset": "lane", "mode": "yolo"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_a_preset_changed_after_its_approval_loses_it() { + let d = Daemon::start("edited"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}, "approveMode": true}), + ) + .await; + // Any client may change the harness or model (they never set a mode + // that does not ask by themselves): the approval no longer covers it. + cli.ok("_acpmux/presets", json!({"name": "lane", "set": {"harness": "mirror"}})).await; + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["modeApproved"], false, "{view}"); + let r = cli.call("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); + // The approval is bound to the preset's contents: the exact contents + // the person approved are approved again. + cli.ok("_acpmux/presets", json!({"name": "lane", "set": {"harness": "fake"}})).await; + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["modeApproved"], true, "{view}"); + // A model change drops it too, and so does the person's own edit. + app.ok("_acpmux/presets", json!({"name": "lane", "set": {"model": "m2"}})).await; + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["modeApproved"], false, "{view}"); + let r = cli.call("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_only_the_person_approves_a_preset_mode_or_sets_one_that_does_not_ask() { + let d = Daemon::start("approve"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}}), + ) + .await; + for p in [ + json!({"name": "lane", "approveMode": true}), + json!({"name": "lane", "set": {"model": "m1"}, "approveMode": true}), + json!({"name": "other", "set": {"harness": "fake", "mode": "yolo"}}), + json!({"name": "lane", "set": {"mode": "yolo"}}), + ] { + let r = cli.call("_acpmux/presets", p.clone()).await; + assert_eq!(reason(&r), REASON, "{p}: {r}"); + } + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["modeApproved"], false, "{view}"); + assert_eq!(view["mode"], "bypass", "{view}"); + // A mode that asks, and withdrawing an approval, stay open. + cli.ok( + "_acpmux/presets", + json!({"name": "careful", "set": {"harness": "fake", "mode": "strict"}}), + ) + .await; + cli.ok("_acpmux/presets", json!({"name": "lane", "approveMode": false})).await; + // Unapproved, the preset's mode is refused from the CLI. + let r = cli.call("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_a_refused_start_mode_names_the_host_and_the_preset_to_approve() { + let d = Daemon::start("where"); + let mut app = Rpc::person(&d.socket, KEY).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}}), + ) + .await; + let mut cli = Rpc::plain(&d.socket).await; + let host = host_label(); + // Through a preset: the message names it. + let r = cli.call("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); + let text = message(&r); + assert!(text.contains(&format!("cmux NEXT on {host}")), "{text}"); + assert!(text.contains("preset lane"), "{text}"); + assert_eq!(text.matches(". ").count(), 0, "one sentence: {text}"); + assert_eq!(r["error"]["data"]["host"], host.as_str(), "{r}"); + assert_eq!(r["error"]["data"]["preset"], "lane", "{r}"); + assert_eq!(r["error"]["data"]["mode"], "bypass", "{r}"); + // Without one: the message says to approve a preset once. + let r = + cli.call("session/new", new_params(&d, json!({"harness": "fake", "mode": "yolo"}))).await; + let text = message(&r); + assert!(text.contains(&format!("cmux NEXT on {host}")), "{text}"); + assert!(text.contains("preset"), "{text}"); + // A mode change on a running session names the host too. + let s = cli.ok("session/new", new_params(&d, json!({"harness": "fake"}))).await; + let r = cli + .call("session/set_mode", json!({"sessionId": s["sessionId"], "modeId": "bypass"})) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + assert!(message(&r).contains(&format!("cmux NEXT on {host}")), "{r}"); +} From 8349d1b577a742ce423d08079f669e48d3e0bbdf Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 03:58:44 -0700 Subject: [PATCH 047/183] feat(acpmux): start a no-ask mode from a session in it or an approved preset Person-only gates for starting a session in a mode that does not ask (hub/person.rs, threat model updated for (a)-(d)): - inherit: each harness gets ACPMUX_SESSION_TOKEN, an HMAC of its session id under a per-run secret; the CLI presents it at its first initialize and the daemon binds the connection to that session. A session/new with _meta.acpmux.mode (acpmux new --mode) may start in the caller session's current mode on the same family, never another; params claims are ignored - approved preset: preset mode plus modeApproval, the sha256 of the preset contents; approveMode and a no-ask preset mode are person-only, and any later change of the contents drops the approval - a peer's permission_pending reaches watchers that did not attach the session; answers still go to the peer, which needs its person's proof - refusals name the host (cmux NEXT on ) and the preset, with host, mode and preset in error.data The presets handler moves to server/requests/presets.rs and the pending notification helpers to server/pending.rs to keep god files from growing. Co-Authored-By: Claude Opus 5.5 --- .../crates/acpmux/docs/acpmux-schema.json | 4 +- cmux-tui/crates/acpmux/src/agent.rs | 4 +- cmux-tui/crates/acpmux/src/cli/command.rs | 5 + .../acpmux/src/cli/orchestrate/presets.rs | 4 +- .../crates/acpmux/src/cli/run/standard.rs | 3 + cmux-tui/crates/acpmux/src/client.rs | 21 +- .../crates/acpmux/src/config/preset_args.rs | 46 +++ cmux-tui/crates/acpmux/src/hub/mod.rs | 2 + cmux-tui/crates/acpmux/src/hub/peers.rs | 19 +- cmux-tui/crates/acpmux/src/hub/person.rs | 75 ++++- cmux-tui/crates/acpmux/src/hub/start_mode.rs | 296 ++++++++++++++++++ cmux-tui/crates/acpmux/src/server/mod.rs | 59 ++-- cmux-tui/crates/acpmux/src/server/pending.rs | 46 +++ cmux-tui/crates/acpmux/src/server/requests.rs | 213 +------------ .../acpmux/src/server/requests/presets.rs | 211 +++++++++++++ .../crates/acpmux/tests/hub_integration.rs | 11 +- 16 files changed, 767 insertions(+), 252 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/hub/start_mode.rs create mode 100644 cmux-tui/crates/acpmux/src/server/pending.rs create mode 100644 cmux-tui/crates/acpmux/src/server/requests/presets.rs diff --git a/cmux-tui/crates/acpmux/docs/acpmux-schema.json b/cmux-tui/crates/acpmux/docs/acpmux-schema.json index 10a81ee86354..30ffd5edf0d8 100644 --- a/cmux-tui/crates/acpmux/docs/acpmux-schema.json +++ b/cmux-tui/crates/acpmux/docs/acpmux-schema.json @@ -31,8 +31,8 @@ "_acpmux/route/test": {"params": {"id": "string"}, "result": {"id": "string", "status": "ok|auth_needed|unreachable|rate_limited|unknown", "message": "string (HTTP status and the start of the body, or the connect error; never a secret)", "ms": "number"}, "note": "One GET to healthUrl, else the model list. On demand only."}, "_acpmux/route/default.set": {"params": {"scope": "\"global\"|\"family:NAME\"|\"workspace:ID\"", "routeId": "string|null (null clears)"}, "result": {"scope": "string", "routeId": "string|null", "bindings": "object"}, "note": "Precedence at spawn: chat, workspace, family, global; none: the harness profile env decides."}, "_acpmux/chat/route.set": {"params": {"sessionId": "string", "routeId": "string|null (null: back to the inherited route)", "when": "\"after-turn\"|\"now\"? (default after-turn; only matters during a turn)"}, "result": {"routeId": "string|null", "applied": "now|after-turn|after-cancel"}, "errors": "data.reason route.not_found, route.invalid (another family), route.unavailable (local CodeRouter down): the chat keeps its route", "note": "Records mux event route_changed {from, to, when}. Idle: the harness restarts now on the new route and resumes the same agent session. The binding survives a daemon restart."}, - "_acpmux/defaults": {"params": {"family": "string?", "set": "SessionDefaults? (null field clears it)", "clear": "boolean?"}, "result": "{families: {family: {kind, profile, error, profiles, model, effort, policy, prefer, env}}, defaults} or one entry", "note": "session/new takes _meta.acpmux.harness (a family or profile: the head of -m HEAD/MODEL), .preset, .model, .effort, .policy, .name. Explicit values win over the preset, which wins over profile then family defaults."}, - "_acpmux/presets": {"params": {"name": "string?", "set": "{harness (family or profile), model?, effort?, policy?, env?, args? (string[]: argv words appended to the harness command, no shell; an allowlist: on Claude Code --tools \"\", --strict-mcp-config, --no-session-persistence, else none), systemPrompt? (string: a Claude Code system prompt, written to acpmux's own presets//system.md and checked against its sha256 at every session start), description?}? (null field clears it); a remote-origin (WebSocket) connection never sets or uses args or systemPrompt", "clear": "boolean?"}, "result": "{presets: [{name, harness, profile, error, model, effort, policy, env, args, systemPromptSha256, description}]} or one preset"}, + "_acpmux/defaults": {"params": {"family": "string?", "set": "SessionDefaults? (null field clears it)", "clear": "boolean?"}, "result": "{families: {family: {kind, profile, error, profiles, model, effort, policy, prefer, env}}, defaults} or one entry", "note": "session/new takes _meta.acpmux.harness (a family or profile: the head of -m HEAD/MODEL), .preset, .model, .effort, .policy, .name, .mode (the mode it starts in; one that does not ask needs the person, an approved preset, or a caller session in that mode: hub/person.rs). Explicit values win over the preset, which wins over profile then family defaults."}, + "_acpmux/presets": {"params": {"name": "string?", "set": "{harness (family or profile), model?, effort?, policy?, env?, args? (string[]: argv words appended to the harness command, no shell; an allowlist: on Claude Code --tools \"\", --strict-mcp-config, --no-session-persistence, else none), systemPrompt? (string: a Claude Code system prompt, written to acpmux's own presets//system.md and checked against its sha256 at every session start), description?, mode? (the mode a session from the preset starts in; one that does not ask is set by the person only)}? (null field clears it); a remote-origin (WebSocket) connection never sets or uses args or systemPrompt", "clear": "boolean?", "approveMode": "boolean? (true: the person approves the preset's mode for its current contents, so any client may start the preset in it until the preset changes; person only. false: withdraws it)"}, "result": "{presets: [{name, harness, profile, error, model, effort, policy, env, args, systemPromptSha256, description, mode, modeApproved}]} or one preset"}, "_acpmux/models_note": "params {refresh?: boolean} forgets reported catalogs and probes every ACP harness again before answering", "_acpmux/models": {"params": {}, "result": {"harnesses": [{"harness": "string", "kind": "acp|claude-stdio", "isDefault": "boolean", "models": [{"id": "string", "name": "string", "declared": "boolean? (from the profile)", "shortName": "string?", "family": "string?", "efforts": "string[]?", "defaultEffort": "string?", "fast": "boolean?", "contextWindow": "number? (these from a profile file or the catalog)", "curated": "boolean? (true: from the curated catalog, with its ref, shortName, provider, aliases, status and its models[ref] metadata: contextWindow, maxOutput, cost, input, reasoning, toolCall...; false: the harness reported it and the catalog does not list it)"}], "probeError": "string? (the last model probe failed: the harness may not start)", "catalog": "{id, name, brand, families, modelSource, docsUrl?, defaultModel?}? (the harness's curated catalog entry)"}], "catalog": "CatalogSummary"}}, "catalog.get": {"params": {}, "result": "CatalogSummary & {catalog: ModelCatalog (schemaVersion 1: {schemaVersion, generatedAt, source, harnesses, models, providers}; web/services/model-catalog/types.ts)}"}, diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index 6345b0891a4b..f9daa9e476a6 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -242,8 +242,8 @@ pub(crate) fn harness_command( .env("ACPMUX_SESSION_ID", id) .env("ACPMUX_SESSION_NAME", sname) .env("ACPMUX_SOCKET", crate::config::socket_path()) - // The caller the cmux CLI names to the app: its tabs open in the - // column right of this session's chat (beside_caller). + // Its caller token (hub/person.rs (a)); the cmux CLI's caller (beside_caller). + .env(crate::hub::person::CALLER_TOKEN_ENV, crate::hub::person::caller_token(id)) .env(crate::agent_tools::AGENT_SESSION_ENV, id); } cmd.args(args) diff --git a/cmux-tui/crates/acpmux/src/cli/command.rs b/cmux-tui/crates/acpmux/src/cli/command.rs index 163baa18c0dd..4dc7d0ee34bf 100644 --- a/cmux-tui/crates/acpmux/src/cli/command.rs +++ b/cmux-tui/crates/acpmux/src/cli/command.rs @@ -716,6 +716,11 @@ pub struct NewArgs { /// Permission policy: ask, approve-reads, approve-edits, approve-all, deny-all. #[arg(long)] pub policy: Option, + /// The harness mode to start in (e.g. `auto`). One that does not ask needs the + /// person in cmux NEXT, a preset the person approved, or this command running + /// inside a session that is in that mode. + #[arg(long)] + pub mode: Option, /// HARNESS[/MODEL]: a family or profile name (`claude`, `codex`, `omp`), optionally with a model /// (`claude/opus`, `codex/gpt-5.5`, `opencode/zai/glm-5.1`). Omitted: the default harness and its defaults. #[arg(long, short)] diff --git a/cmux-tui/crates/acpmux/src/cli/orchestrate/presets.rs b/cmux-tui/crates/acpmux/src/cli/orchestrate/presets.rs index 864798229b5d..111528357810 100644 --- a/cmux-tui/crates/acpmux/src/cli/orchestrate/presets.rs +++ b/cmux-tui/crates/acpmux/src/cli/orchestrate/presets.rs @@ -149,7 +149,7 @@ pub(crate) async fn preset( .split_once('=') .ok_or_else(|| AppError::usage(format!("expected key=value, got {pair:?}")))?; match k { - "harness" | "model" | "effort" | "policy" | "description" => { + "harness" | "model" | "effort" | "policy" | "description" | "mode" => { set.insert(k.into(), if v.is_empty() { Value::Null } else { json!(v) }); } _ if k.starts_with("env.") => { @@ -170,7 +170,7 @@ pub(crate) async fn preset( set.insert("args".into(), args); } _ => return Err(AppError::usage(format!( - "unknown key {k:?}; use harness, model, effort, policy, description, env.KEY, args" + "unknown key {k:?}; use harness, model, effort, policy, description, mode, env.KEY, args" )) .into()), } diff --git a/cmux-tui/crates/acpmux/src/cli/run/standard.rs b/cmux-tui/crates/acpmux/src/cli/run/standard.rs index 061882ce9fea..d5b95ffc0f35 100644 --- a/cmux-tui/crates/acpmux/src/cli/run/standard.rs +++ b/cmux-tui/crates/acpmux/src/cli/run/standard.rs @@ -307,6 +307,9 @@ pub(super) async fn run(cmd: Command, json_out: bool, suppress_reads: bool) -> R if let Some(p) = &args.policy { meta["policy"] = json!(p); } + if let Some(m) = &args.mode { + meta["mode"] = json!(m); + } if let Some(e) = &args.effort { meta["effort"] = json!(e); } diff --git a/cmux-tui/crates/acpmux/src/client.rs b/cmux-tui/crates/acpmux/src/client.rs index f8f7afc44190..9d70b707de2e 100644 --- a/cmux-tui/crates/acpmux/src/client.rs +++ b/cmux-tui/crates/acpmux/src/client.rs @@ -91,12 +91,21 @@ impl Client { notif_tx, closed, }); - let init = client - .request( - method::INITIALIZE, - json!({"protocolVersion": 1, "clientCapabilities": {}, "clientInfo": {"name": "acpmux-cli", "version": crate::hub::VERSION}}), - ) - .await?; + let mut hello = json!({"protocolVersion": 1, "clientCapabilities": {}, "clientInfo": {"name": "acpmux-cli", "version": crate::hub::VERSION}}); + // Inside an acpmux session: say which, with the caller token the + // daemon put in that session's env (`hub/person.rs` (a)). + let caller = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + if let (Some(id), Some(token)) = + (caller("ACPMUX_SESSION_ID"), caller(crate::hub::person::CALLER_TOKEN_ENV)) + { + let mut meta = serde_json::Map::new(); + meta.insert( + crate::hub::person::CALLER_FIELD.to_owned(), + json!({"sessionId": id, "token": token}), + ); + hello["_meta"] = json!({"acpmux": meta}); + } + let init = client.request(method::INITIALIZE, hello).await?; *client.daemon_build.lock().unwrap() = init.pointer("/_meta/acpmux/build").and_then(Value::as_str).map(str::to_owned); Ok(client) diff --git a/cmux-tui/crates/acpmux/src/config/preset_args.rs b/cmux-tui/crates/acpmux/src/config/preset_args.rs index 547a386ef73d..8e28c069d509 100644 --- a/cmux-tui/crates/acpmux/src/config/preset_args.rs +++ b/cmux-tui/crates/acpmux/src/config/preset_args.rs @@ -57,9 +57,55 @@ pub struct Preset { pub system_prompt_sha256: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// The mode a session started from this preset starts in. One that does + /// not ask is set by the person only, and used without the person only + /// while `mode_approval` holds (`hub/person.rs`, (b)). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// The person's approval of this preset's mode: the + /// [`Preset::approval_digest`] of the contents the person approved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode_approval: Option, } impl Preset { + /// The sha256 (lowercase hex) of everything this preset starts a + /// session with: every field but `description` and `mode_approval`. + /// None only if it cannot be serialized (then nothing is approved). + pub fn approval_digest(&self) -> Option { + let bound = Self { description: None, mode_approval: None, ..self.clone() }; + // A struct of strings, lists and a BTreeMap: its JSON is stable. + serde_json::to_vec(&bound).ok().map(|bytes| crate::sha256::sha256_hex(&bytes)) + } + + /// `approveMode` of a preset write (`hub/person.rs` (b); the person's + /// own for `true`): bind the approval to the contents as they are now, + /// or withdraw it. A later change of the contents drops it. + pub fn apply_mode_approval(&mut self, approve: Option) -> Result<(), String> { + match approve { + Some(true) if self.mode.is_none() => { + Err("this preset has no mode to approve: set mode first".into()) + } + Some(true) => { + self.mode_approval = self.approval_digest(); + Ok(()) + } + Some(false) => { + self.mode_approval = None; + Ok(()) + } + None => Ok(()), + } + } + + /// Whether the person approved this preset's mode for its current contents. + pub fn mode_approved(&self) -> bool { + match (&self.mode, &self.mode_approval, self.approval_digest()) { + (Some(_), Some(approved), Some(now)) => cmux_local_auth::tokens_match(approved, &now), + _ => false, + } + } + /// Whether the preset changes the harness command line (args or a system /// prompt): never for a remote-origin session. pub fn shapes_command(&self) -> bool { diff --git a/cmux-tui/crates/acpmux/src/hub/mod.rs b/cmux-tui/crates/acpmux/src/hub/mod.rs index 55fc91c9e828..c77226eae419 100644 --- a/cmux-tui/crates/acpmux/src/hub/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/mod.rs @@ -53,12 +53,14 @@ pub use lifecycle::{ pub use paging::{EventFilter, EventPage}; pub use spawn::expand_env_value; mod peers; +pub(crate) use peers::PEER_PERMISSION_PENDING; mod permission_groups; mod permissions; pub mod person; mod questions; mod remote_floor; mod remote_sandbox; +mod start_mode; pub use permission_groups::PERMISSION_GROUP_OPERATIONS; pub mod rules; mod transfer; diff --git a/cmux-tui/crates/acpmux/src/hub/peers.rs b/cmux-tui/crates/acpmux/src/hub/peers.rs index 26b612436be1..4af6ecfffab5 100644 --- a/cmux-tui/crates/acpmux/src/hub/peers.rs +++ b/cmux-tui/crates/acpmux/src/hub/peers.rs @@ -6,6 +6,10 @@ use super::*; /// ssh peer may take its ssh connect, tunnel and WebSocket timeouts in turn. const PEER_SETTLE_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); +/// The record kind of a peer's `_acpmux/permission_pending`, relayed to +/// this daemon's watchers (`server/mod.rs` `deliver`); never stored. +pub(crate) const PEER_PERMISSION_PENDING: &str = "peer_permission_pending"; + impl Hub { // ------------------------------------------------------------- peers @@ -377,7 +381,20 @@ impl Hub { msg: params.get("msg").cloned().unwrap_or(Value::Null), host_seq: None, }, - // permission_pending is derived from the permission_request event locally. + // A prompt the person must answer, to watchers of this + // daemon that did not attach the session (attached + // clients derive it from the permission_request event): + // the peer's decision nonce and run come with it, so the + // app that holds the peer's key can answer + // (`hub/person.rs` (c)). + method::MUX_PERMISSION_PENDING => EventRecord { + seq: params.get("seq").and_then(Value::as_u64).unwrap_or(0), + at: now_ms(), + dir: "peer".into(), + kind: PEER_PERMISSION_PENDING.into(), + msg: params.clone(), + host_seq: None, + }, _ => return, }; let _ = self.events.send(HubEvent { diff --git a/cmux-tui/crates/acpmux/src/hub/person.rs b/cmux-tui/crates/acpmux/src/hub/person.rs index eac8af350fc7..c8cdaef7ad52 100644 --- a/cmux-tui/crates/acpmux/src/hub/person.rs +++ b/cmux-tui/crates/acpmux/src/hub/person.rs @@ -82,6 +82,47 @@ //! table does not list; a spawn env). Such a request gets //! [`REASON`] and changes nothing: a prompt stays pending. //! +//! Starting in a mode that does not ask (a `session/new` with +//! `_meta.acpmux.mode`, or a preset's `mode`), without the person, needs +//! one of these; each grants a mode the person already approved, never a +//! wider one, and nothing else: +//! - (a) Inherit: the caller is an acpmux session whose current mode is +//! that exact mode on that same harness family (mode ids are per family). +//! The daemon, not the request, says which session calls: each harness it +//! starts gets `ACPMUX_SESSION_TOKEN` = lowercase hex HMAC-SHA256(run +//! secret, "acpmux-caller-session-v1" 0 session id) ([`caller_token`]), a +//! secret made at each daemon start and kept in memory only. A unix +//! socket connection whose FIRST request is an `initialize` with +//! `_meta.acpmux.callerSession {sessionId, token}` that matches (constant +//! time) is that session for its whole life ([`take_caller`]); the field +//! is stripped from every request and never read anywhere else, so a +//! `parentSessionId` or a `callerSession` in any other request counts for +//! nothing. The check reads the caller's mode at the time of the request: +//! a caller the person moved back to asking inherits nothing. A peer or +//! Web connection never has a caller. +//! - (b) A preset the person approved: `_acpmux/presets {name, approveMode: +//! true}` from the person records `modeApproval`, the sha256 of the +//! preset's contents (everything but its description and the approval +//! itself, `Preset::approval_digest`). The approval counts only while the +//! preset still hashes to it and only for that preset's own mode, with no +//! other harness and no adopted conversation in the request. Bound to the +//! contents, not cleared on a write, so every way the preset can change +//! (a `_acpmux/presets` write from any client, a hand edit of config.json +//! and a reload, a future write path) drops it without having to know +//! about it; the exact contents the person approved are approved again. +//! Without the person, `approveMode: true` and a preset `mode` that does +//! not ask are refused; `approveMode: false` and an asking mode narrow and +//! stay open. +//! - (d) The refusal ([`mode_person_required`]) is one sentence that names +//! where to approve: cmux NEXT on this host ([`host_label`]), or the +//! preset to approve there once; `error.data` carries `host`, `mode` and +//! `preset` so an app can show its own (localized) text. +//! - (c) A pending prompt reaches every app that sees the session: a peer's +//! `_acpmux/permission_pending` reaches the watchers of this daemon that +//! did not attach the session (`hub/peers.rs`), with the peer's +//! `decisionNonce` and `personInstance`; an answer still goes to the peer, +//! which counts an allow only with its own person's proof (above). +//! //! Also without the person: no rules change under a session policy that //! does not ask (a rule is then what keeps a tool asking), no removal of a //! stored family-default or preset policy, no write of the `handoffKey` @@ -111,6 +152,18 @@ //! default policy, a new session gets), a preset's `args`, //! `acp.trust.set`, `_acpmux/harness_enable` and `_acpmux/import` stay //! open to the unix socket. +//! - A caller token lives in its harness's environment, which any process +//! of the same user may read (`/proc//environ` on Linux, `ps -E` or +//! `KERN_PROCARGS2` on macOS). A process that reads it starts sessions in +//! that session's current mode only, which that session's own agent can +//! already do without asking (a no-ask mode runs any command). The +//! process still has to run somewhere: an agent of a session that asks +//! needs an allowed command or read to get there. Binding the token to the +//! connecting process's ancestry (peer pid) is a later hardening. +//! - A token dies with its daemon run: a harness that outlives a restart +//! (an agent host) inherits nothing until the next spawn. +//! - `session/new` with a `policy` other than ask or deny-all is open to the +//! unix socket (it predates this check); a mode is not. //! - DEV only: a same-uid process that starts the unsigned daemon before the //! app does gives it its own spawn key; the app then hands that daemon off //! (when its agents run under agent hosts), but the process held the @@ -118,6 +171,11 @@ use super::*; +pub use super::start_mode::{ + CALLER_FIELD, CALLER_TOKEN_ENV, START_MODE_FIELD, StartMode, caller_proves, caller_token, + host_label, mode_person_required, take_caller, +}; + /// The old place of the key in `initialize` (`_meta.acpmux`): stripped, never read. pub const KEY_FIELD: &str = "personKey"; /// The challenge in a local connection's `initialize` result (`_meta.acpmux`). @@ -525,10 +583,17 @@ impl Hub { /// "remote connection must be authed, and should be able to pretty much /// use yolo mode"; `Hub::any_mode_for_web`). The unix socket (the /// CLI, scripts, agents) still needs the person for a mode that does not ask. + /// `caller`: the acpmux session the connection proved it runs in + /// (module docs, (a)), never a value from the request. `starts_modes`: + /// a `session/new` on this connection starts in its mode (the unix + /// socket and the local app; a Web or peer one is settled to an asking + /// mode instead, `server/remote_guard.rs`). pub async fn person_check( &self, person: bool, remote_authenticated: bool, + caller: Option<&str>, + starts_modes: bool, m: &str, params: &Value, ) -> Result { @@ -542,7 +607,7 @@ impl Hub { // handler forwards it and never handles it here. return Ok(PersonVerdict::PeerOnly); } - self.person_check_local(remote_authenticated, m, params) + self.person_check_local(remote_authenticated, caller, starts_modes, m, params) .await .map(|()| PersonVerdict::Handle) } @@ -551,10 +616,13 @@ impl Hub { async fn person_check_local( &self, remote_authenticated: bool, + caller: Option<&str>, + starts_modes: bool, m: &str, params: &Value, ) -> Result<(), RpcError> { match m { + method::SESSION_NEW if starts_modes => self.session_new_check(caller, params).await, method::MUX_PERMISSION_RESPOND => { let option = params.get("optionId").filter(|v| !v.is_null()); let proved = option.is_some_and(|o| self.person_decided(params, o)); @@ -601,6 +669,9 @@ impl Hub { if !asking_policy_value(set.and_then(|s| s.get("policy"))) { return Err(person_required("a permission policy other than ask or deny-all")); } + if m == method::MUX_PRESETS { + self.preset_mode_check(params).await?; + } // Removing a stored policy (a clear, or `policy: null`) lets // the next layer's policy apply, which may not ask. let removes = params.get("clear").and_then(Value::as_bool) == Some(true) @@ -670,6 +741,8 @@ impl Hub { } if self.web_modes().config_value_asks(&family, id, value) { Ok(()) + } else if let (None | Some("mode"), Some(mode)) = (id, value) { + Err(mode_person_required(mode, None, false)) } else { Err(person_required("a mode that does not ask")) } diff --git a/cmux-tui/crates/acpmux/src/hub/start_mode.rs b/cmux-tui/crates/acpmux/src/hub/start_mode.rs new file mode 100644 index 000000000000..56e95d01fb26 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/hub/start_mode.rs @@ -0,0 +1,296 @@ +//! Starting a session in a mode that does not ask without the person: +//! the caller token (inherit), approved presets and the refusal that names +//! where to approve. The rules and the threat model are in `hub/person.rs` +//! ("Starting in a mode that does not ask"), (a), (b) and (d). + +use super::person::{REASON, person_required, valid_key}; +use super::*; + +/// The env var that carries a session's caller token to its harness (and +/// so to the acpmux CLI its agent runs). +pub const CALLER_TOKEN_ENV: &str = "ACPMUX_SESSION_TOKEN"; +/// `_meta.acpmux` field of a first `initialize`: `{sessionId, token}`. +pub const CALLER_FIELD: &str = "callerSession"; +/// `_meta.acpmux` field of `session/new`: the mode the session starts in. +pub const START_MODE_FIELD: &str = "mode"; + +/// This daemon run's caller secret: 32 random bytes, in memory only. +fn caller_secret() -> &'static [u8; 32] { + static SECRET: std::sync::OnceLock<[u8; 32]> = std::sync::OnceLock::new(); + SECRET.get_or_init(|| { + let mut bytes = [0u8; 32]; + // No randomness means no safe token: the daemon cannot serve. + getrandom::fill(&mut bytes).expect("the OS random generator for the caller secret"); // crash-allow: no randomness, no safe token + bytes + }) +} + +/// The caller token of session `session_id` on this daemon run (`hub/person.rs` +/// (a)): lowercase hex HMAC-SHA256(run secret, +/// "acpmux-caller-session-v1" 0 session id). +pub fn caller_token(session_id: &str) -> String { + let mut message = b"acpmux-caller-session-v1".to_vec(); + message.push(0); + message.extend_from_slice(session_id.as_bytes()); + cmux_local_auth::frontend_proof::hex(&cmux_local_auth::frontend_proof::hmac_sha256( + caller_secret(), + &message, + )) +} + +/// Whether `token` is the caller token of `session_id` (constant time). +pub fn caller_proves(session_id: &str, token: &str) -> bool { + valid_key(token) && cmux_local_auth::tokens_match(token, &caller_token(session_id)) +} + +/// Remove `_meta.acpmux.callerSession` from any request's params and return +/// its `(sessionId, token)`. Only a first `initialize` is ever read +/// (`server/mod.rs`); everywhere else it is dropped unread. +pub fn take_caller(params: &mut Value) -> Option<(String, String)> { + let taken = params + .pointer_mut("/_meta/acpmux") + .and_then(Value::as_object_mut) + .and_then(|m| m.remove(CALLER_FIELD))?; + let field = |k: &str| taken.get(k).and_then(Value::as_str).map(str::to_owned); + Some((field("sessionId")?, field("token")?)) +} + +/// The name of this host the refusal names (module docs, (d)): the host +/// name without a trailing `.local`, as the person sees it in cmux NEXT. +pub fn host_label() -> &'static str { + static HOST: std::sync::OnceLock = std::sync::OnceLock::new(); + HOST.get_or_init(|| { + let name = read_host_name().unwrap_or_default(); + let name = name.trim(); + let name = name.strip_suffix(".local").unwrap_or(name); + if name.is_empty() { "this machine".to_owned() } else { name.to_owned() } + }) +} + +#[cfg(unix)] +fn read_host_name() -> Option { + let mut buf = [0u8; 256]; + // SAFETY: a writable buffer of the length passed; gethostname writes at + // most that many bytes. + let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; + if rc != 0 { + return None; + } + let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len()); + Some(String::from_utf8_lossy(&buf[..end]).into_owned()) +} + +#[cfg(not(unix))] +fn read_host_name() -> Option { + std::env::var("COMPUTERNAME").ok() +} + +/// The refusal of a mode that does not ask (module docs, (d)): one +/// sentence that names where the person approves it. `start`: a +/// `session/new`; else a mode change of a running session. +pub fn mode_person_required(mode: &str, preset: Option<&str>, start: bool) -> RpcError { + let host = host_label(); + let message = match (start, preset) { + (true, Some(p)) => format!( + "mode {mode} does not ask before it acts, so only the person starts a session in it: approve preset {p} once in cmux NEXT on {host}, or start the session there" + ), + (true, None) => format!( + "mode {mode} does not ask before it acts, so only the person starts a session in it: start it in cmux NEXT on {host}, or approve a preset with this mode there once and use acpmux new --preset NAME" + ), + (false, _) => format!( + "mode {mode} does not ask before it acts, so only the person sets it: set it in cmux NEXT on {host}, or start the session from a preset approved there once" + ), + }; + RpcError::new(-32000, message) + .with_data(json!({"reason": REASON, "host": host, "mode": mode, "preset": preset})) +} + +/// What a `session/new` asks to start in (module docs, (a) and (b)). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StartMode { + /// `_meta.acpmux.mode`, else the preset's mode. + pub mode: String, + /// The preset the request names, for the refusal. + pub preset: Option, + /// The person approved the named preset's current contents, its mode is + /// this one, and the request keeps its harness and adopts nothing. + pub preset_approved: bool, +} + +impl StartMode { + /// From `session/new` params against `cfg`; None when no mode is asked. + pub fn of(params: &Value, cfg: &crate::config::Config) -> Option { + let meta = params.pointer("/_meta/acpmux"); + let pick = |key: &str| -> Option { + meta.and_then(|m| m.get(key)) + .and_then(Value::as_str) + .or_else(|| params.get(key).and_then(Value::as_str)) + .map(str::to_owned) + }; + let requested = + meta.and_then(|m| m.get(START_MODE_FIELD)).and_then(Value::as_str).map(str::to_owned); + let preset_name = pick("preset"); + let preset = preset_name.as_deref().and_then(|n| cfg.presets.get(n)); + let mode = requested.or_else(|| preset.and_then(|p| p.mode.clone()))?; + let adopts = meta.and_then(|m| m.get("adopt")).is_some_and(|v| !v.is_null()); + let harness = pick("harness"); + let preset_approved = preset.is_some_and(|p| { + p.mode.as_deref() == Some(mode.as_str()) + && p.mode_approved() + && harness.as_deref().is_none_or(|h| h == p.harness) + && !adopts + }); + Some(Self { mode, preset: preset_name, preset_approved }) + } +} + +impl Hub { + /// The session a first unix socket `initialize` proved it runs in + /// (`hub/person.rs` (a)): `(sessionId, token)` from [`take_caller`], + /// when the token is that session's on this run and the session is here. + pub fn proven_caller(&self, (id, token): (String, String)) -> Option { + if caller_proves(&id, &token) && self.resolve(&id).is_ok_and(|s| s.id == id) { + Some(id) + } else { + None + } + } + + /// A `session/new` without the person (module docs, (a), (b)): a mode + /// that does not ask only through an approved preset or from a caller + /// session in that mode. Checked against the harness the request + /// resolves to now; the handler checks the started session again. + pub(super) async fn session_new_check( + &self, + caller: Option<&str>, + params: &Value, + ) -> Result<(), RpcError> { + let meta = params.pointer("/_meta/acpmux"); + let pick = |key: &str| { + meta.and_then(|m| m.get(key)) + .and_then(Value::as_str) + .or_else(|| params.get(key).and_then(Value::as_str)) + .map(str::to_owned) + }; + // A session on a peer: that peer checks it. + if pick("peer").is_some_and(|p| !p.is_empty()) { + return Ok(()); + } + let (family, start) = { + let cfg = self.config.read().await; + let Some(start) = StartMode::of(params, &cfg) else { return Ok(()) }; + let adopted = + crate::adopt::AdoptRequest::from_meta(meta).ok().flatten().and_then(|a| a.harness); + let harness = pick("harness").or(adopted); + let cwd = params.get("cwd").and_then(Value::as_str).map(std::path::Path::new); + match self.resolve_new( + &cfg, + harness, + &pick("preset"), + pick("model").as_deref(), + false, + cwd, + ) { + Ok(r) => (crate::config::derive_family(&r.agent, &r.profile), start), + // Not startable: the handler answers why. + Err(_) => return Ok(()), + } + }; + self.start_mode_check(caller, &family, &start) + } + + /// Whether a client that is not the person may start a session of + /// `family` in `start` (module docs, (a), (b)); the refusal names where + /// to approve it (d). + pub(crate) fn start_mode_check( + &self, + caller: Option<&str>, + family: &str, + start: &StartMode, + ) -> Result<(), RpcError> { + if self.web_modes().config_value_asks(family, None, Some(start.mode.as_str())) { + return Ok(()); + } + if start.preset_approved { + return Ok(()); + } + // The caller's mode now, on the session the daemon bound it to. + let inherits = + caller.and_then(|id| self.resolve(id).ok().filter(|s| s.id == id)).is_some_and(|s| { + let m = s.meta(); + crate::web_modes::family_of(&m) == family + && crate::web_modes::mode_of(&m).as_deref() == Some(start.mode.as_str()) + }); + if inherits { + Ok(()) + } else { + Err(mode_person_required(&start.mode, start.preset.as_deref(), true)) + } + } + + /// A `_acpmux/presets` write without the person (module docs, (b)): no + /// approval, and no `mode` that does not ask for the preset's family. + pub(super) async fn preset_mode_check(&self, params: &Value) -> Result<(), RpcError> { + if params.get("approveMode").and_then(Value::as_bool) == Some(true) { + return Err(person_required("approving a preset's mode")); + } + let Some(mode) = params.pointer("/set/mode").filter(|v| !v.is_null()) else { + return Ok(()); + }; + let refused = || person_required("a preset mode that does not ask"); + let mode = mode.as_str().ok_or_else(refused)?; + let family = { + let cfg = self.config.read().await; + let name = params.get("name").and_then(Value::as_str); + let harness = params + .pointer("/set/harness") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| name.and_then(|n| cfg.presets.get(n)).map(|p| p.harness.clone())); + harness + .and_then(|h| cfg.resolve_harness(&h).ok()) + .map(|profile| crate::config::derive_family(&profile, &cfg.harnesses[&profile])) + }; + match family { + Some(f) if self.web_modes().config_value_asks(&f, None, Some(mode)) => Ok(()), + _ => Err(refused()), + } + } + + /// A new session's start mode (`hub/person.rs` (a), (b)): + /// `_meta.acpmux.mode`, else its preset's. The request check ran + /// against the harness the request resolved to; this checks again + /// against the session as it started (`person`: the connection is the + /// person), then sets the mode. A refused or failed mode ends a session + /// this request created (never one an adopt found already running). + pub(crate) async fn start_in_mode( + self: &Arc, + person: bool, + caller: Option<&str>, + params: &Value, + s: &Arc, + adopting: bool, + ) -> Result<(), RpcError> { + let start = { + let cfg = self.config.read().await; + StartMode::of(params, &cfg) + }; + let Some(start) = start else { return Ok(()) }; + let checked = if person { + Ok(()) + } else { + self.start_mode_check(caller, &crate::web_modes::family_of(&s.meta()), &start) + }; + let applied = match checked { + Ok(()) => self.set_mode(s, &start.mode).await.map(|_| ()), + Err(e) => Err(e), + }; + if let Err(e) = applied { + if !adopting { + let _ = self.kill(s, true).await; + } + return Err(e); + } + Ok(()) + } +} diff --git a/cmux-tui/crates/acpmux/src/server/mod.rs b/cmux-tui/crates/acpmux/src/server/mod.rs index 9bba878ba520..e9c10048e0dd 100644 --- a/cmux-tui/crates/acpmux/src/server/mod.rs +++ b/cmux-tui/crates/acpmux/src/server/mod.rs @@ -5,7 +5,6 @@ use crate::config::PermissionPolicy; use crate::hub::{EventFilter, Hub, HubEvent, VERSION}; use crate::rpc::{Message, RpcError, method}; -use crate::store::EventRecord; use anyhow::{Context, Result}; use cmux_local_auth::{ListenerPolicy, Refusal}; use futures_util::{SinkExt, StreamExt}; @@ -59,6 +58,8 @@ pub struct Conn { person: AtomicBool, /// `_acpmux/person_holds` calls on this connection (`hub/person.rs`). pub(super) holds_calls: std::sync::atomic::AtomicU32, + /// The acpmux session it runs in, proven at its first `initialize` (`hub/person.rs` (a)). + caller: std::sync::OnceLock, } impl Conn { @@ -67,6 +68,11 @@ impl Conn { self.person.load(Ordering::SeqCst) } + /// The acpmux session this connection proved it runs in (`hub/person.rs` (a)). + pub(super) fn caller(&self) -> Option<&str> { + self.caller.get().map(String::as_str) + } + /// Whether this connection gets the person challenge: the unix socket /// or the proven local app. pub(super) fn challengeable(&self) -> bool { @@ -80,10 +86,16 @@ impl Conn { /// the proof matches its own nonce and id. Nothing else ever does. fn bind_person(&self, hub: &Hub, m: &str, params: &mut Option) { let _ = params.as_mut().and_then(crate::hub::person::take_key); + // Stripped from every request; read from a first unix `initialize` only (person.rs (a)). + let caller = params.as_mut().and_then(crate::hub::person::take_caller); let n = self.requests.fetch_add(1, Ordering::SeqCst) + 1; if n == 1 && m == method::INITIALIZE && self.challengeable() { self.challenged.store(true, Ordering::SeqCst); } + let first_local = n == 1 && m == method::INITIALIZE && self.origin == Origin::Local; + if let Some(id) = caller.filter(|_| first_local).and_then(|c| hub.proven_caller(c)) { + let _ = self.caller.set(id); + } if n == 2 && m == method::MUX_PERSON_PROVE && self.challenged.load(Ordering::SeqCst) { let proof = params.as_ref().and_then(|p| p.get("proof")).and_then(Value::as_str); let transport = match self.origin { @@ -685,6 +697,7 @@ async fn serve_connection_from( nonce: crate::hub::person::new_nonce(), person: AtomicBool::new(false), holds_calls: std::sync::atomic::AtomicU32::new(0), + caller: std::sync::OnceLock::new(), }); tracing::debug!(conn = %conn.id, "client connected"); @@ -854,6 +867,18 @@ fn deliver(hub: &Hub, conn: &Conn, ev: HubEvent) { } if watching { if let Some(remote) = &ev.remote { + // A peer's prompt the person must answer, for a watcher that did + // not attach the session (`hub/person.rs` (c)): the peer's own + // notification, with which peer asks. + if rec.kind == crate::hub::PEER_PERMISSION_PENDING { + if !attached { + conn.send(&Message::notification( + method::MUX_PERMISSION_PENDING, + peer_permission_pending(rec, &remote.peer), + )); + } + return; + } // Peers already filter to the interesting kinds and send a summary. if rec.dir == "peer" { let sid = remote @@ -920,35 +945,6 @@ fn deliver(hub: &Hub, conn: &Conn, ev: HubEvent) { } } -/// A record that asks a client to answer a prompt: a new one, or one the -/// next daemon adopted with a new decision nonce (`hub/hosts.rs`). -fn is_permission_ask(kind: &str) -> bool { - matches!(kind, "permission_request" | "permission_reissued") -} - -/// The `_acpmux/permission_pending` notification. The record's -/// `decisionNonce` and `personInstance` (the daemon run that asks, also for a -/// peer's session) are what a relayed allow's decision proof covers -/// (`hub/person.rs`, cx-0q7o). -fn permission_pending(session_id: &str, rec: &EventRecord, via: &str) -> Value { - let mut p = rec.msg.clone(); - p["sessionId"] = Value::String(session_id.to_owned()); - p["seq"] = json!(rec.seq); - p["via"] = Value::String(via.to_owned()); - p -} - -pub fn event_value(session_id: &str, rec: &EventRecord) -> Value { - json!({ - "sessionId": session_id, - "seq": rec.seq, - "at": rec.at, - "dir": rec.dir, - "kind": rec.kind, - "msg": rec.msg, - }) -} - fn str_param<'a>(params: &'a Value, key: &str) -> Option<&'a str> { params.get(key).and_then(Value::as_str) } @@ -1002,6 +998,9 @@ mod harness_enable; pub mod local_app; pub mod peer_auth; mod peer_forward; +mod pending; +pub use pending::event_value; +use pending::{is_permission_ask, peer_permission_pending, permission_pending}; mod redact; pub(crate) mod remote_guard; mod requests; diff --git a/cmux-tui/crates/acpmux/src/server/pending.rs b/cmux-tui/crates/acpmux/src/server/pending.rs new file mode 100644 index 000000000000..c99804082ded --- /dev/null +++ b/cmux-tui/crates/acpmux/src/server/pending.rs @@ -0,0 +1,46 @@ +//! The `_acpmux/permission_pending` notifications a connection gets: a +//! prompt of this daemon's session, or a peer's (`hub/person.rs` (c)). + +use super::*; +use crate::store::EventRecord; + +/// A record that asks a client to answer a prompt: a new one, or one the +/// next daemon adopted with a new decision nonce (`hub/hosts.rs`). +pub(super) fn is_permission_ask(kind: &str) -> bool { + matches!(kind, "permission_request" | "permission_reissued") +} + +/// The `_acpmux/permission_pending` notification. The record's +/// `decisionNonce` and `personInstance` (the daemon run that asks, also for a +/// peer's session) are what a relayed allow's decision proof covers +/// (`hub/person.rs`, cx-0q7o). +pub(super) fn permission_pending(session_id: &str, rec: &EventRecord, via: &str) -> Value { + let mut p = rec.msg.clone(); + p["sessionId"] = Value::String(session_id.to_owned()); + p["seq"] = json!(rec.seq); + p["via"] = Value::String(via.to_owned()); + p +} + +/// A peer's `_acpmux/permission_pending` for a watcher of this daemon that +/// did not attach the session (`hub/person.rs` (c)): the peer's own +/// notification (its `decisionNonce` and `personInstance` included), with +/// which peer asks. +pub(super) fn peer_permission_pending(rec: &EventRecord, peer: &str) -> Value { + let mut p = rec.msg.clone(); + p["peer"] = Value::String(peer.to_owned()); + p["via"] = Value::String("peer".into()); + p +} + +/// A record as `_acpmux/event` sends it. +pub fn event_value(session_id: &str, rec: &EventRecord) -> Value { + json!({ + "sessionId": session_id, + "seq": rec.seq, + "at": rec.at, + "dir": rec.dir, + "kind": rec.kind, + "msg": rec.msg, + }) +} diff --git a/cmux-tui/crates/acpmux/src/server/requests.rs b/cmux-tui/crates/acpmux/src/server/requests.rs index 615dbf2e4182..859a02a9ff7a 100644 --- a/cmux-tui/crates/acpmux/src/server/requests.rs +++ b/cmux-tui/crates/acpmux/src/server/requests.rs @@ -3,6 +3,7 @@ use super::*; mod composer; +mod presets; /// Subscribe the connection to a session and, when that is new, count it /// as attached (remote sessions are not counted; their host does that). @@ -69,7 +70,16 @@ pub(super) async fn handle_request( let remote_authenticated = conn.auth.authenticated() && super::remote_guard::control_of(conn.origin, conn.auth, ¶ms) == crate::hub::Control::Web; - let verdict = hub.person_check(conn.is_person(), remote_authenticated, m, ¶ms).await?; + let verdict = hub + .person_check( + conn.is_person(), + remote_authenticated, + conn.caller(), + !conn.origin.web_class(), + m, + ¶ms, + ) + .await?; let resolved = super::session_key(¶ms).ok().and_then(|key| hub.resolve(key).ok()); super::trust_gate::check(hub, conn.origin, m, ¶ms, resolved.as_ref()).await?; // the folder-trust gate let key = super::session_key(¶ms).ok().map(str::to_owned); @@ -259,6 +269,7 @@ async fn dispatch_request( } let adopt = crate::adopt::AdoptRequest::from_meta(meta).map_err(RpcError::invalid_params)?; + let adopting = adopt.is_some(); let pick = |key: &str| { meta.and_then(|m| m.get(key)) .and_then(Value::as_str) @@ -284,6 +295,8 @@ async fn dispatch_request( let s = hub.new_session(req).await?; if conn.origin.web_class() { super::remote_guard::settle_web_session_mode(hub, &s).await?; + } else { + hub.start_in_mode(conn.is_person(), conn.caller(), ¶ms, &s, adopting).await?; } attach(hub, conn, &s.id); let meta = s.meta(); @@ -644,203 +657,7 @@ async fn dispatch_request( None => Ok(json!({"families": resolved, "defaults": cfg.defaults})), } } - // Read or change presets: {name?, set?: {harness, model, effort, policy, env, args}, clear?: bool}. - method::MUX_PRESETS => { - let name = str_param(¶ms, "name").map(str::to_owned); - let set = params.get("set").filter(|v| v.is_object()); - let clear = params.get("clear").and_then(Value::as_bool).unwrap_or(false); - if set.is_some() || clear { - let name = name.clone().ok_or_else(|| { - RpcError::invalid_params("name is required to change a preset") - })?; - let mut cfg = hub.config.write().await; - // REMOTE-FLOOR v3: a remote-origin client builds its settings - // from scratch, so it never sets, changes or clears a preset - // that shapes the harness command line (args, systemPrompt). - // LocalApp = same-user secret, equal to the unix socket for STARTING presets; writes stay unix-socket only. - let remote = conn.origin != Origin::Local; - if remote - && (cfg.presets.get(&name).is_some_and(|p| p.shapes_command()) - || set.is_some_and(|s| { - ["args", "systemPrompt"] - .iter() - .any(|k| s.get(*k).is_some_and(|v| !v.is_null())) - })) - { - return Err(RpcError::invalid_params(format!( - "preset {name:?}: args and systemPrompt are set only over the local socket; a remote-origin connection builds its settings from scratch" - ))); - } - let presets_dir = cfg.presets_dir(); - // The new system prompt text, written once the set is valid. - let mut new_prompt: Option> = None; - if clear { - cfg.presets.remove(&name); - if let Some(dir) = &presets_dir { - crate::config::remove_preset_dir(dir, &name).map_err(|e| { - RpcError::internal(format!("remove preset directory: {e}")) - })?; - } - } else if let Some(set) = set { - let obj = set.as_object().unwrap(); - let mut merged = cfg.presets.get(&name).cloned(); - let harness = obj - .get("harness") - .and_then(Value::as_str) - .map(str::to_owned) - .or_else(|| merged.as_ref().map(|p| p.harness.clone())) - .ok_or_else(|| { - RpcError::invalid_params("a preset needs harness=FAMILY-or-PROFILE") - })?; - cfg.resolve_harness(&harness).map_err(RpcError::invalid_params)?; - let p = merged.get_or_insert_with(|| crate::config::Preset { - harness: harness.clone(), - model: None, - effort: None, - policy: None, - env: std::collections::BTreeMap::new(), - args: Vec::new(), - system_prompt_sha256: None, - description: None, - }); - p.harness = harness; - for (k, v) in obj { - match (k.as_str(), v) { - ("harness", _) => {} - ("model", Value::Null) => p.model = None, - ("model", Value::String(m)) => p.model = Some(m.clone()), - ("effort", Value::Null) => p.effort = None, - ("effort", Value::String(e)) => p.effort = Some(e.clone()), - ("policy", Value::Null) => p.policy = None, - ("policy", Value::String(pol)) => { - p.policy = Some( - pol.parse::() - .map_err(RpcError::invalid_params)?, - ) - } - ("description", Value::Null) => p.description = None, - ("description", Value::String(d)) => p.description = Some(d.clone()), - ("systemPrompt", Value::Null) => new_prompt = Some(None), - ("systemPrompt", Value::String(text)) => { - new_prompt = Some(Some(text.clone())) - } - ("systemPrompt", _) => { - return Err(RpcError::invalid_params( - "systemPrompt must be the prompt's text (a string) or null", - )); - } - ("args", Value::Null) => p.args.clear(), - ("args", v) => { - p.args = crate::config::parse_preset_args(v) - .map_err(RpcError::invalid_params)? - } - ("env", Value::Null) => p.env.clear(), - ("env", Value::Object(map)) => { - for (ek, ev) in map { - match ev { - Value::Null => { - p.env.remove(ek); - } - Value::String(s) => { - p.env.insert(ek.clone(), s.clone()); - } - _ => { - return Err(RpcError::invalid_params(format!( - "env.{ek} must be a string" - ))); - } - } - } - } - (other, _) => { - return Err(RpcError::invalid_params(format!( - "unknown preset key {other:?}; use harness, model, effort, policy, env, args, systemPrompt, description" - ))); - } - } - } - let mut p = merged.unwrap(); - // Checked against the profile the preset resolves to now. - let profile = - cfg.resolve_harness(&p.harness).map_err(RpcError::invalid_params)?; - // A built-in Chief preset without a client env gets its - // env from the definition (`config/chief_builtins.rs`), - // whoever installs it; a client env is the person's - // (`hub/person.rs` refused it from anyone else). - if let Some(builtin) = crate::config::chief_builtins::parse(&name) - && obj.get("env").is_none() - { - let family = - crate::config::derive_family(&profile, &cfg.harnesses[&profile]); - p.env = crate::config::chief_builtins::env( - &builtin, - &family, - &crate::config::chief_builtins::Context::current(), - ) - .map_err(RpcError::invalid_params)?; - } - let kind = cfg.harnesses[&profile].kind; - crate::config::check_preset_args(kind, &p.args) - .map_err(RpcError::invalid_params)?; - let keeps_prompt = - p.system_prompt_sha256.is_some() && !matches!(new_prompt, Some(None)); - if (matches!(new_prompt, Some(Some(_))) || keeps_prompt) - && kind != crate::config::HarnessKind::ClaudeStdio - { - return Err(RpcError::invalid_params( - "systemPrompt: only Claude Code harnesses take a system prompt file", - )); - } - match new_prompt { - Some(Some(text)) => { - crate::config::check_preset_dir_name(&name) - .map_err(RpcError::invalid_params)?; - let dir = presets_dir.as_ref().ok_or_else(|| { - RpcError::invalid_params( - "systemPrompt: this daemon has no state directory for preset files", - ) - })?; - let sha = crate::config::write_system_prompt(dir, &name, &text) - .map_err(|e| { - RpcError::internal(format!("write the system prompt file: {e}")) - })?; - p.system_prompt_sha256 = Some(sha); - } - Some(None) => { - p.system_prompt_sha256 = None; - if let Some(dir) = &presets_dir { - crate::config::remove_preset_dir(dir, &name).map_err(|e| { - RpcError::internal(format!("remove preset directory: {e}")) - })?; - } - } - None => {} - } - cfg.presets.insert(name.clone(), p); - } - if let Err(e) = cfg.save() { - return Err(RpcError::internal(format!("save config: {e}"))); - } - } - let cfg = hub.config.read().await; - let view = |n: &str, p: &crate::config::Preset| { - let (profile, error) = match cfg.resolve_harness(&p.harness) { - Ok(x) => (Some(x), None), - Err(e) => (None, Some(e)), - }; - json!({"name": n, "harness": p.harness, "profile": profile, "error": error, "model": p.model, "effort": p.effort, "policy": p.policy, "env": p.env, "args": p.args, "systemPromptSha256": p.system_prompt_sha256, "description": p.description}) - }; - match name { - Some(n) if !clear => cfg - .presets - .get(&n) - .map(|p| view(&n, p)) - .ok_or_else(|| RpcError::not_found(format!("no preset {n:?}"))), - _ => Ok( - json!({"presets": cfg.presets.iter().map(|(n, p)| view(n, p)).collect::>()}), - ), - } - } + method::MUX_PRESETS => presets::handle(hub, conn, ¶ms).await, method::MUX_INFO => { let s = hub.resolve(session_key(¶ms)?)?; let mut detail = hub.session_detail(&s); diff --git a/cmux-tui/crates/acpmux/src/server/requests/presets.rs b/cmux-tui/crates/acpmux/src/server/requests/presets.rs new file mode 100644 index 000000000000..2311bc414f9b --- /dev/null +++ b/cmux-tui/crates/acpmux/src/server/requests/presets.rs @@ -0,0 +1,211 @@ +//! `_acpmux/presets`: read or change presets. + +use super::*; + +/// Read or change presets: {name?, set?: {harness, model, effort, policy, env, args, mode}, clear?: bool, +/// approveMode?: bool} (`approveMode: true` is the person's own, `hub/person.rs` (b)). +pub(super) async fn handle( + hub: &Arc, + conn: &Arc, + params: &Value, +) -> Result { + let name = str_param(params, "name").map(str::to_owned); + let approve = params.get("approveMode").and_then(Value::as_bool); + let no_fields = json!({}); + let set = + params.get("set").filter(|v| v.is_object()).or(approve.is_some().then_some(&no_fields)); + let clear = params.get("clear").and_then(Value::as_bool).unwrap_or(false); + if set.is_some() || clear { + let name = name + .clone() + .ok_or_else(|| RpcError::invalid_params("name is required to change a preset"))?; + let mut cfg = hub.config.write().await; + // REMOTE-FLOOR v3: a remote-origin client builds its settings + // from scratch, so it never sets, changes or clears a preset + // that shapes the harness command line (args, systemPrompt). + // LocalApp = same-user secret, equal to the unix socket for STARTING presets; writes stay unix-socket only. + let remote = conn.origin != Origin::Local; + if remote + && (cfg.presets.get(&name).is_some_and(|p| p.shapes_command()) + || set.is_some_and(|s| { + ["args", "systemPrompt"].iter().any(|k| s.get(*k).is_some_and(|v| !v.is_null())) + })) + { + return Err(RpcError::invalid_params(format!( + "preset {name:?}: args and systemPrompt are set only over the local socket; a remote-origin connection builds its settings from scratch" + ))); + } + let presets_dir = cfg.presets_dir(); + // The new system prompt text, written once the set is valid. + let mut new_prompt: Option> = None; + if clear { + cfg.presets.remove(&name); + if let Some(dir) = &presets_dir { + crate::config::remove_preset_dir(dir, &name) + .map_err(|e| RpcError::internal(format!("remove preset directory: {e}")))?; + } + } else if let Some(set) = set { + let obj = set.as_object().unwrap(); + let mut merged = cfg.presets.get(&name).cloned(); + let harness = obj + .get("harness") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| merged.as_ref().map(|p| p.harness.clone())) + .ok_or_else(|| { + RpcError::invalid_params("a preset needs harness=FAMILY-or-PROFILE") + })?; + cfg.resolve_harness(&harness).map_err(RpcError::invalid_params)?; + let p = merged.get_or_insert_with(|| crate::config::Preset { + harness: harness.clone(), + model: None, + effort: None, + policy: None, + env: std::collections::BTreeMap::new(), + args: Vec::new(), + system_prompt_sha256: None, + description: None, + mode: None, + mode_approval: None, + }); + p.harness = harness; + for (k, v) in obj { + match (k.as_str(), v) { + ("harness", _) => {} + ("model", Value::Null) => p.model = None, + ("model", Value::String(m)) => p.model = Some(m.clone()), + ("effort", Value::Null) => p.effort = None, + ("effort", Value::String(e)) => p.effort = Some(e.clone()), + ("policy", Value::Null) => p.policy = None, + ("policy", Value::String(pol)) => { + p.policy = Some( + pol.parse::().map_err(RpcError::invalid_params)?, + ) + } + ("mode", Value::Null) => p.mode = None, + ("mode", Value::String(m)) => p.mode = Some(m.clone()), + ("mode", _) => { + return Err(RpcError::invalid_params( + "mode must be a mode id (a string) or null", + )); + } + ("description", Value::Null) => p.description = None, + ("description", Value::String(d)) => p.description = Some(d.clone()), + ("systemPrompt", Value::Null) => new_prompt = Some(None), + ("systemPrompt", Value::String(text)) => new_prompt = Some(Some(text.clone())), + ("systemPrompt", _) => { + return Err(RpcError::invalid_params( + "systemPrompt must be the prompt's text (a string) or null", + )); + } + ("args", Value::Null) => p.args.clear(), + ("args", v) => { + p.args = + crate::config::parse_preset_args(v).map_err(RpcError::invalid_params)? + } + ("env", Value::Null) => p.env.clear(), + ("env", Value::Object(map)) => { + for (ek, ev) in map { + match ev { + Value::Null => { + p.env.remove(ek); + } + Value::String(s) => { + p.env.insert(ek.clone(), s.clone()); + } + _ => { + return Err(RpcError::invalid_params(format!( + "env.{ek} must be a string" + ))); + } + } + } + } + (other, _) => { + return Err(RpcError::invalid_params(format!( + "unknown preset key {other:?}; use harness, model, effort, policy, env, args, systemPrompt, description, mode" + ))); + } + } + } + let mut p = merged.unwrap(); + // Checked against the profile the preset resolves to now. + let profile = cfg.resolve_harness(&p.harness).map_err(RpcError::invalid_params)?; + // A built-in Chief preset without a client env gets its + // env from the definition (`config/chief_builtins.rs`), + // whoever installs it; a client env is the person's + // (`hub/person.rs` refused it from anyone else). + if let Some(builtin) = crate::config::chief_builtins::parse(&name) + && obj.get("env").is_none() + { + let family = crate::config::derive_family(&profile, &cfg.harnesses[&profile]); + p.env = crate::config::chief_builtins::env( + &builtin, + &family, + &crate::config::chief_builtins::Context::current(), + ) + .map_err(RpcError::invalid_params)?; + } + let kind = cfg.harnesses[&profile].kind; + crate::config::check_preset_args(kind, &p.args).map_err(RpcError::invalid_params)?; + let keeps_prompt = + p.system_prompt_sha256.is_some() && !matches!(new_prompt, Some(None)); + if (matches!(new_prompt, Some(Some(_))) || keeps_prompt) + && kind != crate::config::HarnessKind::ClaudeStdio + { + return Err(RpcError::invalid_params( + "systemPrompt: only Claude Code harnesses take a system prompt file", + )); + } + match new_prompt { + Some(Some(text)) => { + crate::config::check_preset_dir_name(&name) + .map_err(RpcError::invalid_params)?; + let dir = presets_dir.as_ref().ok_or_else(|| { + RpcError::invalid_params( + "systemPrompt: this daemon has no state directory for preset files", + ) + })?; + let sha = + crate::config::write_system_prompt(dir, &name, &text).map_err(|e| { + RpcError::internal(format!("write the system prompt file: {e}")) + })?; + p.system_prompt_sha256 = Some(sha); + } + Some(None) => { + p.system_prompt_sha256 = None; + if let Some(dir) = &presets_dir { + crate::config::remove_preset_dir(dir, &name).map_err(|e| { + RpcError::internal(format!("remove preset directory: {e}")) + })?; + } + } + None => {} + } + // The person's approval binds these contents (`hub/person.rs` (b)). + p.apply_mode_approval(approve).map_err(RpcError::invalid_params)?; + cfg.presets.insert(name.clone(), p); + } + if let Err(e) = cfg.save() { + return Err(RpcError::internal(format!("save config: {e}"))); + } + } + let cfg = hub.config.read().await; + let view = |n: &str, p: &crate::config::Preset| { + let (profile, error) = match cfg.resolve_harness(&p.harness) { + Ok(x) => (Some(x), None), + Err(e) => (None, Some(e)), + }; + json!({"name": n, "harness": p.harness, "profile": profile, "error": error, "model": p.model, "effort": p.effort, "policy": p.policy, "env": p.env, "args": p.args, "systemPromptSha256": p.system_prompt_sha256, "description": p.description, "mode": p.mode, "modeApproved": p.mode_approved()}) + }; + match name { + Some(n) if !clear => cfg + .presets + .get(&n) + .map(|p| view(&n, p)) + .ok_or_else(|| RpcError::not_found(format!("no preset {n:?}"))), + _ => { + Ok(json!({"presets": cfg.presets.iter().map(|(n, p)| view(n, p)).collect::>()})) + } + } +} diff --git a/cmux-tui/crates/acpmux/tests/hub_integration.rs b/cmux-tui/crates/acpmux/tests/hub_integration.rs index 91fcf9a01105..5e2a7db2b1bd 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration.rs @@ -1074,16 +1074,7 @@ async fn catalog_reload_preserves_pending_turn_and_rejects_invalid_config() { ); next.presets.insert( "flash".into(), - acpmux::config::Preset { - harness: "deepseek".into(), - model: None, - effort: None, - policy: None, - env: BTreeMap::new(), - args: Vec::new(), - system_prompt_sha256: None, - description: None, - }, + serde_json::from_value::(json!({"harness": "deepseek"})).unwrap(), ); std::fs::write(&path, serde_json::to_vec(&next).unwrap()).unwrap(); let reload = c.request(method::MUX_RELOAD_CONFIG, json!({})).await.unwrap(); From 4b5cf673e75d2751e6664e91a0d3ba46e446ce78 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:11:36 -0700 Subject: [PATCH 048/183] cmux-next motion (hq-1d phases 1-3): closed-form springs on one MotionClock, cmux-motion core and tokens (+zoom/exit), opt-in kinds (ui.animations.newTab/newWorkspace default false; panes on ui.paneAnimations), Reduce Motion moves nothing, frame-strip motion proof tooling Landing stack of origin/nx-motion-springs-landing 69af321c8c74 rebased onto feat-cmux-next (per-step history: origin/nx-motion-springs-phase3). On this tip: - the sidebar rows on the shared clock (e2494a3a) are NOT included (+3.5 ms/frame in a Cmd-T loop); - tab placement after the current tab and a New Tab page's replacement stay #20190's NewTabSuccession; my strip-level expectInsertion/expectReplacement/placeInsertions and the provisional-tab pairing are dropped (the store pairs provisional tabs since nx-cmdt-fix); - nx-cmdt-fix's insert-at-once stays the default; ui.animations.newTab on restores the grow-in (width 0, alpha 0) and skips insertsAtOnce; closes follow the same switch (hq-1d decision, pending Lawrence); a tab that comes or goes during a drag, group drag or drop settle changes at once while the gesture's springs keep running (hq-4e review item 1); - the Reduce Motion crossfade (TabStripCrossfade) stays removed: Reduce Motion moves nothing (hq-1d decision pending Lawrence); - debug.new_tab frame_strip keeps the tip's WindowFrame checker and adds the + / drag_new gestures, chrome progress and the tabs.travel summary for motion-proof.py. --- .github/workflows/app-ffi-release.yml | 5 +- .../CmuxNextActions/ActionCatalog.xcstrings | 524 ++++++++++++ .../ActionSurfaceCatalog+Exemptions.swift | 8 +- .../ActionSurfaceCatalog+MenuExemptions.swift | 3 +- .../Catalog/SettingsActionCatalog.swift | 24 + .../CmuxNextApp/Control/DebugFrameStrip.swift | 75 +- .../CmuxNextApp/Control/DebugMotion.swift | 43 +- .../Handlers/AppearanceHandlers.swift | 15 + .../CmuxNextDesign/DesignSettings.swift | 4 + .../CmuxNextDesign/Motion/Motion.swift | 17 +- .../CmuxNextDesign/Motion/MotionClock.swift | 111 +++ .../CmuxNextDesign/Motion/MotionKinds.swift | 33 + .../CmuxNextDesign/Motion/MotionPolicy.swift | 20 +- .../CmuxNextDesign/Motion/MotionTokens.swift | 26 +- .../CmuxNextDesign/Motion/MotionTrace.swift | 48 +- .../Motion/MotionTravelSummary.swift | 31 + .../Motion/MotionTunables.swift | 28 +- .../Motion/SpringParameters.swift | 70 +- .../Views/DisplayLinkDriver.swift | 42 +- .../CmuxNextLayout/Views/PaneHostView.swift | 5 +- .../Views/ScreenContentView+DividerDrag.swift | 15 +- .../Views/ScreenContentView+Dock.swift | 4 +- .../Views/ScreenContentView+Visibility.swift | 91 ++ .../Views/ScreenContentView.swift | 128 ++- .../CmuxNextLayout/Views/ScreenDividers.swift | 20 + .../CmuxNextLayout/Views/SplitMotion.swift | 66 ++ .../AnimationKindsSetting.swift | 50 ++ .../CmuxNextSettings/CmuxConfigSnapshot.swift | 2 + .../CmuxNextSettings/Localizable.xcstrings | 786 ++++++++++++++++++ .../CmuxNextSettings/MotionParityCorpus.swift | 54 ++ .../Schema/SettingsSchema+AgentPolicy.swift | 3 + .../Schema/SettingsSchema+Appearance.swift | 25 + .../CmuxNextSettings/SettingsApplier.swift | 2 + .../Views/SidebarListView.swift | 6 +- .../Views/SidebarRowMotion.swift | 6 +- .../Sources/CmuxNextTabs/Spring.swift | 2 +- .../CmuxNextTabs/TabStripCrossfade.swift | 14 - .../CmuxNextTabs/TabStripView+Drag.swift | 12 + .../CmuxNextTabs/TabStripView+Layout.swift | 28 +- .../CmuxNextTabs/TabStripView+Sync.swift | 24 +- .../Sources/CmuxNextTabs/TabStripView.swift | 11 +- cmux-tui/crates/cmux-app-ffi/Cargo.lock | 5 + cmux-tui/crates/cmux-app-ffi/Cargo.toml | 5 +- .../cmux-app-ffi/include/cmux_motion_ffi.h | 44 + cmux-tui/crates/cmux-app-ffi/src/lib.rs | 3 + cmux-tui/crates/cmux-app-ffi/src/motion.rs | 159 ++++ .../crates/cmux-motion/build-support/json.rs | 210 +++++ cmux-tui/crates/cmux-motion/build.rs | 169 ++++ .../examples/motion_trajectories.rs | 86 ++ cmux-tui/crates/cmux-motion/src/fade.rs | 161 ++++ cmux-tui/crates/cmux-motion/src/lib.rs | 21 +- cmux-tui/crates/cmux-motion/src/policy.rs | 110 +++ cmux-tui/crates/cmux-motion/src/settings.rs | 50 +- cmux-tui/crates/cmux-motion/src/spring.rs | 605 +++----------- cmux-tui/crates/cmux-motion/src/tokens.rs | 365 ++++++++ plans/cmux-next/motion.md | 91 +- .../spec-proposals/visuals/design-tokens.json | 127 ++- schemas/defaults/cmux-next.json | 3 + scripts/cmux-next/build-app-ffi.sh | 6 +- scripts/cmux-next/check-app-ffi-pin.sh | 5 + scripts/cmux-next/cmux-tui-tree-inputs.txt | 1 + scripts/cmux-next/motion-parity.py | 97 +++ scripts/cmux-next/motion-proof.py | 350 ++++++++ 63 files changed, 4428 insertions(+), 726 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTravelSummary.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Visibility.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/SplitMotion.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/MotionParityCorpus.swift delete mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripCrossfade.swift create mode 100644 cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h create mode 100644 cmux-tui/crates/cmux-app-ffi/src/motion.rs create mode 100644 cmux-tui/crates/cmux-motion/build-support/json.rs create mode 100644 cmux-tui/crates/cmux-motion/build.rs create mode 100644 cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs create mode 100644 cmux-tui/crates/cmux-motion/src/fade.rs create mode 100644 cmux-tui/crates/cmux-motion/src/policy.rs create mode 100644 cmux-tui/crates/cmux-motion/src/tokens.rs create mode 100755 scripts/cmux-next/motion-parity.py create mode 100644 scripts/cmux-next/motion-proof.py diff --git a/.github/workflows/app-ffi-release.yml b/.github/workflows/app-ffi-release.yml index 6235e1f5d20a..f4209084c17b 100644 --- a/.github/workflows/app-ffi-release.yml +++ b/.github/workflows/app-ffi-release.yml @@ -1,6 +1,6 @@ # Builds the app's ONE Rust static library, CCmuxAppFFI.xcframework -# (cmux-tui/crates/cmux-app-ffi: the remote desktop core and the sidebar layout -# reducer C ABIs over one Rust runtime, universal macOS arm64 + x86_64; see +# (cmux-tui/crates/cmux-app-ffi: the remote desktop core, the sidebar layout +# reducer and the cmux-motion C ABIs over one Rust runtime, universal macOS arm64 + x86_64; see # scripts/cmux-next/build-app-ffi.sh), and publishes it as a prerelease in this # repository, tagged cmux-app-ffi-. # Packages/macOS/CmuxNext/Package.swift pins one release by URL and SwiftPM @@ -42,6 +42,7 @@ on: - "cmux-tui/crates/cmux-layout-reducer/**" - "cmux-tui/crates/cmux-layout-reducer-ffi/**" - "cmux-tui/crates/cmux-palette-ranker/**" + - "cmux-tui/crates/cmux-motion/**" - "cmux-tui/crates/cmux-rd-core/**" - "cmux-tui/crates/cmux-rd-proto/**" - "cmux-tui/crates/cmux-rd-ffi/**" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings index 2ff5913035ec..72c4f4f4f888 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings @@ -78356,6 +78356,530 @@ } } } + }, + "action.appearance.animations.newTab.toggle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle New Tab Animations" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいタブのアニメーションを切り替え" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تبديل حركة علامات التبويب الجديدة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Uključi/isključi animacije novih kartica" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Slå animationer af nye faner til/fra" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Animationen neuer Tabs ein/aus" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Activar o desactivar animaciones de pestañas nuevas" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Activer/désactiver les animations des nouveaux onglets" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Attiva/disattiva animazioni delle nuove schede" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "បិទ/បើកចលនាផ្ទាំងថ្មី" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 탭 애니메이션 전환" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Slå animasjoner av nye faner av/på" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Przełącz animacje nowych kart" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Alternar animações de novas abas" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Переключить анимацию новых вкладок" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "สลับภาพเคลื่อนไหวแท็บใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni sekme animasyonlarını aç/kapat" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Перемкнути анімацію нових вкладок" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Bật/tắt hiệu ứng tab mới" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "切换新标签页动画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "切換新分頁動畫" + } + } + } + }, + "action.appearance.animations.newWorkspace.toggle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle New Workspace Animations" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいワークスペースのアニメーションを切り替え" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تبديل حركة مساحات العمل الجديدة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Uključi/isključi animacije novih radnih prostora" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Slå animationer af nye arbejdsområder til/fra" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Animationen neuer Arbeitsbereiche ein/aus" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Activar o desactivar animaciones de espacios de trabajo nuevos" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Activer/désactiver les animations des nouveaux espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Attiva/disattiva animazioni dei nuovi spazi di lavoro" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "បិទ/បើកចលនាកន្លែងធ្វើការថ្មី" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 작업 공간 애니메이션 전환" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Slå animasjoner av nye arbeidsområder av/på" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Przełącz animacje nowych obszarów roboczych" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Alternar animações de novos espaços de trabalho" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Переключить анимацию новых рабочих пространств" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "สลับภาพเคลื่อนไหวพื้นที่ทำงานใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni çalışma alanı animasyonlarını aç/kapat" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Перемкнути анімацію нових робочих просторів" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Bật/tắt hiệu ứng không gian làm việc mới" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "切换新工作区动画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "切換新工作區動畫" + } + } + } + }, + "action.appearance.animations.bounce.subtle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Bouncy Animations" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "弾むアニメーションを使用" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "استخدام حركة مرتدة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Koristi animacije s odskokom" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Brug fjedrende animationer" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Federnde Animationen verwenden" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Usar animaciones con rebote" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Utiliser des animations avec rebond" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Usa animazioni con rimbalzo" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ប្រើចលនាលោត" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "통통 튀는 애니메이션 사용" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Bruk fjærende animasjoner" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Używaj animacji z odbiciem" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Usar animações com quique" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Использовать пружинистую анимацию" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ใช้ภาพเคลื่อนไหวแบบเด้ง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Sekmeli animasyonlar kullan" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Використовувати пружну анімацію" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Dùng hiệu ứng có độ nảy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "使用弹跳动画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "使用彈跳動畫" + } + } + } + }, + "action.appearance.animations.bounce.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Animations Without Bounce" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "弾まないアニメーションを使用" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "استخدام حركة بلا ارتداد" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Koristi animacije bez odskoka" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Brug animationer uden fjedring" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Animationen ohne Federn verwenden" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Usar animaciones sin rebote" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Utiliser des animations sans rebond" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Usa animazioni senza rimbalzo" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ប្រើចលនាគ្មានការលោត" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "바운스 없는 애니메이션 사용" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Bruk animasjoner uten fjæring" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Używaj animacji bez odbicia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Usar animações sem quique" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Использовать анимацию без отскока" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ใช้ภาพเคลื่อนไหวแบบไม่เด้ง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Sekmesiz animasyonlar kullan" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Використовувати анімацію без відскоку" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Dùng hiệu ứng không nảy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "使用无弹跳动画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "使用無彈跳動畫" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift index 98ea26fc020d..f55e3daa7e12 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift @@ -103,7 +103,8 @@ nonisolated extension ActionSurfaceCatalog { "palette.shortcutKeymap", "palette.installCLI", "palette.uninstallCLI", "palette.restartSocketListener", "palette.checkForUpdates", "updates.whatsNew", "announcements.show", "announcements.hide", "palette.applyUpdateIfAvailable", "palette.switchAppChannel", "appearance.density.compact", "appearance.density.comfortable", "appearance.animationSpeed.fast", - "appearance.animationSpeed.normal", "appearance.animationSpeed.off", "browser.defaultEngine.chromium", + "appearance.animationSpeed.normal", "appearance.animationSpeed.off", "appearance.animations.newTab.toggle", "appearance.animations.newWorkspace.toggle", "appearance.animations.bounce.subtle", "appearance.animations.bounce.none", + "browser.defaultEngine.chromium", "browser.defaultEngine.webkit", "appearance.paneBorder.toggle", "appearance.panePadding.toggle", "appearance.paneCorners.toggle", "layout.centerFocusedColumn.never", "layout.centerFocusedColumn.always", "layout.centerFocusedColumn.onOverflow", "focusRing.toggle", "focusRing.style.ring", "focusRing.style.glow", @@ -274,6 +275,11 @@ nonisolated extension ActionSurfaceCatalog { "appearance.animationSpeed.fast", "appearance.animationSpeed.normal", "appearance.animationSpeed.off", + + "appearance.animations.newTab.toggle", + "appearance.animations.newWorkspace.toggle", + "appearance.animations.bounce.subtle", + "appearance.animations.bounce.none", "browser.defaultEngine.chromium", "browser.defaultEngine.webkit", "appearance.paneBorder.toggle", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift index 8fcdccdbb93c..fac9e04d8712 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift @@ -53,7 +53,8 @@ nonisolated extension ActionSurfaceCatalog { "palette.applyUpdateIfAvailable", "palette.switchAppChannel", "palette.pro.upgrade", "sendFeedback", "help.featureFlags", "help.documentation", "help.showCrashLogs", "appearance.density.compact", "appearance.density.comfortable", "appearance.animationSpeed.fast", - "appearance.animationSpeed.normal", "appearance.animationSpeed.off", "browser.defaultEngine.chromium", + "appearance.animationSpeed.normal", "appearance.animationSpeed.off", "appearance.animations.newTab.toggle", "appearance.animations.newWorkspace.toggle", "appearance.animations.bounce.subtle", "appearance.animations.bounce.none", + "browser.defaultEngine.chromium", "browser.defaultEngine.webkit", "appearance.paneBorder.toggle", "appearance.panePadding.toggle", "appearance.paneCorners.toggle", "layout.centerFocusedColumn.never", "layout.centerFocusedColumn.always", "layout.centerFocusedColumn.onOverflow", "focusRing.toggle", "focusRing.style.ring", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/SettingsActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/SettingsActionCatalog.swift index 094201caffde..530774b6ad66 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/SettingsActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/SettingsActionCatalog.swift @@ -217,6 +217,30 @@ nonisolated enum SettingsActionCatalog: ActionCatalogGroup { keywords: ["animation", "motion", "speed", "off", "disable", "reduce", "appearance"], category: .settings, symbol: "figure.stand", surfaces: [.palette], cliName: "settings turn-off-animations" ), + ActionDescriptor( + id: "appearance.animations.newTab.toggle", + title: String(localized: "action.appearance.animations.newTab.toggle", defaultValue: "Toggle New Tab Animations", bundle: .module), + keywords: ["animation", "motion", "tab", "new tab", "appearance"], category: .settings, + symbol: "plus.rectangle.on.rectangle", surfaces: [.palette], cliName: "settings toggle-new-tab-animations" + ), + ActionDescriptor( + id: "appearance.animations.newWorkspace.toggle", + title: String(localized: "action.appearance.animations.newWorkspace.toggle", defaultValue: "Toggle New Workspace Animations", bundle: .module), + keywords: ["animation", "motion", "workspace", "sidebar", "appearance"], category: .settings, + symbol: "sidebar.left", surfaces: [.palette], cliName: "settings toggle-new-workspace-animations" + ), + ActionDescriptor( + id: "appearance.animations.bounce.subtle", + title: String(localized: "action.appearance.animations.bounce.subtle", defaultValue: "Use Bouncy Animations", bundle: .module), + keywords: ["animation", "motion", "spring", "bounce", "bouncy", "appearance"], category: .settings, + symbol: "arrow.up.and.down.and.sparkles", surfaces: [.palette], cliName: "settings use-bouncy-animations" + ), + ActionDescriptor( + id: "appearance.animations.bounce.none", + title: String(localized: "action.appearance.animations.bounce.none", defaultValue: "Use Animations Without Bounce", bundle: .module), + keywords: ["animation", "motion", "spring", "bounce", "critically damped", "appearance"], category: .settings, + symbol: "arrow.right.to.line", surfaces: [.palette], cliName: "settings use-animations-without-bounce" + ), ActionDescriptor( id: "browser.defaultEngine.chromium", title: String(localized: "action.browser.defaultEngine.chromium", defaultValue: "Use Chromium for New Browser Tabs", bundle: .module), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift index ea489245278c..4566288fd26e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift @@ -1,7 +1,9 @@ #if DEBUG import AppKit +import CmuxNextActions import CmuxNextDesign import CmuxNextSettings +import CmuxNextTabs import CmuxNextWakeups /// `debug.new_tab {action: "frame_strip"}` (DEBUG builds): the window as the @@ -22,6 +24,15 @@ import CmuxNextWakeups /// `content_intermediate` counts frames equal to neither. The pane's strip (its tabs slide) is left /// out of those and of `intermediate`; `strip_intermediate` counts its frames equal to neither, and /// `desync` those where one of strip and content shows the new tab and the other the old one. +/// +/// Motion proofs (scripts/cmux-next/motion-proof.py, hq-1d): `gesture: "plus"` presses the +/// focused strip's + (its intent) instead of a key, `gesture: "drag_new"` drags the strip's first +/// tab while a CLI new tab lands. Outside the content and the strip, each frame's `progress` +/// (0 = before, 1 = final) never goes back (`reversals`) or leaps (`jumps` over `jump`, default +/// 0.6; `max_step`), and `moving` counts the frames between before and final. `content: "none"` +/// (splits) takes no content area. Tab motion is judged from the strip's own `tabs.travel` +/// samples while `debug.motion` records (`tabs_moving_frames`, `tabs_restarts`: a second motion +/// in one gesture, `tabs_peak_travel`): a sliding cell is not monotonic in pixels. @MainActor enum DebugFrameStrip { typealias Frame = WindowFrame @@ -42,15 +53,24 @@ enum DebugFrameStrip { await withCheckedContinuation { (done: CheckedContinuation) in lead.schedule(after: .milliseconds(120)) { @MainActor in done.resume() } } - let sent = DebugKey.send(["key": .string(params["key"]?.stringValue ?? "n"), "modifiers": params["modifiers"] ?? .array([.string("command")]), + let sent: JSONValue + if params["gesture"]?.stringValue == "plus", let pane = controller.content?.focusedPane { + pane.stripModel.send(.newTab(after: nil)) + sent = .string("plus") + } else if params["gesture"]?.stringValue == "drag_new", let pane = controller.content?.focusedPane { + sent = await dragWhileNewTab(pane, services) + } else { + sent = DebugKey.send(["key": .string(params["key"]?.stringValue ?? "n"), "modifiers": params["modifiers"] ?? .array([.string("command")]), "window": .string(controller.state.id)], services: services) - let sentAt = BenchSpans.ms(.now - start) + } + let sentAt = BenchSpans.ms(.now - start), sentTime = CACurrentMediaTime() let frames = await recording.value let spans = BenchSpans.end() guard let first = frames.first, let last = frames.last, let before = frames.last(where: { $0.milliseconds < sentAt }) else { return .object(["error": .string("no frames"), "key": sent]) } - let content = (controller.content?.focusedPane?.view.contentHost).flatMap { rect(of: $0, window: window, frame: first) } + let content = params["content"]?.stringValue == "none" ? nil + : (controller.content?.focusedPane?.view.contentHost).flatMap { rect(of: $0, window: window, frame: first) } // The pane's strip, apart: its own motion (tabs sliding over) is not content. let strip = (controller.content?.focusedPane?.view.stripView) .flatMap { $0.isHiddenOrHasHiddenAncestor ? nil : rect(of: $0, window: window, frame: first) } @@ -58,6 +78,8 @@ enum DebugFrameStrip { var rows: [JSONValue] = [] var intermediate = 0, blank = 0, contentIntermediate = 0, desync = 0, stripIntermediate = 0 var settledAt: Double?, firstChange: Double? + var moving = 0, reversals = 0, jumps = 0, previous: Double?, maxStep = 0.0, peak = 0.0 + let jump = params["jump"]?.doubleValue ?? 0.6 let shown = frames.firstIndex { $0.milliseconds >= before.milliseconds } ?? frames.endIndex // Off the main actor: six comparisons a frame stalled it past the watchdog in a Debug build. let measures = await WindowFrame.measure(Array(frames[shown...]), before: before, last: last, content: content, strip: strip) @@ -72,7 +94,16 @@ enum DebugFrameStrip { // Neither the content before the key nor the settled one; blank: flat too (an empty terminal is flat). let isBlank = content != nil && m.flatness > 0.995 && insideBefore > tolerance && insideFinal > tolerance let isContentIntermediate = frame.milliseconds > sentAt && insideBefore > tolerance && insideFinal > tolerance - if isIntermediate { intermediate += 1 } + if isIntermediate { intermediate += 1; moving += 1 } + // Progress outside the content and the strip from before (0) to final (1). + let total = m.outsideBefore + m.outsideFinal + let progress = total > 0 ? m.outsideBefore / total : (m.outsideBefore > 0 ? 1 : 0) + if let previous { + maxStep = max(maxStep, progress - previous) + if progress - previous > jump, previous < 0.05 { jumps += 1 } + if progress < peak - 0.05 { reversals += 1 } + } + (peak, previous) = (max(peak, progress), progress) if isBlank { blank += 1 } if isContentIntermediate { contentIntermediate += 1 } if frame.milliseconds > sentAt { @@ -90,7 +121,11 @@ enum DebugFrameStrip { ])) } WindowFrame.writeStrip(frames.filter { $0.milliseconds >= before.milliseconds }, to: (dir as NSString).appendingPathComponent("strip.png")) + let tabs = MotionTravelSummary("tabs.travel", after: sentTime) return .object([ + "moving": .number(Double(moving)), "reversals": .number(Double(reversals)), "jumps": .number(Double(jumps)), + "max_step": .number((maxStep * 1000).rounded() / 1000), "tabs_moving_frames": .number(Double(tabs.movingFrames)), + "tabs_restarts": .number(Double(tabs.restarts)), "tabs_peak_travel": .number(tabs.peak.rounded()), "key": sent, "dir": .string(dir), "frames": .array(rows), "intermediate": .number(Double(intermediate)), "blank": .number(Double(blank)), "frame_count": .number(Double(rows.count)), "content_intermediate": .number(Double(contentIntermediate)), "desync": .number(Double(desync)), @@ -101,6 +136,38 @@ enum DebugFrameStrip { ]) } + /// `gesture: "drag_new"`: drags the focused strip's first tab right in 12 steps, one per frame + /// (synthetic events into the strip's own handlers), and at step 4 a script opens a terminal tab + /// in the pane (`newSurface` with the CLI's origin): the drag's springs must keep running. + private static func dragWhileNewTab(_ pane: PaneController, _ services: AppServices) async -> JSONValue { + let strip = pane.view.stripView + guard let window = strip.window, let id = strip.displayedOrder.first, let start = strip.tabCenterInWindow(id) else { + return .string("drag_new: no tab") + } + func send(_ type: NSEvent.EventType, _ dx: CGFloat) { + guard let event = NSEvent.mouseEvent(with: type, location: CGPoint(x: start.x + dx, y: start.y), modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1) + else { return } + switch type { + case .leftMouseDown: strip.mouseDown(with: event) + case .leftMouseUp: strip.mouseUp(with: event) + default: strip.mouseDragged(with: event) + } + } + send(.leftMouseDown, 0) + for step in 1...12 { + let tick = DemandTimer(owner: "debug.frame_strip.drag") + await withCheckedContinuation { (done: CheckedContinuation) in tick.schedule(after: .milliseconds(16)) { @MainActor in done.resume() } } + send(.leftMouseDragged, CGFloat(step) * 18) + if step == 4 { + _ = services.registry.perform("newSurface", invocation: ActionInvocation(target: ActionTargetRef(kind: .pane, id: pane.paneKey), origin: .cli)) + } + } + send(.leftMouseUp, 12 * 18) + return .string("drag_new") + } + /// `host`'s bounds in `frame` pixels (top-left origin). private static func rect(of host: NSView, window: NSWindow, frame: Frame) -> CGRect? { guard host.window === window, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift index 8510021c1764..f4d1d8f321dd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift @@ -1,5 +1,6 @@ import CmuxNextDesign import CmuxNextSettings +import CmuxNextWakeups /// `debug.motion`: `start` begins recording animation spans, `read` returns /// them, `stop` returns them and stops. Each span runs from an animation's @@ -10,6 +11,17 @@ import CmuxNextSettings /// false overrides Reduce Motion in memory (null follows the system setting /// again), so a bench records reduced motion without the system setting. /// `work` is each timed animation's main-thread work per frame (`MotionTrace.measure`). +/// `bounce` with `value` ("none", "subtle") and `kind` with `name` +/// ("paneAnimations", "newTab", "newWorkspace") and `value` true or false set +/// `ui.paneAnimations` / `ui.animations.*` in memory the same way. The report lists the display links alive +/// (`display_links`: 0 once every spring rests, so the window idles). +/// Spans carry `at_ms` (from `start`); `samples` lists per-frame values +/// (`clock.dt_ms`, and `tabs.travel`, `layout.travel`, `sidebar.open`: the +/// points an animator has left to move), so a bench reads each spring's path. +/// `trajectory` returns this port's motion parity corpus (the resolved +/// chrome springs and their frame paths), the same document cmux-motion's +/// `motion_trajectories` example prints; scripts/cmux-next/motion-parity.py +/// compares the two. enum DebugMotion { static func handle(_ params: [String: JSONValue]) -> JSONValue { switch params["action"]?.stringValue ?? "read" { @@ -21,6 +33,20 @@ enum DebugMotion { } case "reduce": Motion.reduceMotionOverride = params["value"]?.boolValue + case "bounce": + if let value = params["value"]?.stringValue, let bounce = MotionBounce(rawValue: value) { + DesignSettings.shared.animationBounce = bounce + } + case "kind": + if let name = params["name"]?.stringValue, let on = params["value"]?.boolValue { + if name == "paneAnimations" { + DesignSettings.shared.paneAnimations = on + } else if let kind = MotionKind(rawValue: name) { + if on { DesignSettings.shared.enabledAnimations.insert(kind) } else { DesignSettings.shared.enabledAnimations.remove(kind) } + } + } + case "trajectory": + return MotionParityCorpus().document case "stop": let report = self.report MotionTrace.stop() @@ -37,8 +63,23 @@ enum DebugMotion { "recording": .bool(MotionTrace.isEnabled), "speed": .string(policy.speed.rawValue), "reduce_motion": .bool(policy.reduceMotion), + "bounce": .string(policy.bounce.rawValue), + "enabled_kinds": .array(policy.enabledKinds.map(\.rawValue).sorted().map(JSONValue.string)), + "pane_animations": .bool(DesignSettings.shared.paneAnimations), + "display_links": .number(Double(FrameScheduler.all.filter(\.hasLink).count)), + "active_clients": .array(FrameScheduler.all.flatMap(\.activeClients).map(JSONValue.string)), "spans": .array(MotionTrace.spans.map { span in - ["name": .string(span.name), "ms": .number((span.milliseconds * 10).rounded() / 10)] + ["name": .string(span.name), "ms": .number((span.milliseconds * 10).rounded() / 10), + "at_ms": .number(((span.start - MotionTrace.startedAt) * 10000).rounded() / 10)] + }), + // Per-frame values (clock dt, travel left per animator), ms from `start`. + "samples": .array(MotionTrace.samples.map { sample in + ["name": .string(sample.name), "t_ms": .number(((sample.time - MotionTrace.startedAt) * 10000).rounded() / 10), + "v": .number((sample.value * 100).rounded() / 100)] + }), + // What an animator decided, ms from `start` (the tab strip's syncs: added, removed, animated). + "notes": .array(MotionTrace.notes.map { note in + ["t_ms": .number(((note.time - MotionTrace.startedAt) * 10000).rounded() / 10), "text": .string(note.text)] }), "work": .object(MotionTrace.work.mapValues { frames in let sorted = frames.sorted() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift index f5d0f9b5b525..08f87011a6d8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift @@ -38,6 +38,13 @@ enum AppearanceHandlers { } SettingChoiceActions.bind(MotionSpeed.allCases, into: registry, id: { "appearance.animationSpeed.\($0.rawValue)" }, path: AnimationSpeedSetting.configPath, fileValue: { .string($0.rawValue) }, save: save) { context.design.animationSpeed = $0 } + for kind in MotionKind.allCases { + registry.bind(ActionID(rawValue: "appearance.animations.\(kind.rawValue).toggle"), run: { _ in try toggleAnimation(kind, context) }) + } + // `ui.animations.bounce`: none (the default) removes the key. + SettingChoiceActions.bind(MotionBounce.allCases, into: registry, id: { "appearance.animations.bounce.\($0.rawValue)" }, + path: AnimationKindsSetting.bouncePath, fileValue: { $0 == AnimationKindsSetting.bounceFallback ? nil : .string($0.rawValue) }, + save: save) { context.design.animationBounce = $0 } SettingChoiceActions.bind(CenterFocusedColumn.allCases, into: registry, id: { "layout.centerFocusedColumn.\($0 == .onOverflow ? "onOverflow" : $0.rawValue)" }, path: CenterFocusedColumnSetting.configPath, fileValue: { .string($0.rawValue) }, save: save) { context.design.centerFocusedColumn = $0 } ChromeStyleSetting.bindActions(into: registry, design: context.design, save: save) @@ -143,6 +150,14 @@ enum AppearanceHandlers { context.writeSetting("set titlebar", WindowTitlebarSetting.configPath, value, reloadOnFailure: true) } + /// `ui.animations.`: flipped at once, then written (off, the default, removes the key). + private static func toggleAnimation(_ kind: MotionKind, _ context: AppActionContext) throws { + try requireUnmanaged(AnimationKindsSetting.path(kind), context) + let on = !context.design.enabledAnimations.contains(kind) + if on { context.design.enabledAnimations.insert(kind) } else { context.design.enabledAnimations.remove(kind) } + context.writeSetting("toggle \(kind.rawValue) animations", AnimationKindsSetting.path(kind), on ? .bool(true) : nil, reloadOnFailure: true) + } + /// Body size in points: the override, else the density default. static func interfaceSize(_ design: DesignSettings = .shared) -> Double { Double(design.overrides[.chromeFontSize] ?? (design.density == .compact ? 12 : 13)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift index c9fa324c358e..d965d3905dfb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift @@ -34,6 +34,10 @@ public final class DesignSettings { public var agentPaneZoom: CGFloat = 1 /// `ui.animationSpeed`: how fast chrome animates (see `Motion`). public var animationSpeed: MotionSpeed = .fast + /// `ui.animations.bounce`: structural springs are critically damped or bounce subtly. + public var animationBounce: MotionBounce = .none + /// `ui.animations.{newTab,newWorkspace}` set to true (both off by default). + public var enabledAnimations: Set = [] /// `ui.paneAnimations`: pane layout changes spring when true (see /// `Motion.animatesPaneLayout`); off by default. public var paneAnimations = false diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion.swift index 2da0d740b162..94b7d002eb58 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion.swift @@ -33,7 +33,16 @@ public struct Motion { public static var speed: MotionSpeed { DesignSettings.shared.animationSpeed } - public static var policy: MotionPolicy { MotionPolicy(speed: speed, reduceMotion: reduceMotion) } + public static var policy: MotionPolicy { + let design = DesignSettings.shared + return MotionPolicy(speed: speed, reduceMotion: reduceMotion, bounce: design.animationBounce, + enabledKinds: design.enabledAnimations) + } + + /// `kind` animates: movement animates (not `off`, not Reduce Motion) and + /// its `ui.animations.*` switch is on (off by default). When false the + /// change applies in one frame, with no crossfade. + public static func animates(_ kind: MotionKind) -> Bool { policy.animates(kind) } /// Position, size, scale and scroll animate. When false, snap. public static var animatesMovement: Bool { policy.animatesMovement } @@ -52,6 +61,12 @@ public struct Motion { public static func duration(_ token: MotionSpring) -> TimeInterval { policy.duration(token) } public static func period(_ loop: MotionLoop) -> TimeInterval? { policy.period(loop) } + /// Seconds of a released divider's velocity projected to find a snap; 0 + /// when movement does not animate. + public static var dividerReleaseProjection: Double { + animatesMovement ? MotionTunables.dividerReleaseProjection.value * speed.timeScale : 0 + } + /// The standard ease-out curve for timed fades. public static var fadeCurve: CAMediaTimingFunction { CAMediaTimingFunction(name: .easeOut) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift new file mode 100644 index 000000000000..c5919ca91429 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift @@ -0,0 +1,111 @@ +public import AppKit +import CmuxNextWakeups + +/// Something whose springs a ``MotionClock`` steps: the tab strip, the +/// layout's panes and dividers, the sidebar rows. +@MainActor +public protocol MotionAnimating: AnyObject { + /// Advances every spring by `dt` seconds and applies the presented + /// values (layer frames). Returns true while anything still moves. + func motionStep(_ dt: Double) -> Bool +} + +/// The one chrome animation clock of a window (motion.md, DESIGN P4): every +/// spring of the window steps on the same frame with the same `dt`, so a +/// tab growing in and its neighbours sliding, or a pane growing and the +/// divider beside it, can never drift apart. It is one client of the +/// window's ``FrameScheduler`` and goes idle when the last animator settles, +/// so a window at rest has no display link at all (0% idle CPU). +@MainActor +public final class MotionClock { + private struct Entry { + weak var animator: (any MotionAnimating)? + var trace: String + } + + private var entries: [ObjectIdentifier: Entry] = [:] + private var order: [ObjectIdentifier] = [] + private lazy var client = FrameClient(owner: "Motion.clock", scheduler: { [weak self] in + self?.window.map(FrameScheduler.forWindow) ?? .app + }) { [weak self] tick in + self?.tick(tick.elapsed) ?? false + } + /// The window whose scheduler the clock ticks on (nil: the app clock). + private weak var window: NSWindow? + /// Frames delivered while something moved (debug). + public private(set) var frames: UInt64 = 0 + + init(window: NSWindow?) { + self.window = window + } + + private static var associationKey: UInt8 = 0 + private static let app = MotionClock(window: nil) + + /// `window`'s clock, created on first use and kept by the window. + public static func forWindow(_ window: NSWindow) -> MotionClock { + if let clock = objc_getAssociatedObject(window, &associationKey) as? MotionClock { return clock } + let clock = MotionClock(window: window) + objc_setAssociatedObject(window, &associationKey, clock, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return clock + } + + /// The clock of `view`'s window; window-less views share the app clock. + public static func forView(_ view: NSView) -> MotionClock { + view.window.map(forWindow) ?? app + } + + /// Steps `animator` every frame until it reports rest. `trace` names its + /// `debug.motion` span (first frame to rest). No effect while running. + public func run(_ animator: some MotionAnimating, trace: String) { + let id = ObjectIdentifier(animator) + // A scheduler that dropped the client (its window closed) left stale entries behind. + if !client.isActive, !entries.isEmpty { + entries.removeAll() + order.removeAll() + } + guard entries[id] == nil else { return } + entries[id] = Entry(animator: animator, trace: trace) + if !order.contains(id) { order.append(id) } + MotionTrace.begin(trace, id: id) + client.activate() + } + + /// Stops stepping `animator` (it left its window). + public func stop(_ animator: some MotionAnimating) { + let id = ObjectIdentifier(animator) + guard let entry = entries.removeValue(forKey: id) else { return } + order.removeAll { $0 == id } + MotionTrace.end(entry.trace, id: id) + if entries.isEmpty { client.deactivate() } + } + + public func isRunning(_ animator: some MotionAnimating) -> Bool { + entries[ObjectIdentifier(animator)] != nil + } + + /// Animators being stepped now (debug). + public var running: [String] { order.compactMap { entries[$0]?.trace } } + + /// One frame. While `debug.motion` records, the main-thread work of the whole frame + /// (`clock`) and of each animator (its trace name) is timed (`MotionTrace.measure`). + private func tick(_ dt: Double) -> Bool { + MotionTrace.measure("clock") { step(dt) } + } + + private func step(_ dt: Double) -> Bool { + frames &+= 1 + if MotionTrace.isEnabled { MotionTrace.sample("clock.dt_ms", dt * 1000) } + // Animators started during this frame step from the next one. + for id in order { + guard let entry = entries[id] else { continue } + guard let animator = entry.animator, MotionTrace.measure(entry.trace, { animator.motionStep(dt) }) else { + entries[id] = nil + MotionTrace.end(entry.trace, id: id) + continue + } + } + order.removeAll { entries[$0] == nil } + return !entries.isEmpty + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift new file mode 100644 index 000000000000..320dce94fde4 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift @@ -0,0 +1,33 @@ +public import Foundation + +/// How springy structural motion is (`ui.animations.bounce` in cmux.json). +/// Structural springs (`move`, `appear`, `disappear`) are critically damped +/// by default: they never pass their target. `subtle` gives them SwiftUI's +/// bounce `MotionTunables.subtleBounce` (0.15). +public nonisolated enum MotionBounce: String, Sendable, CaseIterable, Codable { + case none + case subtle +} + +/// The structural changes a user can turn animation on for one by one +/// (`ui.animations.newTab`, `ui.animations.newWorkspace`; off by default: +/// they apply in one frame), under `ui.animationSpeed`. Pane layout +/// (splits, closes, zoom, divider release) is `ui.paneAnimations` +/// (`Motion.animatesPaneLayout`). +public nonisolated enum MotionKind: String, Sendable, CaseIterable, Codable { + /// A new tab growing into its slot (Cmd-T, +, New Terminal Tab, socket). + case newTab + /// A new workspace row opening in the sidebar (Cmd-N). + case newWorkspace +} + +nonisolated extension MotionSpring { + /// Structure: things that take or give up room. These follow the bounce + /// setting; release, scroll and overlay springs keep their own damping. + public var isStructural: Bool { + switch self { + case .move, .appear, .disappear: true + default: false + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionPolicy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionPolicy.swift index 02972d672037..604179016d19 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionPolicy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionPolicy.swift @@ -11,14 +11,24 @@ public import Foundation public nonisolated struct MotionPolicy: Sendable, Equatable { public var speed: MotionSpeed public var reduceMotion: Bool + /// `ui.animations.bounce`: structural springs bounce or not. + public var bounce: MotionBounce + /// The kinds turned on one by one (`ui.animations.*` true; off by default). + public var enabledKinds: Set - public init(speed: MotionSpeed, reduceMotion: Bool) { + public init(speed: MotionSpeed, reduceMotion: Bool, bounce: MotionBounce = .none, enabledKinds: Set = []) { self.speed = speed self.reduceMotion = reduceMotion + self.bounce = bounce + self.enabledKinds = enabledKinds } /// Position, size, scale and scroll animate. When false, snap. public var animatesMovement: Bool { speed != .off && !reduceMotion } + /// `kind` moves: movement animates and its switch is on. Off (the + /// default) means the change applies in one frame (Reduce Motion: no + /// crossfade either). + public func animates(_ kind: MotionKind) -> Bool { animatesMovement && enabledKinds.contains(kind) } /// Opacity and color changes animate. When false, apply at once. public var animatesFades: Bool { speed != .off } /// Repeating indicators (spinner, pulse) run. @@ -28,7 +38,13 @@ public nonisolated struct MotionPolicy: Sendable, Equatable { /// `animatesMovement` is false; `off` returns the `fast` spring so a /// caller that steps anyway never divides by zero. public func spring(_ token: MotionSpring) -> SpringParameters { - speed == .off ? token.base : token.base.scaled(by: speed.timeScale) + var base = token.base + if token.isStructural { + // Bounce none: critically damped (never past the target); subtle: SwiftUI bounce. + base = bounce == .subtle ? base.withBounce(MotionTunables.subtleBounce.value) + : SpringParameters(response: base.response, dampingFraction: max(base.dampingFraction, 1)) + } + return speed == .off ? base : base.scaled(by: speed.timeScale) } /// Seconds for `token`; 0 means apply at once. Under Reduce Motion a fade diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift index 9bd01a79b5bb..4015c661b7fe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift @@ -21,19 +21,18 @@ public nonisolated enum MotionSpeed: String, Sendable, CaseIterable, Codable { /// Spring tokens. Each one is a response (seconds for one undamped period) /// and a damping fraction (1 = critically damped), as in SwiftUI -/// `.spring(response:dampingFraction:)`. Damping 0.9 overshoots 0.05% (0.1 -/// pt on a 200 pt move, invisible) and ends visibly ~30% sooner than a -/// critically damped spring's long tail. Values and rationale are in -/// plans/cmux-next/motion.md; change them only there and here. +/// `.spring(response:dampingFraction:)`. The structural ones (`move`, +/// `appear`, `disappear`) are critically damped with a short response +/// (they never pass the target; `ui.animations.bounce` subtle opts into a +/// SwiftUI bounce). The values are the spec's design-tokens.json `motion` +/// (cmux-motion reads the same file; scripts/cmux-next/motion-parity.py +/// compares this port's paths with cmux-motion's). The cases are declared +/// in cmux-motion's `MotionSpring::ALL` order, the token index of its C ABI +/// (`cmux_motion_spring_base`): append new tokens on both sides. public nonisolated enum MotionSpring: String, Sendable, CaseIterable { /// An existing item moves or resizes: tab reflow and reorder, sidebar /// rows, pane and divider frames, pane unzoom, toolbar height. case move - /// A pane zooming to fill its screen: it owns the motion (Leo 2026-10-13). - case zoom - /// The panes a zoom leaves fade out, faster than anything arrives - /// (`Motion.exit(nearness:)`: the farthest first). - case exit /// Something appears or expands: tab grow-in, group expand, row insert, /// sidebar show. case appear @@ -56,6 +55,14 @@ public nonisolated enum MotionSpring: String, Sendable, CaseIterable { case selection /// Floating panel open: palette scale-in, hover card slide. case panel + /// Home list pin drag: tiles making room, rows below the grid, a dropped + /// tile landing (MessagesLab `SidebarPinDragging.spring`). + case pinDrag + /// A pane zooming to fill its screen: it owns the motion (Leo 2026-10-13). + case zoom + /// The panes a zoom leaves fade out, faster than anything arrives + /// (`Motion.exit(nearness:)`: the farthest first). + case exit /// Values at `MotionSpeed.fast`: the code default (`MotionTunables`), /// or its Debug Settings override in DEV and NIGHTLY builds. @@ -63,3 +70,4 @@ public nonisolated enum MotionSpring: String, Sendable, CaseIterable { MotionTunables.springs[self]?.value ?? SpringParameters(response: 0.2, dampingFraction: 0.9) } } + diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTrace.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTrace.swift index 3a8e35a9a8a1..8410fdc30e51 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTrace.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTrace.swift @@ -3,7 +3,11 @@ public import QuartzCore /// Measured animation spans for `debug.motion` (plans/cmux-next/motion.md). /// Off by default: every hook is one Bool check. While on, a span opens at /// the first start of a named animation and closes when it settles, so a -/// retargeted animation counts once, from first frame to rest. +/// retargeted animation counts once, from first frame to rest. Two +/// animators with one name (two tab strips) keep separate spans (`id`). +/// `sample` records one value per frame (the clock's dt, the travel a strip +/// or layout has left), so a bench reads the spring's path, not only its +/// length. public struct MotionTrace { public init() {} public struct Span: Sendable, Equatable { @@ -12,18 +16,33 @@ public struct MotionTrace { public var milliseconds: Double } + public struct Sample: Sendable, Equatable { + public var name: String + public var time: CFTimeInterval + public var value: Double + } + public private(set) static var isEnabled = false private static var open: [String: CFTimeInterval] = [:] public private(set) static var spans: [Span] = [] /// Main-thread work per frame of each animation `measure` times, in ms. public private(set) static var work: [String: [Double]] = [:] + public private(set) static var samples: [Sample] = [] + /// What an animator decided at a time (`note`), for a bench that must tell why something moved. + public private(set) static var notes: [(time: CFTimeInterval, text: String)] = [] + /// When recording started (sample times are reported from here). + public private(set) static var startedAt: CFTimeInterval = 0 static let capacity = 1024 + static let sampleCapacity = 8192 public static func start() { isEnabled = true open.removeAll() spans.removeAll() work.removeAll() + samples.removeAll() + notes.removeAll() + startedAt = CACurrentMediaTime() } public static func stop() { @@ -31,9 +50,10 @@ public struct MotionTrace { open.removeAll() } - public static func begin(_ name: String) { - guard isEnabled, open[name] == nil else { return } - open[name] = CACurrentMediaTime() + public static func begin(_ name: String, id: ObjectIdentifier? = nil) { + let key = key(name, id) + guard isEnabled, open[key] == nil else { return } + open[key] = CACurrentMediaTime() } /// Runs one frame of the animation `name`; while on, records how long it took. @@ -45,8 +65,24 @@ public struct MotionTrace { return result } - public static func end(_ name: String) { - guard isEnabled, let start = open.removeValue(forKey: name), spans.count < capacity else { return } + public static func end(_ name: String, id: ObjectIdentifier? = nil) { + guard isEnabled, let start = open.removeValue(forKey: key(name, id)), spans.count < capacity else { return } spans.append(Span(name: name, start: start, milliseconds: (CACurrentMediaTime() - start) * 1000)) } + + /// One value of `name` at this frame (only while recording). + public static func sample(_ name: String, _ value: Double) { + guard isEnabled, samples.count < sampleCapacity else { return } + samples.append(Sample(name: name, time: CACurrentMediaTime(), value: value)) + } + + /// One decision of an animator (only while recording; the text is built only then). + public static func note(_ text: @autoclosure () -> String) { + guard isEnabled, notes.count < capacity else { return } + notes.append((CACurrentMediaTime(), text())) + } + + private static func key(_ name: String, _ id: ObjectIdentifier?) -> String { + id.map { "\(name)#\($0.hashValue)" } ?? name + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTravelSummary.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTravelSummary.swift new file mode 100644 index 000000000000..02b388fe32fa --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTravelSummary.swift @@ -0,0 +1,31 @@ +#if DEBUG +public import QuartzCore + +/// How one animator's `MotionTrace` travel sample (`tabs.travel`: the points its springs have +/// left to move) ran after a time. Pixel shares cannot judge a moving tab (a cell sliding over +/// its neighbours is not monotonic in pixels); its own travel is: a spring only ever closes it. +public nonisolated struct MotionTravelSummary: Sendable, Equatable { + /// Frames with travel left (more than half a point). + public var movingFrames = 0 + /// Frames where the travel grew after it had started to fall: a second motion inside the + /// gesture (or a retarget away), which one motion per gesture never has. + public var restarts = 0 + /// The most travel any frame had left. + public var peak = 0.0 + + @MainActor public init(_ name: String, after time: CFTimeInterval) { + var previous = 0.0, falling = false + for sample in MotionTrace.samples where sample.name == name && sample.time >= time { + if sample.value > 0.5 { movingFrames += 1 } + peak = Swift.max(peak, sample.value) + // Falling from a real motion (a few points' tail of an earlier gesture is not one). + if peak > 4, sample.value < previous - 0.5 { falling = true } + if falling, sample.value > previous + Swift.max(2, peak * 0.05) { + restarts += 1 + falling = false + } + previous = sample.value + } + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift index 053f96985040..6e5a317ceb3a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift @@ -8,17 +8,21 @@ public nonisolated enum MotionTunables { // MARK: Springs (speed "fast") static let springDefaults: [MotionSpring: (SpringParameters, String)] = [ - .move: (SpringParameters(response: 0.2, dampingFraction: 0.9), "Tab reflow and reorder, sidebar row moves, pane and divider frames."), + // Structural (design-tokens.json motion.springs, motion.structural): critically damped by + // default, response short enough that a move ends visibly (within 0.5%) by 0.18 s (hq-1d D1). + .move: (SpringParameters(response: 0.15, dampingFraction: 1), "Tab reflow and reorder, sidebar row moves, pane and divider frames."), .zoom: (SpringParameters(response: 0.32, dampingFraction: 0.9), "A pane zooming to fill its screen."), .exit: (SpringParameters(response: 0.12, dampingFraction: 1), "The panes a zoom leaves fading out, the farthest first."), - .appear: (SpringParameters(response: 0.18, dampingFraction: 0.9), "Palette scale-in, tab grow-in, group expand, row insert, sidebar show."), - .disappear: (SpringParameters(response: 0.15, dampingFraction: 0.9), "Tab close, group collapse, row removal, sidebar hide."), + .appear: (SpringParameters(response: 0.135, dampingFraction: 1), "Palette scale-in, tab grow-in, group expand, row insert, sidebar show."), + .disappear: (SpringParameters(response: 0.11, dampingFraction: 1), "Tab close, group collapse, row removal, sidebar hide."), .settle: (SpringParameters(response: 0.22, dampingFraction: 0.85), "Release after a drag: tab drop, ghost landing, sidebar row drop."), .scroll: (SpringParameters(response: 0.22, dampingFraction: 0.9), "Tab strip reveal, strip column reveal, wheel notch, fling snap."), .screen: (SpringParameters(response: 0.22, dampingFraction: 0.9), "Screen switch slide."), .track: (SpringParameters(response: 0.12, dampingFraction: 0.9), "Drop-zone overlay and the drag ghost jumping between targets."), .selection: (SpringParameters(response: 0.15, dampingFraction: 0.9), "Page info toggle state change. The sidebar selection does not animate."), .panel: (SpringParameters(response: 0.18, dampingFraction: 0.85), "Hover card slide."), + .pinDrag: (SpringParameters(response: 0.3512407365520363, dampingFraction: 0.8385254915624211), + "Home list pin drag: tiles making room, rows below the grid, a dropped tile landing."), ] static let springs: [MotionSpring: Tunable] = Dictionary(uniqueKeysWithValues: MotionSpring.allCases.map { token in @@ -27,6 +31,22 @@ public nonisolated enum MotionTunables { default: entry.0, code: "MotionTunables.springDefaults[.\(token.rawValue)]")) }) + /// SwiftUI bounce of the structural springs (`move`, `appear`, + /// `disappear`) when `ui.animations.bounce` is "subtle". With "none" + /// (the default) they are critically damped and never pass the target. + public static let subtleBounce = Tunable.number( + "motion.spring.subtleBounce", .springs, "Subtle bounce", + help: "Bounce of tab, row, pane and divider springs when Animations Bounce is Subtle (0 is critically damped).", + default: 0.15, range: 0...0.5, step: 0.01, unit: .multiplier, code: "MotionTunables.subtleBounce") + + /// How far ahead a released divider's velocity is projected to find a + /// snap (halves, thirds) it is flung onto; it then springs there from + /// that velocity. + public static let dividerReleaseProjection = Tunable.number( + "motion.divider.releaseProjection", .springs, "Divider fling projection", + help: "Seconds of release velocity a divider looks ahead for a snap point.", + default: 0.05, range: 0...0.3, step: 0.01, unit: .seconds, code: "MotionTunables.dividerReleaseProjection") + // MARK: Fades and loops static let fadeDefaults: [MotionFade: (TimeInterval, String)] = [ @@ -106,7 +126,7 @@ public nonisolated enum MotionTunables { /// Every Motion tunable, for the catalog. public static var all: [TunableDescriptor] { - MotionSpring.allCases.compactMap { springs[$0]?.descriptor } + MotionSpring.allCases.compactMap { springs[$0]?.descriptor } + [subtleBounce.descriptor, dividerReleaseProjection.descriptor] + MotionFade.allCases.compactMap { fades[$0]?.descriptor } + MotionLoop.allCases.compactMap { loops[$0]?.descriptor } + [marqueeDelay, marqueeSpeed, marqueeMinimumScroll, marqueeHold, marqueeMinimumTravel, hoverCardDwell].map(\.descriptor) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift index 839787079aca..56c6a836b019 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift @@ -14,6 +14,24 @@ public nonisolated struct SpringParameters: Hashable, Sendable { self.dampingFraction = dampingFraction } + /// SwiftUI `Spring(duration:bounce:)`: `duration` is the perceptual + /// duration (the response), `bounce` 0 is critically damped, above 0 + /// overshoots (0.15 is a subtle bounce), below 0 is over-damped. + public init(duration: Double, bounce: Double) { + self.response = duration + self.dampingFraction = bounce >= 0 ? 1 - bounce : 1 / (1 + bounce) + } + + /// SwiftUI's bounce for this damping fraction (0 critically damped). + public var bounce: Double { + dampingFraction <= 1 ? 1 - dampingFraction : 1 / dampingFraction - 1 + } + + /// The same response with `bounce` (SwiftUI semantics). + public func withBounce(_ bounce: Double) -> SpringParameters { + SpringParameters(duration: response, bounce: bounce) + } + /// Angular frequency (rad/s). public var omega: Double { 2 * .pi / max(response, 0.001) } /// `CASpringAnimation.stiffness` for mass 1. @@ -73,26 +91,42 @@ public nonisolated struct SpringValue: Hashable, Sendable { self.target = value } - /// Advances by `dt` seconds using fixed substeps (semi-implicit Euler), - /// stable for every frame rate up to 120 Hz and beyond. `dt` is capped - /// at 0.1 s so a stalled frame never teleports. + /// Advances by `dt` seconds with the closed-form solution of the damped + /// harmonic oscillator (under-, critically or over-damped), so the path + /// is exact and identical at every frame rate: two 60 Hz frames land + /// where four 120 Hz frames do. `dt` is capped at 0.1 s so a stalled + /// frame never teleports. public mutating func step(_ dt: Double, parameters: SpringParameters) { - let stiffness = parameters.stiffness - let damping = parameters.damping - var remaining = min(max(dt, 0), 0.1) - let maxStep = 1.0 / 480.0 - var x = Double(value) - var v = Double(velocity) - let t = Double(target) - while remaining > 0 { - let h = min(remaining, maxStep) - let acceleration = -stiffness * (x - t) - damping * v - v += acceleration * h - x += v * h - remaining -= h + let t = min(max(dt, 0), 0.1) + guard t > 0 else { return } + let state = SpringValue.solve(displacement: Double(value - target), velocity: Double(velocity), time: t, parameters: parameters) + value = target + CGFloat(state.displacement) + velocity = CGFloat(state.velocity) + } + + /// Displacement from the target and velocity `time` seconds after a + /// state, for `parameters` (mass 1). + public static func solve(displacement x0: Double, velocity v0: Double, time t: Double, + parameters: SpringParameters) -> (displacement: Double, velocity: Double) { + let w0 = parameters.omega + let zeta = max(parameters.dampingFraction, 0) + if abs(zeta - 1) < 1e-4 { + let decay = exp(-w0 * t) + let b = v0 + w0 * x0 + return ((x0 + b * t) * decay, (v0 - w0 * b * t) * decay) + } + if zeta < 1 { + let wd = w0 * (1 - zeta * zeta).squareRoot() + let decay = exp(-zeta * w0 * t) + let c = cos(wd * t), s = sin(wd * t) + let b = (v0 + zeta * w0 * x0) / wd + return (decay * (x0 * c + b * s), decay * (v0 * c - (w0 * w0 * x0 + zeta * w0 * v0) / wd * s)) } - value = CGFloat(x) - velocity = CGFloat(v) + let root = (zeta * zeta - 1).squareRoot() + let r1 = -w0 * (zeta - root), r2 = -w0 * (zeta + root) + let c2 = (v0 - r1 * x0) / (r2 - r1), c1 = x0 - c2 + let e1 = exp(r1 * t), e2 = exp(r2 * t) + return (c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2) } public func isSettled(epsilon: CGFloat) -> Bool { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/DisplayLinkDriver.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/DisplayLinkDriver.swift index d4a74927ffc2..6f0e451d9edc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/DisplayLinkDriver.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/DisplayLinkDriver.swift @@ -1,42 +1,46 @@ import AppKit import CmuxNextDesign -import CmuxNextWakeups -/// Drives spring animations from the window's ``FrameScheduler`` (up to -/// 120 Hz on ProMotion). Goes idle whenever `onFrame` reports nothing is -/// moving, so the window's display link stops with the last animation. -final class DisplayLinkDriver { +/// Steps the layout's springs (panes, dividers, columns, screens, drop +/// zones) on the window's one `MotionClock`, the clock the tab strip and the +/// sidebar rows also step on (up to 120 Hz on ProMotion). Goes idle whenever +/// `onFrame` reports nothing is moving, so the window's display link stops +/// with the last animation. +final class DisplayLinkDriver: MotionAnimating { /// Called once per frame with the elapsed seconds. Return true to keep ticking. var onFrame: ((Double) -> Bool)? /// Tests are the clock: `start` asks for no display-link frames, and the test steps /// `LayoutRootView.advanceFrame` at a fixed dt instead. var isManual = false - private var client: FrameClient? + private weak var view: NSView? + private weak var clock: MotionClock? - var isAttached: Bool { client != nil } + var isAttached: Bool { view != nil } /// An animation is in flight. - var isRunning: Bool { client?.isActive ?? false } + var isRunning: Bool { clock?.isRunning(self) ?? false } func attach(to view: NSView) { detach() - client = FrameClient(owner: "Layout.animation", view: view) { [weak self] tick in - let keepGoing = MotionTrace.measure("layout") { self?.onFrame?(tick.elapsed) ?? false } - if !keepGoing { MotionTrace.end("layout") } - return keepGoing - } + self.view = view } /// Stops ticking; call when leaving the window. func detach() { - if client?.isActive == true { MotionTrace.end("layout") } - client?.deactivate() - client = nil + clock?.stop(self) + clock = nil + view = nil } func start() { - guard let client, !client.isActive, !isManual else { return } - MotionTrace.begin("layout") - client.activate() + guard let view, !isManual else { return } + let target = MotionClock.forView(view) + if let clock, clock !== target { clock.stop(self) } + clock = target + target.run(self, trace: "layout") + } + + func motionStep(_ dt: Double) -> Bool { + onFrame?(dt) ?? false } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift index 4e92ffdc4b2f..697529bea86b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift @@ -144,7 +144,7 @@ final class PaneHostView: NSView { /// Returns true when the clip changed: a Chromium page in this pane then /// re-reads its clip shape, which carries this mask (`CEFClipShape`). @discardableResult - func setStripClip(_ rect: CGRect?) -> Bool { + func setStripClip(_ rect: CGRect?, cornerRadius: CGFloat = 0) -> Bool { guard let layer else { return false } guard let rect else { guard layer.mask != nil else { return false } @@ -158,10 +158,11 @@ final class PaneHostView: NSView { layer.mask = mask return mask }() - guard mask.frame != rect else { return false } + guard mask.frame != rect || mask.cornerRadius != cornerRadius else { return false } CATransaction.begin() CATransaction.setDisableActions(true) mask.frame = rect + mask.cornerRadius = cornerRadius CATransaction.commit() return true } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+DividerDrag.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+DividerDrag.swift index c244a7fa7caa..2abbd35910f1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+DividerDrag.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+DividerDrag.swift @@ -1,4 +1,6 @@ import AppKit +import CmuxNextDesign +import QuartzCore /// Divider and column-edge drags, emitted as transactional layout intents. extension ScreenContentView { @@ -59,9 +61,14 @@ extension ScreenContentView { model.setGestureActive(true) context.requestFrames() case let .moved(windowPoint): + activeDrag?.sample(contentPoint(fromWindow: windowPoint, kind: kind), at: CACurrentMediaTime()) applyDrag(at: windowPoint, phase: .changed) context.requestFrames() case let .ended(windowPoint): + if case .split = kind, let drag = activeDrag { + // The layout this release makes springs on from the pointer's velocity. + releasedDivider = (kind, drag.releaseVelocity(at: CACurrentMediaTime()), CACurrentMediaTime()) + } applyDrag(at: windowPoint, phase: .ended) activeDrag = nil model.setGestureActive(false) @@ -84,7 +91,13 @@ extension ScreenContentView { let style = context.style switch drag.kind { case let .split(id): - guard let ratio = activeDrag?.splitRatio(at: point, style: style, phase: phase) else { return } + // A fling that would carry the divider onto a snap within the spring's reach lands there. + let reach = phase == .ended && context.animatesPaneLayout ? (releasedDivider?.velocity ?? 0) * CGFloat(Motion.dividerReleaseProjection) : 0 + let projected = drag.axis == .horizontal ? CGPoint(x: point.x + reach, y: point.y) : CGPoint(x: point.x, y: point.y + reach) + var probe = drag + let flung = reach != 0 ? probe.splitRatio(at: projected, style: style, phase: phase) : nil + guard let ratio = flung.flatMap({ drag.snaps.contains($0) ? $0 : nil }) ?? activeDrag?.splitRatio(at: point, style: style, phase: phase) + else { return } context.model.setSplitRatio(id, ratio: ratio, transaction: drag.transaction, phase: phase) case let .columnEdge(id): // Strip widths are shares of the strip's viewport; a side dock's diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift index 2a5076a330bd..a5ff669325d3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift @@ -71,9 +71,11 @@ extension ScreenContentView { var visible = revealing.map(host.frame.intersection) ?? host.frame if scrolls, let clip { visible = visible.intersection(clip) } for cover in panes(above: host) { visible = visible.remainder(outside: cover) } + // A split's presented rect is the content itself: rounded as at rest. let changed = visible == host.frame ? host.setStripClip(nil) - : host.setStripClip(visible.isNull ? .zero : visible.offsetBy(dx: -host.frame.minX, dy: -host.frame.minY)) + : host.setStripClip(visible.isNull ? .zero : visible.offsetBy(dx: -host.frame.minX, dy: -host.frame.minY), + cornerRadius: revealing == nil ? 0 : host.cornerRadius) // A clip can change while the pane stays put (a dock growing over it): the window's // Chromium pages re-read their clip shapes, which carry this mask. if changed { context.stripClipsDidChange() } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Visibility.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Visibility.swift new file mode 100644 index 000000000000..7b5cc2e8f8ad --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Visibility.swift @@ -0,0 +1,91 @@ +import AppKit +import CmuxNextDesign + +// Pane chrome (focus ring, dim, attention), visibility and hit testing, and +// the travel a split has left (debug.motion), split out of +// ScreenContentView.swift to keep it under its size budget. +extension ScreenContentView { + // MARK: Chrome + + /// Focus ring, inactive dim and attention rings. Overlay-only: no pane + /// frame or inset depends on any of it. + func updateChrome(focused: PaneID?, dimsInactive: Bool, attention: [PaneID: AttentionMark], animated: Bool) { + let multiple = paneFrames.count > 1 + let style = context.style + let ringAllowed = (multiple || style.focusRing.showsForSinglePane) + && style.focusIndicator.marksBorder + // Pointer and scroll focus should not leave a keyboard-only focus ring behind. + && context.model.lastFocusSource != .pointer + && context.model.lastFocusSource != .scroll + let dim = style.inactiveDim(dimsInactive: dimsInactive) + for pane in paneFrames.keys { + guard let host = context.hosts[pane] else { continue } + let isFocused = pane == focused + host.setChrome( + showsRing: ringAllowed && isFocused, + dim: multiple && !isFocused ? dim.amount : 0, dimFill: dim.fill, + focusRing: style.focusRing, + ringAlphaOverride: style.focusRingAlphaOverride, + tabEmphasis: .forPane(isFocused: isFocused, paneCount: paneFrames.count, indicator: style.focusIndicator, + style: style.inactiveTabStyle, strength: style.inactiveTabStrength), + border: PaneOverlayView.Border(shows: style.showsPaneBorder, width: style.paneBorderWidth, color: style.paneBorderColor), + attention: attention[pane], + attentionSettings: style.attention, + animated: animated + ) + } + } + + // MARK: Visibility and hit testing + + /// Panes whose displayed frame intersects the viewport. + func visiblePanes() -> Set { + var result: Set = [] + let uncovered = uncoveredRect + for (pane, frame) in paneFrames { + // A strip pane wholly under a docked column is hidden. + let scrolls = geometry.scrolls(pane: pane) + let displayed = displayedRect(frame.rect, pane: pane) + let overlap = displayed.intersection(scrolls ? uncovered : bounds) + if !overlap.isNull, overlap.width > 0.5, overlap.height > 0.5, frame.alpha.value > 0.01 { + result.insert(pane) + } + } + return result + } + + /// Panes whose displayed frame lies within one viewport width of the + /// viewport on either side (architecture.md 4): visible panes plus the + /// off-screen columns a short scroll brings in. Their content stays + /// alive, paused, so scrolling back shows it at once. + func keepAlivePanes() -> Set { + KeepAliveBand.panes(displayed: paneFrames.reduce(into: [:]) { $0[$1.key] = displayedRect($1.value.rect, pane: $1.key) }, + viewport: bounds) + } + + /// The pane under `localPoint`: a docked column's pane above the strip. + func pane(at localPoint: NSPoint) -> PaneID? { + if geometry.dock.contains(where: { $0.cover.contains(localPoint) }) { + return geometry.panes.first { !geometry.scrolls(pane: $0.key) && $0.value.contains(localPoint) }?.key + } + let content = CGPoint(x: localPoint.x - stripShift, y: localPoint.y) + return geometry.panes.first { geometry.scrolls(pane: $0.key) && $0.value.contains(content) }?.key + } + + /// Displayed frame of `pane` in local coordinates. + func displayedFrame(of pane: PaneID) -> CGRect? { + paneFrames[pane].map { displayedRect($0.rect, pane: pane) } + } + + // MARK: Motion trace + + /// The points every pane and divider still has to travel (all four + /// edges): the layout spring's path, sampled per frame for debug.motion. + var travelLeft: CGFloat { + func left(_ frame: AnimatedFrame) -> CGFloat { + abs(frame.x.target - frame.x.value) + abs(frame.y.target - frame.y.value) + + abs(frame.width.target - frame.width.value) + abs(frame.height.target - frame.height.value) + } + return paneFrames.values.reduce(0) { $0 + left($1) } + dividerFrames.values.reduce(0) { $0 + left($1) } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView.swift index aeee063ef54e..0353ceb1eca9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView.swift @@ -21,14 +21,21 @@ final class ScreenContentView: NSView { /// `baseGeometry` at the presented row offsets (`shiftingRows`): what /// hit testing, drops, focus and the strip scroll read. var geometry: ScreenGeometry - private var paneFrames: [PaneID: AnimatedFrame] = [:] + private(set) var paneFrames: [PaneID: AnimatedFrame] = [:] private(set) var dividerViews: [DividerHandleView.Kind: DividerHandleView] = [:] - private var dividerFrames: [DividerHandleView.Kind: AnimatedFrame] = [:] + private(set) var dividerFrames: [DividerHandleView.Kind: AnimatedFrame] = [:] /// Panes growing in from a split's edge (`PaneArrival.presentation`), and the dock columns that change added. private var arriving: Set = [], newDockColumns: Set = [] /// A zoom or unzoom: the pane that owns the motion, and the panes a zoom leaves fading out under it. private(set) var zoomMotion = PaneZoomMotion() var covered: Set { Set(zoomMotion.leaving.keys) } + /// Split motion moves layers only (DESIGN P4): while a split or a close springs, each pane that + /// changes size keeps one host frame (`SplitMotion.heldFrame`), so its terminal or page lays + /// out once, and is clipped to the frame the spring presents. + private var heldFrames: [PaneID: CGRect] = [:] + /// A divider released with this pointer velocity (pt/s along its axis): the next layout's + /// spring starts from it (`SplitMotion.seed`). + var releasedDivider: (kind: DividerHandleView.Kind, velocity: CGFloat, time: CFTimeInterval)? /// Column scroll rules and state (`ColumnScrollState.reduce`). var scrollState = ColumnScrollState() @@ -126,14 +133,22 @@ final class ScreenContentView: NSView { baseGeometry = ScreenGeometry.compute(shown, viewport: bounds.size, style: context.style, scale: scale) geometry = baseGeometry.shiftingRows(rowOffsets) let animate = animated && !context.reduceMotion && bounds.width > 0 - let arrivals = animate && structural && !unzooming + // `ui.paneAnimations` (default off): a split, a close and a released divider spring only with it on. + let splits = animate && context.animatesPaneLayout + let arrivals = splits && structural && !unzooming ? PaneArrival.split(paneFrames.mapValues(\.targetRect), from: before, to: baseGeometry, offset: scroll.value) : [:] - let departing = animate && structural && arrivals.isEmpty && PaneArrival.departing(paneFrames.keys, to: baseGeometry.panes.keys) + let departing = (zooming ? animate : splits) && structural && arrivals.isEmpty + && PaneArrival.departing(paneFrames.keys, to: baseGeometry.panes.keys) // An unzoom: the other panes come back in place under the zoomed one, which springs back to its slot. let revealing = animate && structural && unzooming // A zoom: the other panes fade out under the zoomed one (`PaneZoomMotion`), rather than leaving a hole. let covering = animate && structural && zooming if structural { zoomMotion.begin(covering ? baseGeometry.panes.keys.first : revealing ? paneFrames.keys.first { baseGeometry.panes[$0] != nil } : nil, zooming: covering, frames: paneFrames, to: baseGeometry) } + // A split still springing when the next structural update lands (the daemon's echo of the + // split moves the tab into the new pane ~75 ms in) keeps springing from where it shows, + // with its velocity, instead of snapping to the end (hq-1d: Cmd-D measured 4 frames). + let inFlight = splits && structural && arrivals.isEmpty && !departing + && paneFrames.values.contains { $0.rect != $0.targetRect } let carried = !arrivals.isEmpty || departing || revealing // Strip springs run in strip coordinates and fixed ones in the view's: a moved strip origin (a dock // that came or went) or a pane changing sides (a zoom shows its pane fixed) keeps it where it showed. @@ -141,10 +156,12 @@ final class ScreenContentView: NSView { func shift(_ geometry: ScreenGeometry, _ pane: PaneID) -> CGFloat { geometry.scrolls(pane: pane) ? geometry.viewShift(offset: offset) : 0 } let drift = carried ? before.viewShift(offset: offset) - baseGeometry.viewShift(offset: offset) : 0 (carriedShown, carriedDividers) = ([:], [:]) - let animateFrames = animate && (!structural || carried) + // A release seeds only the layout it made (`releasedDivider` from this gesture, not a stale one). + if let released = releasedDivider, CACurrentMediaTime() - released.time > 0.5 { releasedDivider = nil } + let animateFrames = animate && (!structural || carried || inFlight) && (releasedDivider == nil || splits) // Under the zoomed pane, which every pane coming back waits beneath. let revealAnchor = revealing ? paneFrames.keys.compactMap { context.hosts[$0] }.first { $0.superview === self } : nil - if structural { (arriving, newDockColumns) = (Set(arrivals.keys), PaneArrival.newDocks(arrivals, from: before, to: baseGeometry)) } + if structural, !inFlight { (arriving, newDockColumns) = (Set(arrivals.keys), PaneArrival.newDocks(arrivals, from: before, to: baseGeometry)) } // Panes. let style = context.style @@ -156,6 +173,7 @@ final class ScreenContentView: NSView { } existing.setTarget(target, alpha: 1) if !animateFrames { existing.snap() } + heldFrames[pane] = animateFrames ? SplitMotion.heldFrame(shown: existing.rect, target: target) : nil paneFrames[pane] = existing } else { let host = context.host(for: pane) @@ -171,6 +189,7 @@ final class ScreenContentView: NSView { } for pane in paneFrames.keys where baseGeometry.panes[pane] == nil { let frame = paneFrames.removeValue(forKey: pane) + heldFrames[pane] = nil if covering, context.hosts[pane]?.superview === self, let frame { zoomMotion.leave(pane, frame) continue @@ -226,6 +245,14 @@ final class ScreenContentView: NSView { dividerFrames[kind] = nil } + if let released = releasedDivider { + releasedDivider = nil + if animateFrames, splits, let divider = dividerFrames[released.kind] { + SplitMotion.seed(velocity: released.velocity, along: dividerViews[released.kind]?.axis ?? .horizontal, divider: divider, + panes: &paneFrames, dividers: ÷rFrames) + } + } + reconcileDock() // The scroll follows in `syncScroll`, which the root calls with the // focus after every update (ColumnScrollState.reduce). @@ -286,6 +313,7 @@ final class ScreenContentView: NSView { if dividerFrames[key]?.advance(dt, parameters: Motion.spring(.move)) == true { moving = true } } if stepRows(dt) { moving = true } + if MotionTrace.isEnabled { MotionTrace.sample("layout.travel", Double(travelLeft)) } if !isUserScrolling { if scroll.advance(dt, parameters: Motion.spring(.scroll), epsilon: 0.25) { moving = true @@ -311,9 +339,18 @@ final class ScreenContentView: NSView { let scrolls = geometry.scrolls(pane: pane) let shift = CGVector(dx: scrolls ? strip : 0, dy: -rowOffset(of: pane)) if arriving.contains(pane), frame.rect == frame.targetRect { arriving.remove(pane) } - let presented = zoomMotion.presentation(of: pane, frame) ?? (arriving.contains(pane) ? PaneArrival.presentation(shown: frame.rect, target: frame.targetRect) : nil) - let revealing = presented.map { _ in frame.rect.offsetBy(dx: shift.dx, dy: shift.dy) } - host.frame = (presented ?? frame.rect).offsetBy(dx: shift.dx, dy: shift.dy) + if heldFrames[pane] != nil, frame.rect == frame.targetRect { heldFrames[pane] = nil } + let held = heldFrames[pane] + // The zooming pane keeps its full size under its spring rect (`PaneZoomMotion`); an arriving + // pane slides in at its final size; a held one keeps its frame. Each shows only the presented + // rect (a split's: the padded content, rounded as at rest). + let zoomed = zoomMotion.presentation(of: pane, frame) + let revealing = zoomed != nil ? frame.rect.offsetBy(dx: shift.dx, dy: shift.dy) + : arriving.contains(pane) || held != nil + ? SplitMotion.visible(frame.rect, padding: host.padding).offsetBy(dx: shift.dx, dy: shift.dy) : nil + let placed = (zoomed ?? held ?? (arriving.contains(pane) ? PaneArrival.presentation(shown: frame.rect, target: frame.targetRect) : frame.rect)) + .offsetBy(dx: shift.dx, dy: shift.dy) + if host.frame != placed { host.frame = placed } (host.alphaValue, host.isDocked, host.revealRect) = (frame.alpha.value, !scrolls, revealing) clips.append((host, scrolls, revealing)) } @@ -349,78 +386,6 @@ final class ScreenContentView: NSView { var dividerMouseAreas: [LayoutMouseArea] { ScreenDividers(screen: self).mouseAreas } var dividerLineRects: [CGRect] { ScreenDividers(screen: self).lineRects } - // MARK: Chrome - - /// Focus ring, inactive dim and attention rings. Overlay-only: no pane - /// frame or inset depends on any of it. - func updateChrome(focused: PaneID?, dimsInactive: Bool, attention: [PaneID: AttentionMark], animated: Bool) { - let multiple = paneFrames.count > 1 - let style = context.style - let ringAllowed = (multiple || style.focusRing.showsForSinglePane) - && style.focusIndicator.marksBorder - // Pointer and scroll focus should not leave a keyboard-only focus ring behind. - && context.model.lastFocusSource != .pointer - && context.model.lastFocusSource != .scroll - let dim = style.inactiveDim(dimsInactive: dimsInactive) - for pane in paneFrames.keys { - guard let host = context.hosts[pane] else { continue } - let isFocused = pane == focused - host.setChrome( - showsRing: ringAllowed && isFocused, - dim: multiple && !isFocused ? dim.amount : 0, dimFill: dim.fill, - focusRing: style.focusRing, - ringAlphaOverride: style.focusRingAlphaOverride, - tabEmphasis: .forPane(isFocused: isFocused, paneCount: paneFrames.count, indicator: style.focusIndicator, - style: style.inactiveTabStyle, strength: style.inactiveTabStrength), - border: PaneOverlayView.Border(shows: style.showsPaneBorder, width: style.paneBorderWidth, color: style.paneBorderColor), - attention: attention[pane], - attentionSettings: style.attention, - animated: animated - ) - } - } - - // MARK: Visibility and hit testing - - /// Panes whose displayed frame intersects the viewport. - func visiblePanes() -> Set { - var result: Set = [] - let uncovered = uncoveredRect - for (pane, frame) in paneFrames { - // A strip pane wholly under a docked column is hidden. - let scrolls = geometry.scrolls(pane: pane) - let displayed = displayedRect(frame.rect, pane: pane) - let overlap = displayed.intersection(scrolls ? uncovered : bounds) - if !overlap.isNull, overlap.width > 0.5, overlap.height > 0.5, frame.alpha.value > 0.01 { - result.insert(pane) - } - } - return result - } - - /// Panes whose displayed frame lies within one viewport width of the - /// viewport on either side (architecture.md 4): visible panes plus the - /// off-screen columns a short scroll brings in. Their content stays - /// alive, paused, so scrolling back shows it at once. - func keepAlivePanes() -> Set { - KeepAliveBand.panes(displayed: paneFrames.reduce(into: [:]) { $0[$1.key] = displayedRect($1.value.rect, pane: $1.key) }, - viewport: bounds) - } - - /// The pane under `localPoint`: a docked column's pane above the strip. - func pane(at localPoint: NSPoint) -> PaneID? { - if geometry.dock.contains(where: { $0.cover.contains(localPoint) }) { - return geometry.panes.first { !geometry.scrolls(pane: $0.key) && $0.value.contains(localPoint) }?.key - } - let content = CGPoint(x: localPoint.x - stripShift, y: localPoint.y) - return geometry.panes.first { geometry.scrolls(pane: $0.key) && $0.value.contains(content) }?.key - } - - /// Displayed frame of `pane` in local coordinates. - func displayedFrame(of pane: PaneID) -> CGRect? { - paneFrames[pane].map { displayedRect($0.rect, pane: pane) } - } - /// Releases every hosted pane that is not live elsewhere (screen removed). func tearDown() { // A drag on a screen that goes away gets no release either. @@ -429,6 +394,7 @@ final class ScreenContentView: NSView { context.release(pane) } paneFrames.removeAll() + heldFrames.removeAll() releaseCovered(all: true) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenDividers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenDividers.swift index 64f0808144d8..673d5261e2f9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenDividers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenDividers.swift @@ -88,6 +88,26 @@ struct DividerDragState { /// (cx-piioc), and the one the divider sits on now. var snaps: [Double] = [] var snapped: Double? + /// The pointer along `axis` (content space) and its velocity in pt/s, + /// lightly smoothed, for the release spring (`SplitMotion.seed`). + var pointer: (position: CGFloat, time: CFTimeInterval)? + var velocity: CGFloat = 0 + + /// Notes the pointer at `point`; a pointer still for 50 ms has no velocity. + mutating func sample(_ point: CGPoint, at time: CFTimeInterval) { + let position = axis == .horizontal ? point.x : point.y + if let last = pointer, time > last.time { + let instant = (position - last.position) / CGFloat(time - last.time) + velocity = time - last.time > 0.05 ? instant : velocity * 0.4 + instant * 0.6 + } + pointer = (position, time) + } + + /// Velocity at release: zero when the pointer stopped before letting go. + func releaseVelocity(at time: CFTimeInterval) -> CGFloat { + guard let pointer, time - pointer.time <= 0.05 else { return 0 } + return velocity + } /// How close, in points, a split divider comes to a snap before it lands there. static let snapDistance: CGFloat = 8 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/SplitMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/SplitMotion.swift new file mode 100644 index 000000000000..4e047aa33665 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/SplitMotion.swift @@ -0,0 +1,66 @@ +import CmuxNextDesign +import CoreGraphics + +/// The physics of a split (DESIGN P3, P4; plans/cmux-next/motion.md). Cmd-D, +/// Cmd-Shift-D, closing a split and a released divider move every pane edge +/// and divider of the change on the `move` spring, all started together from +/// rest (or from one shared release velocity, `seed`). The spring equation +/// is linear, so each coordinate then covers the same fraction of its travel +/// at every instant: the frames are one spring on the divider fraction, read +/// through each pane's start and end. The new pane grows from the split edge +/// while the other shrinks, and the divider moves once. +/// +/// Content never animates: a pane whose size changes keeps one host frame +/// for the whole motion (`heldFrame`) and is clipped to the presented rect, +/// so its terminal reflows (or its page lays out) once, not per frame. +struct SplitMotion { + /// The host frame a pane keeps while its frame springs from `shown` to + /// `target`: the target when the pane grows (lay out once, now, and be + /// revealed), the frame it shows when it shrinks (keep the last layout, + /// clipped, and lay out once at rest). Nil for a pure move or a resize + /// that neither contains the other: those keep following the spring. + static func heldFrame(shown: CGRect, target: CGRect) -> CGRect? { + guard shown.size != target.size else { return nil } + let slack: CGFloat = 0.5 + if target.insetBy(dx: -slack, dy: -slack).contains(shown) { return target } + if shown.insetBy(dx: -slack, dy: -slack).contains(target) { return shown } + return nil + } + + /// The part of a pane cell the person sees: inside the pane padding, as + /// the content at rest. + static func visible(_ cell: CGRect, padding: CGFloat) -> CGRect { + let inset = min(padding, cell.width / 2, cell.height / 2) + return cell.insetBy(dx: max(0, inset), dy: max(0, inset)) + } + + /// A divider released while moving at `velocity` (pt/s along `axis`): + /// the spring toward the snapped layout starts with that velocity. In + /// the spring's own unit (the fraction of the travel left) the divider's + /// velocity is `velocity / travel`; every moving pane and divider gets + /// the same fraction, so the whole change stays one spring. A release + /// moving away from the target, or with nothing left to travel, starts + /// from rest. + static func seed(velocity: CGFloat, along axis: SplitAxis, divider: AnimatedFrame, + panes: inout [PaneID: AnimatedFrame], dividers: inout [DividerHandleView.Kind: AnimatedFrame]) { + let travel = axis == .horizontal ? divider.x.target - divider.x.value : divider.y.target - divider.y.value + guard abs(travel) > 0.5 else { return } + let fraction = velocity / travel + guard fraction > 0 else { return } + // A fling faster than the spring could start (10 travels a second) is capped there. + let rate = min(fraction, 10) + for key in Array(panes.keys) { panes[key]?.seed(fractionVelocity: rate) } + for key in Array(dividers.keys) { dividers[key]?.seed(fractionVelocity: rate) } + } +} + +extension AnimatedFrame { + /// Gives every component still short of its target the velocity of + /// `rate` (fractions of its remaining travel per second). + mutating func seed(fractionVelocity rate: CGFloat) { + x.velocity = (x.target - x.value) * rate + y.velocity = (y.target - y.value) * rate + width.velocity = (width.target - width.value) * rate + height.velocity = (height.target - height.value) * rate + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift new file mode 100644 index 000000000000..6844362ab5f4 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift @@ -0,0 +1,50 @@ +public import CmuxNextDesign + +/// `ui.animations.{newTab,newWorkspace}` (bool, default false: the change +/// applies in one frame; true opts into its spring) and +/// `ui.animations.bounce` ("none" or "subtle") in cmux.json +/// (plans/cmux-next/motion.md). They apply under `ui.animationSpeed`: +/// "off" or Reduce Motion stops every kind whatever these say. Pane layout +/// is `ui.paneAnimations` (`PaneAnimationsSetting`). +public nonisolated struct AnimationKindsSetting: Sendable, Equatable { + /// Kinds whose `ui.animations.` is true. + public var enabled: Set = [] + /// `ui.animations.bounce`. + public var bounce: MotionBounce = AnimationKindsSetting.bounceFallback + + public init(enabled: Set = [], bounce: MotionBounce = AnimationKindsSetting.bounceFallback) { + self.enabled = enabled + self.bounce = bounce + } + + public static let bouncePath = ["ui", "animations", "bounce"] + public static let bounceFallback: MotionBounce = .none + + /// `ui.animations.`. + public static func path(_ kind: MotionKind) -> [String] { ["ui", "animations", kind.rawValue] } + + /// The kinds turned on and the bounce. A missing key is its default + /// with no diagnostic; a bad value is the default plus a diagnostic. + static func parse(_ root: JSONValue) -> (AnimationKindsSetting, [SettingsDiagnostic]) { + var enabled: Set = [] + var diagnostics: [SettingsDiagnostic] = [] + for kind in MotionKind.allCases { + guard let value = root.value(at: path(kind)) else { continue } + guard let on = value.boolValue else { + diagnostics.append(SettingsDiagnostic(kind: .invalidValue, path: path(kind).joined(separator: "."), message: "expected true or false")) + continue + } + if on { enabled.insert(kind) } + } + var bounce = bounceFallback + if let value = root.value(at: bouncePath) { + if let text = value.stringValue, let parsed = MotionBounce(rawValue: text) { + bounce = parsed + } else { + let choices = MotionBounce.allCases.map { "\"\($0.rawValue)\"" }.joined(separator: ", ") + diagnostics.append(SettingsDiagnostic(kind: .invalidValue, path: "ui.animations.bounce", message: "expected one of \(choices)")) + } + } + return (AnimationKindsSetting(enabled: enabled, bounce: bounce), diagnostics) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift index d406c8b1e7c5..ba3a67bd36fa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift @@ -54,6 +54,7 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { public var remoteLocalhost: RemoteLocalhostSetting = .fallback /// `ui.animationSpeed`; "fast" when unset or invalid. public var animationSpeed: MotionSpeed = AnimationSpeedSetting.fallback + public var animationKinds = AnimationKindsSetting() /// `ui.paneAnimations`; off when unset or invalid. public var paneAnimations: Bool = PaneAnimationsSetting.fallback /// `layout.centerFocusedColumn`; "never" when unset or invalid. @@ -229,6 +230,7 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { let (speed, speedDiagnostic) = AnimationSpeedSetting.parse(root) snapshot.animationSpeed = speed if let speedDiagnostic { snapshot.diagnostics.append(speedDiagnostic) } + snapshot.take(AnimationKindsSetting.parse(root), \.animationKinds) snapshot.take(PaneAnimationsSetting.parse(root), \.paneAnimations) let (centering, centeringDiagnostic) = CenterFocusedColumnSetting.parse(root) snapshot.centerFocusedColumn = centering diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings index b86a58158cc9..44f96fa221a7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings @@ -91428,6 +91428,792 @@ } } } + }, + "settings.ui.animations.newTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Animate New Tabs" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいタブをアニメーション" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تحريك علامات التبويب الجديدة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Animiraj nove kartice" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Animer nye faner" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Neue Tabs animieren" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Animar pestañas nuevas" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Animer les nouveaux onglets" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Anima le nuove schede" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ធ្វើចលនាផ្ទាំងថ្មី" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 탭 애니메이션" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Animer nye faner" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Animuj nowe karty" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Animar novas abas" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Анимировать новые вкладки" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เคลื่อนไหวแท็บใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni sekmeleri canlandır" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Анімувати нові вкладки" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Tạo hiệu ứng tab mới" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "动画显示新标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "動畫顯示新分頁" + } + } + } + }, + "settings.ui.animations.newTab.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A new tab grows into its place in the tab bar. Off shows it there at once." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいタブはタブバーの位置で広がります。オフにするとすぐに表示されます。" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تنمو علامة التبويب الجديدة في مكانها في شريط علامات التبويب. عند الإيقاف تظهر فورًا." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Nova kartica raste na svom mjestu u traci kartica. Isključeno je odmah prikazuje." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "En ny fane vokser frem på sin plads i fanelinjen. Fra viser den der med det samme." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Ein neuer Tab wächst an seinem Platz in der Tableiste. Aus zeigt ihn dort sofort." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Una pestaña nueva crece en su lugar de la barra de pestañas. Desactivado la muestra al instante." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Un nouvel onglet s’ouvre à sa place dans la barre d’onglets. Désactivé l’affiche aussitôt." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Una nuova scheda cresce al suo posto nella barra delle schede. Disattivato la mostra subito." + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ផ្ទាំងថ្មីលូតលាស់នៅកន្លែងរបស់វាក្នុងរបារផ្ទាំង។ បិទ បង្ហាញវាភ្លាមៗ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 탭은 탭 막대의 자기 자리에서 자라납니다. 끄면 즉시 표시됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "En ny fane vokser inn på plassen sin i fanelinjen. Av viser den der med en gang." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nowa karta wyrasta na swoim miejscu na pasku kart. Wyłączone pokazuje ją od razu." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Uma nova aba cresce no seu lugar na barra de abas. Desativado a mostra na hora." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Новая вкладка вырастает на своём месте в панели вкладок. Выключено показывает её сразу." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "แท็บใหม่จะขยายในตำแหน่งของมันบนแถบแท็บ ปิดจะแสดงทันที" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni sekme sekme çubuğundaki yerinde büyür. Kapalı olduğunda hemen gösterilir." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Нова вкладка виростає на своєму місці на панелі вкладок. Вимкнено показує її одразу." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Tab mới mở ra tại vị trí của nó trên thanh tab. Tắt sẽ hiện ngay." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "新标签页在标签栏中的位置展开。关闭后立即显示。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "新分頁在分頁列中的位置展開。關閉後立即顯示。" + } + } + } + }, + "settings.ui.animations.newWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Animate New Workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいワークスペースをアニメーション" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تحريك مساحات العمل الجديدة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Animiraj nove radne prostore" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Animer nye arbejdsområder" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Neue Arbeitsbereiche animieren" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Animar espacios de trabajo nuevos" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Animer les nouveaux espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Anima i nuovi spazi di lavoro" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ធ្វើចលនាកន្លែងធ្វើការថ្មី" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 작업 공간 애니메이션" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Animer nye arbeidsområder" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Animuj nowe obszary robocze" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Animar novos espaços de trabalho" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Анимировать новые рабочие пространства" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เคลื่อนไหวพื้นที่ทำงานใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni çalışma alanlarını canlandır" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Анімувати нові робочі простори" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Tạo hiệu ứng không gian làm việc mới" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "动画显示新工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "動畫顯示新工作區" + } + } + } + }, + "settings.ui.animations.newWorkspace.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A new workspace row opens in the sidebar. Off shows it there at once." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいワークスペースの行がサイドバーで開きます。オフにするとすぐに表示されます。" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "ينفتح صف مساحة العمل الجديدة في الشريط الجانبي. عند الإيقاف يظهر فورًا." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Novi red radnog prostora otvara se u bočnoj traci. Isključeno ga odmah prikazuje." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "En ny række for arbejdsområdet åbner i sidepanelet. Fra viser den der med det samme." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Eine neue Arbeitsbereichszeile öffnet sich in der Seitenleiste. Aus zeigt sie dort sofort." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Una fila de espacio de trabajo nueva se abre en la barra lateral. Desactivado la muestra al instante." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Une nouvelle ligne d’espace de travail s’ouvre dans la barre latérale. Désactivé l’affiche aussitôt." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Una nuova riga dello spazio di lavoro si apre nella barra laterale. Disattivato la mostra subito." + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ជួរកន្លែងធ្វើការថ្មីបើកនៅក្នុងរបារចំហៀង។ បិទ បង្ហាញវាភ្លាមៗ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 작업 공간 행이 사이드바에서 열립니다. 끄면 즉시 표시됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "En ny rad for arbeidsområdet åpner seg i sidepanelet. Av viser den der med en gang." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nowy wiersz obszaru roboczego otwiera się na pasku bocznym. Wyłączone pokazuje go od razu." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Uma nova linha de espaço de trabalho se abre na barra lateral. Desativado a mostra na hora." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Новая строка рабочего пространства раскрывается на боковой панели. Выключено показывает её сразу." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "แถวพื้นที่ทำงานใหม่จะเปิดในแถบด้านข้าง ปิดจะแสดงทันที" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni çalışma alanı satırı kenar çubuğunda açılır. Kapalı olduğunda hemen gösterilir." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Новий рядок робочого простору розкривається на бічній панелі. Вимкнено показує його одразу." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Hàng không gian làm việc mới mở ra trong thanh bên. Tắt sẽ hiện ngay." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "新工作区行在侧边栏中展开。关闭后立即显示。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "新工作區列在側邊欄中展開。關閉後立即顯示。" + } + } + } + }, + "settings.ui.animations.bounce": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Animation Bounce" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "アニメーションの弾み" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "ارتداد الحركة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Odskok animacije" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Animationsfjedring" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Animationsfederung" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Rebote de animación" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Rebond des animations" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Rimbalzo delle animazioni" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ការលោតនៃចលនា" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "애니메이션 바운스" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Animasjonsfjæring" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Odbicie animacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Quique das animações" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Пружинистость анимации" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "การเด้งของภาพเคลื่อนไหว" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Animasyon sekmesi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Пружність анімації" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Độ nảy của hiệu ứng" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "动画弹跳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "動畫彈跳" + } + } + } + }, + "settings.ui.animations.bounce.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "なしではタブ、行、ペインがその位置でぴたりと止まります。控えめでは小さく弾んで落ち着きます。" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لا شيء يوقف علامات التبويب والصفوف والأجزاء في مكانها تمامًا. خفيف يتركها تستقر بارتداد صغير." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Ništa zaustavlja kartice, redove i okna tačno na mjestu. Suptilno ih pušta da se smire uz mali odskok." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Ingen stopper faner, rækker og ruder præcis på deres plads. Diskret lader dem falde på plads med et lille fjed." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Keine hält Tabs, Zeilen und Bereiche genau an ihrem Platz an. Dezent lässt sie mit einem kleinen Federn einrasten." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Ninguno detiene pestañas, filas y paneles justo en su sitio. Sutil los deja asentarse con un pequeño rebote." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Aucun arrête onglets, lignes et sous-fenêtres exactement à leur place. Discret les laisse se poser avec un petit rebond." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nessuno ferma schede, righe e riquadri esattamente al loro posto. Leggero li fa assestare con un piccolo rimbalzo." + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "គ្មាន បញ្ឈប់ផ្ទាំង ជួរ និងផ្ទាំងបន្ទះនៅកន្លែងរបស់វាត្រឹមត្រូវ។ ស្រាល ឱ្យវាស្ងប់ដោយលោតបន្តិច។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "없음은 탭, 행, 창을 정확히 제자리에 멈춥니다. 은은하게는 작게 튕기며 자리 잡습니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Ingen stopper faner, rader og ruter nøyaktig på plassen sin. Diskret lar dem falle på plass med en liten fjæring." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Brak zatrzymuje karty, wiersze i panele dokładnie na miejscu. Subtelne pozwala im osiąść z lekkim odbiciem." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Nenhum para abas, linhas e painéis exatamente no lugar. Sutil os deixa assentar com um pequeno quique." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Нет останавливает вкладки, строки и панели точно на месте. Слегка даёт им встать на место с небольшим отскоком." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ไม่มี จะหยุดแท็บ แถว และบานหน้าต่างตรงตำแหน่งพอดี เล็กน้อย จะให้เด้งเล็กน้อยก่อนเข้าที่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yok sekmeleri, satırları ve bölmeleri tam yerinde durdurur. Hafif, küçük bir sekmeyle yerleşmelerini sağlar." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Немає зупиняє вкладки, рядки й панелі точно на місці. Легко дає їм стати на місце з невеликим відскоком." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Không dừng tab, hàng và khung đúng vị trí. Nhẹ cho chúng nảy nhẹ trước khi dừng." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "无:标签页、行和窗格精确停在原位。轻微:以轻微弹跳落定。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "無:分頁、列與窗格精確停在原位。輕微:以輕微彈跳落定。" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/MotionParityCorpus.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/MotionParityCorpus.swift new file mode 100644 index 000000000000..6dcbfc6acedc --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/MotionParityCorpus.swift @@ -0,0 +1,54 @@ +public import CmuxNextDesign +import CoreGraphics + +/// The motion parity corpus of this port (`debug.motion {action: +/// "trajectory"}`): every chrome spring token resolved under bounce none +/// and subtle at speed fast, and the frame path of a 200 pt move (and a +/// retarget to 50 pt after 5 frames) at 60 and 120 Hz until the spring +/// rests. cmux-motion's `motion_trajectories` example prints the same +/// document; scripts/cmux-next/motion-parity.py compares them. +/// `debug.motion` (CmuxNextApp) serves it; it lives here, beside the +/// motion settings, to keep the App target small. +public struct MotionParityCorpus { + private let tokens = MotionSpring.allCases + + public init() {} + + public var document: JSONValue { + var tokenRows: [JSONValue] = [] + var paths: [JSONValue] = [] + for bounce in [MotionBounce.none, .subtle] { + let policy = MotionPolicy(speed: .fast, reduceMotion: false, bounce: bounce) + for token in tokens { + let spring = policy.spring(token) + tokenRows.append([ + "token": .string(token.rawValue), "bounce": .string(bounce.rawValue), + "response": .number(spring.response), "damping_fraction": .number(spring.dampingFraction), + "visible_end": .number(spring.perceivedDuration), + ]) + for hz in [60.0, 120.0] { + for (name, retarget) in [("move", nil), ("retarget", (5, CGFloat(50)))] as [(String, (Int, CGFloat)?)] { + paths.append([ + "token": .string(token.rawValue), "bounce": .string(bounce.rawValue), "hz": .number(hz), + "case": .string(name), "values": .array(path(spring, hz: hz, retarget: retarget).map { .number(Double($0)) }), + ]) + } + } + } + } + return ["port": .string("swift"), "tokens": .array(tokenRows), "paths": .array(paths)] + } + + private func path(_ spring: SpringParameters, hz: Double, retarget: (Int, CGFloat)?) -> [CGFloat] { + var value = SpringValue(0) + value.target = 200 + var out: [CGFloat] = [] + for frame in 0..() - let animate = animated && !old.rows.isEmpty && selectedRowKey.map { old.row(for: $0) != nil } != false // a row shown as it is added (Cmd-N) appears in place, with the rest (cx-8g3sn) + // A new workspace row (Cmd-N, a script, another client) opens only with ui.animations.newWorkspace on (hq-1d). + let insertsWorkspace = layout.rows.contains { if case .workspace = $0.key { old.row(for: $0.key) == nil } else { false } } + let animate = animated && !old.rows.isEmpty && (!insertsWorkspace || Motion.animates(.newWorkspace)) // New rows open from their run's top, so only the rows below move; a row still opening // that the next update moves (appended, then placed below) opens again there, in a new view: // the old one's retargeted spring would carry its stale open on through the rows (cx-ai79). let now = CACurrentMediaTime() openingRows = openingRows.filter { now - $0.value < Motion.spring(.move).settlingTime() } let reopened = animate ? Set(layout.rows.filter { openingRows[$0.key] != nil && old.row(for: $0.key)?.y != $0.y }.map(\.key)) : [] - for row in layout.rows where !old.rows.isEmpty && (old.row(for: row.key) == nil || reopened.contains(row.key)) { openingRows[row.key] = now } + for row in layout.rows where animate && (old.row(for: row.key) == nil || reopened.contains(row.key)) { openingRows[row.key] = now } // only animated applies open rows (hq-1d) let insertRuns = SidebarRowTransition.runTops(of: layout, missingFrom: old, reopened: reopened) for row in layout.rows { let target = frame(for: row) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRowMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRowMotion.swift index 92807831570f..0e0f39dc75d4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRowMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRowMotion.swift @@ -30,10 +30,8 @@ struct RowMotion { return } guard Motion.animatesMovement else { - // Reduce Motion: rows take their places at once and the change crossfades (motion.md - // rule 7). Inside the movement spring, which Reduce Motion skips, the fades never ran: - // a closed row vanished and a new one popped in. - if !appearing.isEmpty || !leaving.isEmpty { Motion.crossfadeInPlace(list.layer) } + // Reduce Motion: rows take their places in one step, with no crossfade standing in + // for the motion (DESIGN P6): no movement, and nothing a person must wait out. Motion.withoutAnimation { moves() lines.changes() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Spring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Spring.swift index 788da9bad485..e24991a679b5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Spring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Spring.swift @@ -86,7 +86,7 @@ public struct Spring: Equatable, Sendable { step(dt, parameters: Motion.spring(activeToken)) } - /// Advances by `dt` seconds with fixed substeps (stable at any frame rate). + /// Advances by `dt` seconds on the closed-form spring (exact at any frame rate). public mutating func step(_ dt: CGFloat, parameters: SpringParameters) { lastSample = nil guard !isSettled else { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripCrossfade.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripCrossfade.swift deleted file mode 100644 index a0a1d451894e..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripCrossfade.swift +++ /dev/null @@ -1,14 +0,0 @@ -import AppKit -import CmuxNextDesign - -/// Reduce Motion in the tab strip: tabs take their places at once, and a tab that comes or goes -/// crossfades (motion.md rule 7) instead of vanishing or popping in. The strip's own springs -/// (width and alpha) do not run there. -@MainActor -enum TabStripCrossfade { - /// Crossfades `clip` when the tabs `next` differ from the ones `shown`, under Reduce Motion. - static func run(in clip: NSView, shown: Set, next: Set) { - guard !Motion.animatesMovement, shown != next else { return } - Motion.crossfadeInPlace(clip.layer) - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift index be69a5f33bf0..d518afdae315 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift @@ -276,3 +276,15 @@ extension TabStripView { /// trails the reply by a frame or two) before the model's order shows. static let reorderSettleTimeout: Duration = .milliseconds(300) } + +// Debug proofs (debug.new_tab frame_strip `drag_new`) drive a drag at a tab's place. +extension TabStripView { + /// The tabs in the order the strip shows them. + public var displayedOrder: [TabID] { displayed.map(\.id) } + + /// Where `id`'s cell shows now, its center in window coordinates; nil when it shows nowhere. + public func tabCenterInWindow(_ id: TabID) -> CGPoint? { + guard let m = motion[id] else { return nil } + return tabsClip.convert(CGPoint(x: m.x.value - scroll.value + m.width.value / 2, y: tabsClip.bounds.midY), to: nil) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift index f324555f24e6..dd6ca864fc7b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift @@ -1,5 +1,5 @@ import AppKit -import CmuxNextDesign +public import CmuxNextDesign import CmuxNextWakeups import QuartzCore // Layout, frame application, edge fades, and the FrameScheduler animation client. @@ -45,7 +45,10 @@ extension TabStripView { items.insert(placeholder, at: position) } - func relayout(animated: Bool, added: Set = []) { + /// `growsAdded` (default false): the `added` tabs grow in from zero width (`ui.animations.newTab` + /// on, outside a gesture); otherwise they are there at once at full width (nx-cmdt-fix). + func relayout(animated: Bool, added: Set = [], growsAdded: Bool = false) { + let grows = growsAdded result = TabLayoutEngine.layout( items: layoutItems(), availableWidth: viewportWidth, @@ -56,7 +59,7 @@ extension TabStripView { // A new tab (Cmd-T, +, a script's tab) is there at once, at its slot and full width, and the // tabs make room for it in the same frame: no grow-in, no slide (Lawrence 2026-10-14: no big // animations by default). A drag or a torn-out tab's drop keeps every spring as it was. - let insertsAtOnce = !added.isEmpty && drag == nil && pendingDrop == nil + let insertsAtOnce = !added.isEmpty && drag == nil && pendingDrop == nil && !grows for slot in result.slots where slot.id != Self.placeholderID { guard var m = motion[slot.id] else { continue } if added.contains(slot.id) { @@ -64,7 +67,8 @@ extension TabStripView { m = TabMotion(x: pendingDrop.x, width: pendingDrop.width, alpha: 1) self.pendingDrop = nil } else { - m = TabMotion(x: slot.x, width: slot.width, alpha: 1) + // `ui.animations.newTab` on: it grows in from zero width at its slot (hq-1d). + m = TabMotion(x: slot.x, width: grows ? 0 : slot.width, alpha: grows ? 0 : 1) } } if drag?.id != slot.id { m.x.target = groupDragX(for: slot) ?? slot.x } @@ -180,9 +184,10 @@ extension TabStripView { func startAnimating() { guard window != nil else { return } - guard !animationClient.isActive else { return } - MotionTrace.begin("tabs") - animationClient.activate() + let clock = MotionClock.forView(self) + if let motionClock, motionClock !== clock { motionClock.stop(self) } + motionClock = clock + clock.run(self, trace: "tabs") } /// One animation frame; false when everything settled. @@ -207,10 +212,17 @@ extension TabStripView { if autoscrollDuringDrag(dt) { active = true } scroll.step(dt) if !scroll.isSettled { active = true } + if MotionTrace.isEnabled { + // The points the strip's tabs have left to travel (x and width): its spring's path, for debug.motion. + MotionTrace.sample("tabs.travel", Double(motion.values.reduce(0) { $0 + abs($1.x.target - $1.x.value) + abs($1.width.target - $1.width.value) })) + } // Tabs sliding under a still pointer update hover (applyFrames -> // geometryDidChange). applyFrames() - if !active { MotionTrace.end("tabs") } return active } } + +extension TabStripView: MotionAnimating { + public func motionStep(_ dt: Double) -> Bool { advance(CGFloat(dt)) } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift index d7d927169d87..6cc608f2a38a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift @@ -41,13 +41,27 @@ extension TabStripView { } } - let animated = hasSynced && !reduceMotion && animating + // A new tab grows into its slot while its neighbours slide, on one clock, and a closed one + // gives its room back, only with `ui.animations.newTab` on (hq-1d, Lawrence 2026-10-14: no big + // animations by default). Off (the default) or under Reduce Motion the strip takes its new + // places in one step, with no crossfade standing in for the motion (DESIGN P6). A tab that + // comes or goes during a person's gesture (a drag, a group drag, a drop settling) appears or + // leaves at once while the gesture's other springs keep running. Reorders keep their motion. let ids = Set(ordered.map(\.id)) - if hasSynced { TabStripCrossfade.run(in: tabsClip, shown: Set(cells.keys), next: ids) } + let adding = ordered.contains { cells[$0.id] == nil } + let removing = cells.keys.contains { !ids.contains($0) && !dying.contains($0) } + let structureAnimates = !(adding || removing) || Motion.animates(.newTab) + let inGesture = drag != nil || groups.drag != nil || pendingDrop != nil + let animated = hasSynced && !reduceMotion && animating && (structureAnimates || inGesture) + let cellsAnimate = animated && structureAnimates + if MotionTrace.isEnabled { + MotionTrace.note("tabs.sync fromModel=\(fromModel) animating=\(animating) animated=\(animated)" + + " added=\(ordered.filter { cells[$0.id] == nil }.map(\.id.rawValue)) removed=\(cells.keys.filter { !ids.contains($0) }.map(\.rawValue))") + } // A tab taking another's place (`TabStripModel.successors`) gets its cell's place; that cell goes now. let handed = model.takeSuccessions(shown: Set(cells.keys), next: ids) for (id, cell) in cells where !ids.contains(id) && !dying.contains(id) && !handed.values.contains(id) { - if animated { + if cellsAnimate { dying.insert(id) motion[id]?.width.target = 0 motion[id]?.alpha.target = 0 @@ -69,7 +83,7 @@ extension TabStripView { motion[item.id] = place removeTab(from) } else { - added.insert(makeCell(item, animated: animated)) + added.insert(makeCell(item, animated: cellsAnimate)) } } added.formUnion(syncChips(ordered, animated: animated)) @@ -90,7 +104,7 @@ extension TabStripView { needsLayout = true } - relayout(animated: animated || (styleChanged && !reduceMotion), added: added) + relayout(animated: animated || (styleChanged && !reduceMotion), added: added, growsAdded: cellsAnimate) let selected = model.selectedID if let selected, selected != lastSelectedID || added.contains(selected) { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView.swift index 88e204d552b3..35f4c2fa6da2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView.swift @@ -82,10 +82,10 @@ public final class TabStripView: NSView { var lastModelOrder: [TabID] = [] var lastStyle: TabStripStyle? var lastViewportWidth: CGFloat = -1 - /// Springs and drag autoscroll tick on the window's FrameScheduler. - lazy var animationClient = FrameClient(owner: "TabStrip.animation", view: self) { [weak self] tick in - self?.advance(CGFloat(tick.elapsed)) ?? false - } + /// Springs and drag autoscroll step on the window's one `MotionClock`, + /// with the window's other chrome springs. + weak var motionClock: MotionClock? + var isAnimating: Bool { motionClock?.isRunning(self) ?? false } var observationTask: Task? var tokenObservationTask: Task? @@ -239,7 +239,8 @@ public final class TabStripView: NSView { observationTask = nil tokenObservationTask?.cancel() tokenObservationTask = nil - animationClient.deactivate() + motionClock?.stop(self) + motionClock = nil groupEditor.hide() groups.holdTask?.cancel() removeEscapeMonitor() diff --git a/cmux-tui/crates/cmux-app-ffi/Cargo.lock b/cmux-tui/crates/cmux-app-ffi/Cargo.lock index 0c9486f46cb2..94d223ecff31 100644 --- a/cmux-tui/crates/cmux-app-ffi/Cargo.lock +++ b/cmux-tui/crates/cmux-app-ffi/Cargo.lock @@ -22,6 +22,7 @@ name = "cmux-app-ffi" version = "0.1.0" dependencies = [ "cmux-layout-reducer-ffi", + "cmux-motion", "cmux-palette-ranker", "cmux-rd-ffi", ] @@ -54,6 +55,10 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cmux-motion" +version = "0.1.0" + [[package]] name = "cmux-palette-ranker" version = "0.1.0" diff --git a/cmux-tui/crates/cmux-app-ffi/Cargo.toml b/cmux-tui/crates/cmux-app-ffi/Cargo.toml index d9c5ea4cda93..724a3ad07ae9 100644 --- a/cmux-tui/crates/cmux-app-ffi/Cargo.toml +++ b/cmux-tui/crates/cmux-app-ffi/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.91" license = "GPL-3.0-or-later" publish = false -description = "The one Rust static library the cmux macOS app links: the C ABIs of the remote desktop core, the sidebar layout reducer and the palette ranker" +description = "The one Rust static library the cmux macOS app links: the C ABIs of the remote desktop core, the sidebar layout reducer, the palette ranker and cmux-motion" # Own workspace, like its members: built only for the app xcframework # (scripts/cmux-next/build-app-ffi.sh), so cmux-tui workspace builds and @@ -19,6 +19,9 @@ crate-type = ["staticlib"] cmux-rd-ffi = { path = "../cmux-rd-ffi" } cmux-layout-reducer-ffi = { path = "../cmux-layout-reducer-ffi" } cmux-palette-ranker = { path = "../cmux-palette-ranker" } +# The spring step and motion tokens (src/motion.rs). No `settings`: the app +# passes its own settings per call, so no AppKit or env reading here. +cmux-motion = { path = "../cmux-motion", default-features = false } [profile.release] opt-level = 3 diff --git a/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h b/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h new file mode 100644 index 000000000000..2e8dce7e1a4b --- /dev/null +++ b/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h @@ -0,0 +1,44 @@ +#ifndef CMUX_MOTION_FFI_H +#define CMUX_MOTION_FFI_H + +/* cmux-motion's C ABI (cmux-tui/crates/cmux-app-ffi/src/motion.rs): the + * closed-form spring, the spec's motion tokens, the structural bounce rule + * and the ui.animations.* switches. Pure functions; no globals. */ + +#include +#include + +typedef struct { + double response; + double damping_fraction; +} CmuxMotionSpring; + +typedef struct { + double value; + double velocity; + double target; +} CmuxMotionState; + +/* speed: 0 fast, 1 normal, 2 off. bounce: 0 none, 1 subtle. */ +typedef struct { + uint8_t speed; + bool reduce_motion; + uint8_t bounce; + bool pane_animations; + bool new_tab; + bool new_workspace; +} CmuxMotionPolicy; + +uint32_t cmux_motion_ffi_abi_version(void); +/* token: 0 move, 1 appear, 2 disappear, 3 settle, 4 scroll, 5 screen, + * 6 track, 7 selection, 8 panel, 9 pinDrag, 10 zoom, 11 exit + * (the macOS MotionSpring declares its cases in this order). */ +bool cmux_motion_spring_base(uint8_t token, CmuxMotionSpring *out); +bool cmux_motion_spring_is_structural(uint8_t token); +CmuxMotionSpring cmux_motion_resolve(CmuxMotionSpring base, bool structural, CmuxMotionPolicy policy); +bool cmux_motion_step(CmuxMotionState *state, double dt, CmuxMotionSpring spring, double epsilon); +/* kind: 0 paneAnimations, 1 newTab, 2 newWorkspace (all off by default). */ +bool cmux_motion_animates(uint8_t kind, CmuxMotionPolicy policy); +double cmux_motion_fade_seconds(uint8_t fade, CmuxMotionPolicy policy); + +#endif diff --git a/cmux-tui/crates/cmux-app-ffi/src/lib.rs b/cmux-tui/crates/cmux-app-ffi/src/lib.rs index f9ead1f17480..c555a3e7dac2 100644 --- a/cmux-tui/crates/cmux-app-ffi/src/lib.rs +++ b/cmux-tui/crates/cmux-app-ffi/src/lib.rs @@ -8,6 +8,9 @@ //! archive: this crate links their crates, whose `#[unsafe(no_mangle)]` //! functions are the archive's exported symbols, over one Rust runtime. //! Add a new in-tree C ABI here, never as another static library. +//! `motion` is cmux-motion's C ABI (the spring step and motion tokens). + +pub mod motion; pub use cmux_layout_reducer_ffi as layout_reducer; pub use cmux_palette_ranker::ffi as palette_ranker; diff --git a/cmux-tui/crates/cmux-app-ffi/src/motion.rs b/cmux-tui/crates/cmux-app-ffi/src/motion.rs new file mode 100644 index 000000000000..d0184acf0bec --- /dev/null +++ b/cmux-tui/crates/cmux-app-ffi/src/motion.rs @@ -0,0 +1,159 @@ +//! The C ABI of cmux-motion for the macOS app (`include/cmux_motion_ffi.h`, +//! Swift module `CCmuxMotionFFI`): the closed-form spring step, the token +//! table, the structural bounce rule and the per-kind switches. The app's +//! `MotionClock` keeps only the display link and the layer updates; the +//! physics and the rules live once, in cmux-motion. Every function is pure +//! (no globals, no allocation) and never panics on any input. + +use cmux_motion::{ + AnimationSwitches, MotionBounce, MotionFade, MotionKind, MotionPolicy, MotionSpeed, + MotionSpring, SpringParameters, advance_spring, +}; + +/// Bumped on any change to a type or function in the header. +const ABI_VERSION: u32 = 1; + +/// `SpringParameters`. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionSpring { + pub response: f64, + pub damping_fraction: f64, +} + +/// One animated scalar: value, velocity (units per second), target. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionState { + pub value: f64, + pub velocity: f64, + pub target: f64, +} + +/// The host's settings for one resolve: `speed` 0 fast, 1 normal, 2 off; +/// `bounce` 0 none, 1 subtle; the three `ui.animations.*` switches. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionPolicy { + pub speed: u8, + pub reduce_motion: bool, + pub bounce: u8, + pub pane_animations: bool, + pub new_tab: bool, + pub new_workspace: bool, +} + +impl From for SpringParameters { + fn from(s: CmuxMotionSpring) -> Self { + SpringParameters::new(s.response, s.damping_fraction) + } +} + +impl From for CmuxMotionSpring { + fn from(p: SpringParameters) -> Self { + Self { response: p.response, damping_fraction: p.damping_fraction } + } +} + +fn policy(p: &CmuxMotionPolicy) -> MotionPolicy { + let speed = match p.speed { + 1 => MotionSpeed::Normal, + 2 => MotionSpeed::Off, + _ => MotionSpeed::Fast, + }; + let bounce = if p.bounce == 1 { MotionBounce::Subtle } else { MotionBounce::None }; + MotionPolicy::new(speed, p.reduce_motion).with_animations(AnimationSwitches { + pane_animations: p.pane_animations, + new_tab: p.new_tab, + new_workspace: p.new_workspace, + bounce, + }) +} + +/// The ABI version this library implements. +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_ffi_abi_version() -> u32 { + ABI_VERSION +} + +/// The spring token `token` (`MotionSpring` order: 0 move, 1 appear, 2 +/// disappear, 3 settle, 4 scroll, 5 screen, 6 track, 7 selection, 8 panel, +/// 9 pinDrag, 10 zoom, 11 exit; the macOS `MotionSpring` declares its +/// cases in this order) at speed `fast` from the spec, before the policy. Returns +/// false for an unknown token. +/// +/// # Safety +/// `out` is null or points to a writable `CmuxMotionSpring`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cmux_motion_spring_base(token: u8, out: *mut CmuxMotionSpring) -> bool { + let Some(token) = MotionSpring::ALL.get(usize::from(token)) else { return false }; + if out.is_null() { + return false; + } + // SAFETY: `out` is non-null and writable per the contract above. + unsafe { out.write(token.base().into()) }; + true +} + +/// True when spring token `token` is structural (follows the bounce). +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_spring_is_structural(token: u8) -> bool { + MotionSpring::ALL.get(usize::from(token)).is_some_and(|t| t.is_structural()) +} + +/// Resolves `base` (a token's speed-`fast` tuning, possibly a host +/// override) under `policy`: a structural spring is critically damped with +/// bounce none and takes the subtle bounce with bounce subtle; then the +/// speed scales the response. The same rule as `MotionPolicy::spring`. +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_resolve( + base: CmuxMotionSpring, + structural: bool, + policy: CmuxMotionPolicy, +) -> CmuxMotionSpring { + let p = self::policy(&policy); + let mut s = SpringParameters::from(base); + if structural { + s = match p.animations.bounce { + MotionBounce::None => SpringParameters::new(s.response, s.damping_fraction.max(1.)), + MotionBounce::Subtle => s.with_bounce(MotionBounce::Subtle.amount()), + }; + } + if p.speed == MotionSpeed::Off { s.into() } else { s.scaled(p.speed.time_scale()).into() } +} + +/// Advances `state` by `dt` seconds on `spring` in closed form (exact at +/// any frame rate, keeps velocity across retargets, dt capped at 0.1 s) and +/// snaps it to its target once within `epsilon` and slow. True while still +/// moving; false (and no change) for a null `state`. +/// +/// # Safety +/// `state` is null or points to a valid, writable `CmuxMotionState`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cmux_motion_step( + state: *mut CmuxMotionState, + dt: f64, + spring: CmuxMotionSpring, + epsilon: f64, +) -> bool { + if state.is_null() { + return false; + } + // SAFETY: `state` is non-null, valid and writable per the contract above. + let s = unsafe { &mut *state }; + advance_spring(&mut s.value, &mut s.velocity, s.target, dt, spring.into(), epsilon) +} + +/// True when kind `kind` (0 paneAnimations, 1 newTab, 2 newWorkspace) animates +/// under `policy`: movement animates and its switch is on. +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_animates(kind: u8, policy: CmuxMotionPolicy) -> bool { + MotionKind::ALL.get(usize::from(kind)).is_some_and(|k| self::policy(&policy).animates(*k)) +} + +/// Seconds of fade token `fade` (`MotionFade` order) under `policy`; 0 +/// applies at once (also for an unknown token). +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_fade_seconds(fade: u8, policy: CmuxMotionPolicy) -> f64 { + MotionFade::ALL.get(usize::from(fade)).map_or(0., |f| self::policy(&policy).fade(*f)) +} diff --git a/cmux-tui/crates/cmux-motion/build-support/json.rs b/cmux-tui/crates/cmux-motion/build-support/json.rs new file mode 100644 index 000000000000..4690ba0f2757 --- /dev/null +++ b/cmux-tui/crates/cmux-motion/build-support/json.rs @@ -0,0 +1,210 @@ +//! A small JSON reader for `build.rs` (no dependencies, so the crate's +//! dependency graph and every Cargo.lock stay unchanged). It reads exactly +//! what `design-tokens.json` uses: objects, arrays, strings, numbers, +//! booleans and null. Errors name the byte offset. + +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(Vec<(String, Value)>), +} + +impl Value { + pub fn get(&self, key: &str) -> Option<&Value> { + match self { + Value::Object(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + pub fn as_f64(&self) -> Option { + match self { + Value::Number(n) => Some(*n), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Value::String(s) => Some(s), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&[Value]> { + match self { + Value::Array(items) => Some(items), + _ => None, + } + } +} + +pub fn parse(text: &str) -> Result { + let mut p = Parser { s: text.as_bytes(), i: 0 }; + let v = p.value()?; + p.ws(); + if p.i != p.s.len() { + return Err(p.err("trailing text")); + } + Ok(v) +} + +struct Parser<'a> { + s: &'a [u8], + i: usize, +} + +impl Parser<'_> { + fn err(&self, what: &str) -> String { + format!("{what} at byte {}", self.i) + } + + fn ws(&mut self) { + while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() { + self.i += 1; + } + } + + fn eat(&mut self, lit: &str) -> bool { + if self.s[self.i..].starts_with(lit.as_bytes()) { + self.i += lit.len(); + true + } else { + false + } + } + + fn value(&mut self) -> Result { + self.ws(); + match self.s.get(self.i) { + Some(b'{') => self.object(), + Some(b'[') => self.array(), + Some(b'"') => self.string().map(Value::String), + Some(b't') if self.eat("true") => Ok(Value::Bool(true)), + Some(b'f') if self.eat("false") => Ok(Value::Bool(false)), + Some(b'n') if self.eat("null") => Ok(Value::Null), + Some(_) => self.number(), + None => Err(self.err("unexpected end")), + } + } + + fn object(&mut self) -> Result { + self.i += 1; + let mut entries = Vec::new(); + self.ws(); + if self.eat("}") { + return Ok(Value::Object(entries)); + } + loop { + self.ws(); + let key = self.string()?; + self.ws(); + if !self.eat(":") { + return Err(self.err("expected ':'")); + } + entries.push((key, self.value()?)); + self.ws(); + if self.eat(",") { + continue; + } + if self.eat("}") { + return Ok(Value::Object(entries)); + } + return Err(self.err("expected ',' or '}'")); + } + } + + fn array(&mut self) -> Result { + self.i += 1; + let mut items = Vec::new(); + self.ws(); + if self.eat("]") { + return Ok(Value::Array(items)); + } + loop { + items.push(self.value()?); + self.ws(); + if self.eat(",") { + continue; + } + if self.eat("]") { + return Ok(Value::Array(items)); + } + return Err(self.err("expected ',' or ']'")); + } + } + + fn string(&mut self) -> Result { + if !self.eat("\"") { + return Err(self.err("expected a string")); + } + let mut out = String::new(); + loop { + let start = self.i; + while self.i < self.s.len() && self.s[self.i] != b'"' && self.s[self.i] != b'\\' { + self.i += 1; + } + out.push_str( + std::str::from_utf8(&self.s[start..self.i]).map_err(|_| self.err("bad UTF-8"))?, + ); + match self.s.get(self.i) { + Some(b'"') => { + self.i += 1; + return Ok(out); + } + Some(b'\\') => { + self.i += 1; + let c = *self.s.get(self.i).ok_or_else(|| self.err("unexpected end"))?; + self.i += 1; + match c { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let hex = self + .s + .get(self.i..self.i + 4) + .ok_or_else(|| self.err("short \\u"))?; + let code = + u32::from_str_radix(std::str::from_utf8(hex).unwrap_or("x"), 16) + .map_err(|_| self.err("bad \\u"))?; + self.i += 4; + // Surrogate pairs never carry a token value; keep a replacement. + out.push(char::from_u32(code).unwrap_or('\u{fffd}')); + } + _ => return Err(self.err("bad escape")), + } + } + _ => return Err(self.err("unterminated string")), + } + } + } + + fn number(&mut self) -> Result { + let start = self.i; + while self.i < self.s.len() + && matches!(self.s[self.i], b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') + { + self.i += 1; + } + let text = + std::str::from_utf8(&self.s[start..self.i]).map_err(|_| self.err("bad number"))?; + text.parse::().map(Value::Number).map_err(|_| self.err("expected a value")) + } +} diff --git a/cmux-tui/crates/cmux-motion/build.rs b/cmux-tui/crates/cmux-motion/build.rs new file mode 100644 index 000000000000..1b2d85c809b1 --- /dev/null +++ b/cmux-tui/crates/cmux-motion/build.rs @@ -0,0 +1,169 @@ +//! Reads the motion tokens from the spec's `design-tokens.json` (`motion`) +//! at build time and writes them as constants (`$OUT_DIR/tokens.rs`), so +//! the numbers live in one file that every port reads (cmux-next macOS, +//! this crate, the GPUI app) and the token functions stay `const`. A +//! missing key, a wrong type or a value out of range fails the build with +//! the json path; nothing falls back to a copy in code. + +#[path = "build-support/json.rs"] +mod json; + +use std::fmt::Write as _; +use std::path::PathBuf; + +/// Repo root `plans/cmux-next/spec-proposals/visuals/design-tokens.json`, +/// the same file cmux-theme-tokens compiles in. +const TOKENS: &str = "../../../plans/cmux-next/spec-proposals/visuals/design-tokens.json"; + +/// `motion.springs` keys in `MotionSpring` declaration order. +const SPRINGS: [&str; 12] = [ + "move", + "appear", + "disappear", + "settle", + "scroll", + "screen", + "track", + "selection", + "panel", + "pinDrag", + "zoom", + "exit", +]; +/// `motion.fades` keys in `MotionFade` declaration order. +const FADES: [&str; 12] = [ + "hover", + "focus", + "fadeIn", + "fadeOut", + "crossfade", + "lift", + "theme", + "highlight", + "launch", + "pinLift", + "pinSettle", + "pinShrink", +]; +/// `motion.loops` keys in `MotionLoop` declaration order. +const LOOPS: [&str; 3] = ["spinner", "pulse", "flash"]; + +fn main() { + let path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")) + .join(TOKENS); + println!("cargo:rerun-if-changed={}", path.display()); + println!("cargo:rerun-if-changed=build-support/json.rs"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let doc = json::parse(&text).unwrap_or_else(|e| panic!("design-tokens.json: {e}")); + let motion = doc.get("motion").unwrap_or_else(|| panic!("design-tokens.json: no motion")); + let code = generate(motion).unwrap_or_else(|e| panic!("design-tokens.json motion.{e}")); + let out = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")).join("tokens.rs"); + std::fs::write(&out, code).unwrap_or_else(|e| panic!("{}: {e}", out.display())); +} + +fn at<'a>(v: &'a json::Value, path: &[&str]) -> Result<&'a json::Value, String> { + path.iter() + .try_fold(v, |v, key| v.get(key)) + .ok_or_else(|| format!("{}: missing", path.join("."))) +} + +fn number( + v: &json::Value, + path: &[&str], + range: std::ops::RangeInclusive, +) -> Result { + let n = at(v, path)?.as_f64().ok_or_else(|| format!("{}: not a number", path.join(".")))?; + if !range.contains(&n) { + return Err(format!("{}: {n} is outside {range:?}", path.join("."))); + } + Ok(n) +} + +fn flag(v: &json::Value, path: &[&str]) -> Result { + at(v, path)?.as_bool().ok_or_else(|| format!("{}: not a boolean", path.join("."))) +} + +fn generate(m: &json::Value) -> Result { + let mut code = + String::from("// Generated by cmux-motion/build.rs from design-tokens.json `motion`.\n"); + let speed = |key| number(m, &["speed", "value", key], 0.0..=10.0); + let (fast, normal, off) = (speed("fast")?, speed("normal")?, speed("off")?); + if fast != 1.0 || off != 0.0 { + return Err(format!("speed.value: fast must be 1 and off 0 (got {fast}, {off})")); + } + writeln!(code, "pub(crate) const SPEED_NORMAL: f64 = {normal:?};").ok(); + + writeln!(code, "pub(crate) const SPRINGS: [SpringParameters; {}] = [", SPRINGS.len()).ok(); + for key in SPRINGS { + let response = number(m, &["springs", key, "value", "response"], 0.01..=2.0)?; + let damping = number(m, &["springs", key, "value", "dampingFraction"], 0.05..=4.0)?; + writeln!(code, " SpringParameters::new({response:?}, {damping:?}), // {key}").ok(); + } + code.push_str("];\n"); + + let structural = at(m, &["structural", "value"])? + .as_array() + .ok_or("structural.value: not an array")? + .iter() + .map(|v| v.as_str().ok_or("structural.value: not a string").map(str::to_owned)) + .collect::, _>>()?; + if let Some(unknown) = structural.iter().find(|k| !SPRINGS.contains(&k.as_str())) { + return Err(format!("structural.value: unknown spring {unknown}")); + } + let flags: Vec = + SPRINGS.iter().map(|k| structural.iter().any(|s| s == k).to_string()).collect(); + writeln!( + code, + "pub(crate) const STRUCTURAL: [bool; {}] = [{}];", + SPRINGS.len(), + flags.join(", ") + ) + .ok(); + + let subtle = number(m, &["bounce", "value", "subtle"], 0.0..=0.9)?; + if number(m, &["bounce", "value", "none"], 0.0..=0.0).is_err() { + return Err("bounce.value.none: must be 0 (critically damped)".into()); + } + let default_bounce = match at(m, &["bounce", "value", "default"])?.as_str() { + Some("none") => "None", + Some("subtle") => "Subtle", + _ => return Err("bounce.value.default: not none or subtle".into()), + }; + writeln!(code, "pub(crate) const SUBTLE_BOUNCE: f64 = {subtle:?};").ok(); + writeln!( + code, + "pub(crate) const DEFAULT_BOUNCE: MotionBounce = MotionBounce::{default_bounce};" + ) + .ok(); + + let kinds = + ["paneAnimations", "newTab", "newWorkspace"].map(|k| flag(m, &["kinds", "value", k])); + let [pane_animations, new_tab, new_workspace] = kinds; + writeln!( + code, + "pub(crate) const DEFAULT_KINDS: [bool; 3] = [{}, {}, {}];", + pane_animations?, new_tab?, new_workspace? + ) + .ok(); + + let geometry = number(m, &["settle", "value", "geometry"], 0.001..=4.0)?; + let unit = number(m, &["settle", "value", "unit"], 0.000_001..=0.1)?; + let factor = number(m, &["settle", "value", "velocityFactor"], 1.0..=100.0)?; + writeln!(code, "pub(crate) const SETTLE_GEOMETRY: f32 = {:?};", geometry as f32).ok(); + writeln!(code, "pub(crate) const SETTLE_UNIT: f32 = {:?};", unit as f32).ok(); + writeln!(code, "pub(crate) const SETTLE_VELOCITY_FACTOR: f32 = {:?};", factor as f32).ok(); + + code.push_str("pub(crate) const FADES: [f64; 12] = [\n"); + for key in FADES { + let seconds = number(m, &["fades", key, "value"], 0.0..=5.0)?; + writeln!(code, " {seconds:?}, // {key}").ok(); + } + code.push_str("];\n"); + code.push_str("pub(crate) const LOOPS: [f64; 3] = [\n"); + for key in LOOPS { + let seconds = number(m, &["loops", key, "value"], 0.05..=10.0)?; + writeln!(code, " {seconds:?}, // {key}").ok(); + } + code.push_str("];\n"); + Ok(code) +} diff --git a/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs b/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs new file mode 100644 index 000000000000..04f9307b7efa --- /dev/null +++ b/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs @@ -0,0 +1,86 @@ +//! Prints the motion parity corpus as JSON: every chrome spring token +//! resolved under bounce `none` and `subtle`, and the frame-by-frame path +//! of a 200 pt move (and a mid-flight retarget to 50 pt) at 60 and 120 Hz +//! until the spring rests. cmux-next macOS prints the same corpus from its +//! own spring (`debug.motion {action: "trajectory"}`); +//! `scripts/cmux-next/motion-parity.py RUST.json SWIFT.json` compares them. +//! +//! `cargo run -p cmux-motion --example motion_trajectories` + +use cmux_motion::{ + AnimationSwitches, GEOMETRY_EPSILON, MotionBounce, MotionPolicy, MotionSpeed, MotionSpring, + SpringParameters, SpringValue, +}; + +/// Tokens both ports have, by their spec name. +const TOKENS: [(&str, MotionSpring); 12] = [ + ("move", MotionSpring::Move), + ("appear", MotionSpring::Appear), + ("disappear", MotionSpring::Disappear), + ("settle", MotionSpring::Settle), + ("scroll", MotionSpring::Scroll), + ("screen", MotionSpring::Screen), + ("track", MotionSpring::Track), + ("selection", MotionSpring::Selection), + ("panel", MotionSpring::Panel), + ("pinDrag", MotionSpring::PinDrag), + ("zoom", MotionSpring::Zoom), + ("exit", MotionSpring::Exit), +]; + +/// Frames until rest (at most 3 s) from 0 toward 200, retargeting to +/// `retarget` after `at` frames when given. +fn path(p: SpringParameters, hz: f64, retarget: Option<(usize, f32)>) -> Vec { + let mut s = SpringValue::new(0.); + s.target = 200.; + let dt = 1. / hz; + let mut out = Vec::new(); + for frame in 0..(3. * hz) as usize { + if let Some((_, to)) = retarget.filter(|(at, _)| *at == frame) { + s.target = to; + } + let moving = s.advance(dt, p, GEOMETRY_EPSILON); + out.push(s.value); + if !moving { + break; + } + } + out +} + +fn list(values: &[f32]) -> String { + values.iter().map(|v| format!("{v:.4}")).collect::>().join(",") +} + +fn main() { + let mut tokens = Vec::new(); + let mut paths = Vec::new(); + for bounce in [MotionBounce::None, MotionBounce::Subtle] { + let policy = MotionPolicy::new(MotionSpeed::Fast, false) + .with_animations(AnimationSwitches { bounce, ..AnimationSwitches::DEFAULT }); + for (name, token) in TOKENS { + let p = policy.spring(token); + tokens.push(format!( + "{{\"token\":\"{name}\",\"bounce\":\"{}\",\"response\":{:.9},\"damping_fraction\":{:.9},\"visible_end\":{:.6}}}", + bounce.as_str(), + p.response, + p.damping_fraction, + p.visible_end() + )); + for hz in [60., 120.] { + for (case, retarget) in [("move", None), ("retarget", Some((5, 50.)))] { + paths.push(format!( + "{{\"token\":\"{name}\",\"bounce\":\"{}\",\"hz\":{hz},\"case\":\"{case}\",\"values\":[{}]}}", + bounce.as_str(), + list(&path(p, hz, retarget)) + )); + } + } + } + } + println!( + "{{\"port\":\"rust\",\"tokens\":[{}],\"paths\":[{}]}}", + tokens.join(","), + paths.join(",") + ); +} diff --git a/cmux-tui/crates/cmux-motion/src/fade.rs b/cmux-tui/crates/cmux-motion/src/fade.rs new file mode 100644 index 000000000000..3d1eb9d18e3c --- /dev/null +++ b/cmux-tui/crates/cmux-motion/src/fade.rs @@ -0,0 +1,161 @@ +//! Timed fades: Core Animation's ease curves and `Fade`, a timed opacity +//! or color change that retargets from the value on screen. + +use crate::spring::MAX_FRAME; +use crate::{MotionFade, MotionPolicy}; + +// --------------------------------------------------------------------------- +// Timed fades + +/// Control points of Core Animation's `easeOut` +/// (`CAMediaTimingFunction(name: .easeOut)`, the curve of cmux-next's +/// `Motion.fadeCurve`): a cubic Bezier from (0, 0) to (1, 1) through +/// (0, 0) and (0.58, 1). +pub const EASE_OUT_CONTROL_POINTS: [f64; 4] = [0., 0., 0.58, 1.]; + +/// Core Animation's `easeOut`, used by every timed fade: the progress at +/// time fraction `t` (0..=1). Solves the curve's x for `t` (Newton, then +/// bisection), then evaluates its y; within 1e-5 of `CAMediaTimingFunction` +/// (whose own solver stops at a coarser tolerance: 7e-6 at most here). +pub fn ease_out(t: f32) -> f32 { + let [x1, y1, x2, y2] = EASE_OUT_CONTROL_POINTS; + cubic_bezier(f64::from(t.clamp(0., 1.)), x1, y1, x2, y2) as f32 +} + +/// Control points of Core Animation's `easeInEaseOut` +/// (`CAMediaTimingFunction(name: .easeInEaseOut)`): a cubic Bezier from +/// (0, 0) to (1, 1) through (0.42, 0) and (0.58, 1). cmux-next uses it for +/// the hover marquee's scroll (`Motion.marqueeAnimation`). +pub const EASE_IN_EASE_OUT_CONTROL_POINTS: [f64; 4] = [0.42, 0., 0.58, 1.]; + +/// Core Animation's `easeInEaseOut` at time fraction `t` (0..=1), solved +/// like `ease_out`; within 1e-5 of `CAMediaTimingFunction`. +pub fn ease_in_ease_out(t: f32) -> f32 { + let [x1, y1, x2, y2] = EASE_IN_EASE_OUT_CONTROL_POINTS; + cubic_bezier(f64::from(t.clamp(0., 1.)), x1, y1, x2, y2) as f32 +} + +/// A CSS / Core Animation timing curve through (0, 0), (x1, y1), (x2, y2), +/// (1, 1) at time fraction `t` (0..=1; x1 and x2 within 0..=1). +fn cubic_bezier(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { + if t <= 0. { + return 0.; + } + if t >= 1. { + return 1.; + } + // B(s) = 3(1-s)^2 s p1 + 3(1-s) s^2 p2 + s^3, as a polynomial in s. + let coefficients = |p1: f64, p2: f64| { + let c = 3. * p1; + let b = 3. * (p2 - p1) - c; + (1. - c - b, b, c) + }; + let (ax, bx, cx) = coefficients(x1, x2); + let (ay, by, cy) = coefficients(y1, y2); + let x = |s: f64| ((ax * s + bx) * s + cx) * s; + let dx = |s: f64| (3. * ax * s + 2. * bx) * s + cx; + let y = |s: f64| ((ay * s + by) * s + cy) * s; + const EPSILON: f64 = 1e-9; + let mut s = t; + for _ in 0..8 { + let error = x(s) - t; + if error.abs() < EPSILON { + return y(s); + } + let slope = dx(s); + if slope.abs() < 1e-12 { + break; + } + s -= error / slope; + } + // x is monotonic in s on 0..=1: bisection always converges. + let (mut lo, mut hi) = (0., 1.); + s = t; + for _ in 0..64 { + let v = x(s); + if (v - t).abs() < EPSILON { + break; + } + if v < t { + lo = s; + } else { + hi = s; + } + s = (lo + hi) / 2.; + } + y(s) +} + +/// A timed opacity or color change with ease-out. Retargeting restarts the +/// curve from the value on screen (as AppKit does for timed animators), so +/// a fade never jumps and never queues. +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct Fade { + pub token: MotionFade, + from: f32, + target: f32, + value: f32, + elapsed: f64, + duration: f64, +} + +impl Fade { + pub fn new(value: f32, token: MotionFade) -> Self { + Self { token, from: value, target: value, value, elapsed: 0., duration: 0. } + } + + pub fn value(&self) -> f32 { + self.value + } + + pub fn target(&self) -> f32 { + self.target + } + + pub fn is_moving(&self) -> bool { + self.value != self.target + } + + /// Starts toward `target` from the current value with this token's + /// duration under `policy` (0 applies at once). + pub fn set_target(&mut self, target: f32, policy: &MotionPolicy) { + self.set_target_with(target, self.token, policy); + } + + /// As `set_target`, with another token (e.g. `fadeIn` vs `fadeOut`). + pub fn set_target_with(&mut self, target: f32, token: MotionFade, policy: &MotionPolicy) { + if target == self.target && self.is_moving() { + return; + } + self.token = token; + self.from = self.value; + self.target = target; + self.elapsed = 0.; + self.duration = policy.fade(token); + if self.duration <= 0. { + self.value = target; + } + } + + pub fn snap_to(&mut self, target: f32) { + self.from = target; + self.target = target; + self.value = target; + } + + /// Advances `dt` seconds. True while still moving. + pub fn step(&mut self, dt: f64) -> bool { + if !self.is_moving() { + return false; + } + self.elapsed += dt.clamp(0., MAX_FRAME); + if self.duration <= 0. || self.elapsed >= self.duration { + self.value = self.target; + return false; + } + let t = self.token.curve().progress((self.elapsed / self.duration) as f32); + self.value = self.from + (self.target - self.from) * t; + true + } +} diff --git a/cmux-tui/crates/cmux-motion/src/lib.rs b/cmux-tui/crates/cmux-motion/src/lib.rs index 56c1c81a1c26..68b9a639ca90 100644 --- a/cmux-tui/crates/cmux-motion/src/lib.rs +++ b/cmux-tui/crates/cmux-motion/src/lib.rs @@ -18,21 +18,36 @@ //! read on macOS, Linux and Windows (`system_reduce_motion`; on Linux and //! Windows a watcher thread keeps it current). A `Spring` is a `SpringKind::Size` //! (a move to 0 uses `disappear`) unless built with `Spring::position` -//! (positions and offsets never switch token). The numbers in `spring.rs` -//! are the only animation timing constants; change them together with -//! `docs/motion.md`. +//! (positions and offsets never switch token). +//! +//! The token numbers (springs, fades, loops, speed, the bounce, the settle +//! thresholds, the structural set and the per-kind switch defaults) are read +//! at build time from the spec's `design-tokens.json` (`motion`; build.rs), +//! the same file cmux-next macOS and cmux-theme-tokens read. Springs step in +//! closed form (`SpringValue::solve`), so a path is exact at any frame rate. +//! Structural springs (`MotionSpring::is_structural`) follow +//! `ui.animations.bounce`; `ui.paneAnimations`, `ui.animations.newTab` and +//! `ui.animations.newWorkspace` turn one kind on (`MotionPolicy::animates`; +//! all off by default). The host passes these settings +//! in (`set_animation_switches` or `MotionPolicy::with_animations`). mod clock; +mod fade; mod marquee; +mod policy; #[cfg(feature = "settings")] mod settings; mod spring; +mod tokens; // The Reduce Motion reading off macOS (macOS asks NSWorkspace in settings.rs). #[cfg(all(feature = "settings", not(target_os = "macos")))] mod system; pub use clock::*; +pub use fade::*; pub use marquee::*; +pub use policy::*; #[cfg(feature = "settings")] pub use settings::*; pub use spring::*; +pub use tokens::*; diff --git a/cmux-tui/crates/cmux-motion/src/policy.rs b/cmux-tui/crates/cmux-motion/src/policy.rs new file mode 100644 index 000000000000..d25310191ccc --- /dev/null +++ b/cmux-tui/crates/cmux-motion/src/policy.rs @@ -0,0 +1,110 @@ +//! `MotionPolicy`: tokens resolved for one speed, Reduce Motion state, +//! bounce setting and set of per-kind switches. + +use crate::{ + AnimationSwitches, MotionBounce, MotionFade, MotionKind, MotionLoop, MotionSpeed, MotionSpring, + SpringParameters, +}; + +/// Resolves tokens for one speed setting, Reduce Motion state and set of +/// `ui.animations.*` switches. +/// +/// - `off`: nothing animates. Wins over Reduce Motion. +/// - Reduce Motion: movement (position, size, scale, scroll) is instant; +/// fades stay, capped at one crossfade (0.1 s); loops stop. +/// - `normal`: every time constant is 1.5x `fast`; damping is unchanged. +/// - Structural springs (`MotionSpring::is_structural`) are critically +/// damped with bounce `none` and take SwiftUI's subtle bounce with +/// `subtle`; a kind switched off (`animates(kind)` false) applies in one +/// frame, like Reduce Motion. +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct MotionPolicy { + pub speed: MotionSpeed, + pub reduce_motion: bool, + /// Extra time multiplier for screen recordings (`CMUX2_ANIM_SCALE`); 1 + /// normally. Not part of the cmux-next contract. + pub debug_scale: f64, + /// `ui.animations.*`: per-kind switches and the bounce. + pub animations: AnimationSwitches, +} + +impl Default for MotionPolicy { + fn default() -> Self { + Self::new(MotionSpeed::Fast, false) + } +} + +impl MotionPolicy { + pub fn new(speed: MotionSpeed, reduce_motion: bool) -> Self { + Self { speed, reduce_motion, debug_scale: 1., animations: AnimationSwitches::DEFAULT } + } + + /// The same policy with these `ui.animations.*` settings. + pub fn with_animations(self, animations: AnimationSwitches) -> Self { + Self { animations, ..self } + } + + /// `kind` moves: movement animates and its `ui.animations.*` switch is + /// on. When false the change applies in one frame (no crossfade). + pub fn animates(&self, kind: MotionKind) -> bool { + self.animates_movement() && self.animations.enabled(kind) + } + + pub(crate) fn scale(&self) -> f64 { + self.speed.time_scale() * self.debug_scale.max(1.) + } + + /// Position, size, scale and scroll animate. When false, snap. + pub fn animates_movement(&self) -> bool { + self.speed != MotionSpeed::Off && !self.reduce_motion + } + + /// Opacity and color changes animate. When false, apply at once. + pub fn animates_fades(&self) -> bool { + self.speed != MotionSpeed::Off + } + + /// Spinners and pulses run. + pub fn animates_loops(&self) -> bool { + self.speed != MotionSpeed::Off && !self.reduce_motion + } + + /// The spring for `token`. `off` returns the `fast` spring so a caller + /// that steps anyway never divides by zero; callers snap when + /// `animates_movement` is false (`Spring::step` does). + pub fn spring(&self, token: MotionSpring) -> SpringParameters { + let mut base = token.base(); + if token.is_structural() { + // Bounce none: critically damped (never past the target); subtle: SwiftUI bounce. + base = match self.animations.bounce { + MotionBounce::None => { + SpringParameters::new(base.response, base.damping_fraction.max(1.)) + } + MotionBounce::Subtle => base.with_bounce(MotionBounce::Subtle.amount()), + }; + } + if self.speed == MotionSpeed::Off { base } else { base.scaled(self.scale()) } + } + + /// Seconds for a fade; 0 applies at once. Reduce Motion caps it at one + /// crossfade. + pub fn fade(&self, token: MotionFade) -> f64 { + if !self.animates_fades() { + return 0.; + } + let scaled = token.base() * self.scale(); + if self.reduce_motion { scaled.min(MotionFade::Crossfade.base()) } else { scaled } + } + + /// Seconds that stand in for a spring when only a timed API exists: + /// the spring's visible end. 0 when movement does not animate. + pub fn spring_duration(&self, token: MotionSpring) -> f64 { + if self.animates_movement() { self.spring(token).visible_end() } else { 0. } + } + + /// Period of a loop, or `None` when loops are stopped. + pub fn period(&self, token: MotionLoop) -> Option { + self.animates_loops().then(|| token.period()) + } +} diff --git a/cmux-tui/crates/cmux-motion/src/settings.rs b/cmux-tui/crates/cmux-motion/src/settings.rs index 91dab074d04d..75f8f2f055a3 100644 --- a/cmux-tui/crates/cmux-motion/src/settings.rs +++ b/cmux-tui/crates/cmux-motion/src/settings.rs @@ -1,7 +1,10 @@ -//! Process-wide motion settings: animation speed, Reduce Motion and the -//! screen-recording time scale. These are the crate's only global state: -//! two atomics behind documented setters (`set_speed`, -//! `set_reduce_motion_override`), plus environment values read once. +//! Process-wide motion settings: animation speed, Reduce Motion, the +//! `ui.animations.*` switches and the screen-recording time scale. These +//! are the crate's only global state: three atomics behind documented +//! setters (`set_speed`, `set_reduce_motion_override`, +//! `set_animation_switches`), plus environment values read once. The host +//! reads its own settings file and calls the setters; this crate never +//! reads a settings file. //! Callers that want no globals build a `MotionPolicy` themselves and pass //! it to `Spring::step` / `Animated::advance`; `policy()` is a convenience. @@ -10,11 +13,14 @@ use std::sync::{ atomic::{AtomicU8, Ordering}, }; -use crate::{MotionPolicy, MotionSpeed}; +use crate::{AnimationSwitches, MotionBounce, MotionPolicy, MotionSpeed}; const UNSET: u8 = u8::MAX; static SPEED: AtomicU8 = AtomicU8::new(UNSET); static REDUCE_OVERRIDE: AtomicU8 = AtomicU8::new(UNSET); +/// `AnimationSwitches` as bits (1 paneAnimations, 2 newTab, 4 newWorkspace, 8 +/// bounce subtle); `UNSET` until the host sets them (the spec defaults). +static SWITCHES: AtomicU8 = AtomicU8::new(UNSET); fn speed_code(s: MotionSpeed) -> u8 { match s { @@ -47,6 +53,31 @@ pub fn set_speed(s: MotionSpeed) { SPEED.store(speed_code(s), Ordering::Relaxed); } +/// Changes the `ui.animations.*` settings (per-kind switches and bounce) +/// for the whole process. Running animations pick them up on their next +/// frame. +pub fn set_animation_switches(switches: AnimationSwitches) { + let bits = u8::from(switches.pane_animations) + | (u8::from(switches.new_tab) << 1) + | (u8::from(switches.new_workspace) << 2) + | (u8::from(switches.bounce == MotionBounce::Subtle) << 3); + SWITCHES.store(bits, Ordering::Relaxed); +} + +/// The `ui.animations.*` settings: the last `set_animation_switches`, else +/// the spec defaults. +pub fn animation_switches() -> AnimationSwitches { + match SWITCHES.load(Ordering::Relaxed) { + UNSET => AnimationSwitches::DEFAULT, + bits => AnimationSwitches { + pane_animations: bits & 1 != 0, + new_tab: bits & 2 != 0, + new_workspace: bits & 4 != 0, + bounce: if bits & 8 != 0 { MotionBounce::Subtle } else { MotionBounce::None }, + }, + } +} + /// Pins Reduce Motion (tests, a future setting); `None` follows the system. pub fn set_reduce_motion_override(value: Option) { REDUCE_OVERRIDE.store(value.map_or(UNSET, u8::from), Ordering::Relaxed); @@ -100,7 +131,12 @@ fn debug_scale() -> f64 { }) } -/// The live policy: speed, Reduce Motion and the recording scale. +/// The live policy: speed, Reduce Motion, the `ui.animations.*` switches +/// and the recording scale. pub fn policy() -> MotionPolicy { - MotionPolicy { debug_scale: debug_scale(), ..MotionPolicy::new(speed(), reduce_motion()) } + MotionPolicy { + debug_scale: debug_scale(), + animations: animation_switches(), + ..MotionPolicy::new(speed(), reduce_motion()) + } } diff --git a/cmux-tui/crates/cmux-motion/src/spring.rs b/cmux-tui/crates/cmux-motion/src/spring.rs index 2ffb37ff3b54..b4f325c08143 100644 --- a/cmux-tui/crates/cmux-motion/src/spring.rs +++ b/cmux-tui/crates/cmux-motion/src/spring.rs @@ -1,14 +1,18 @@ -//! Tokens, the spring integrator, timed fades and the speed / Reduce Motion -//! policy. Pure and synchronous: the caller passes time, nothing here reads -//! a clock or a global. Every type is a plain `Copy` value with a C layout -//! (`repr(C)` structs, `repr(u8)` enums) so a C ABI can wrap it later. +//! Springs: tuning (`SpringParameters`), the closed-form damped harmonic +//! oscillator (`SpringValue`) and the token-driven `Spring` with direct +//! manipulation. Pure and synchronous: the caller passes time, nothing here +//! reads a clock or a global. Every type is a plain `Copy` value with a C +//! layout (`repr(C)` structs, `repr(u8)` enums) so a C ABI can wrap it. use std::f64::consts::PI; -/// Fixed substep of the spring integrator (s). Same as cmux-next. +use crate::tokens::{SETTLE_GEOMETRY, SETTLE_UNIT, SETTLE_VELOCITY_FACTOR}; +use crate::{MotionPolicy, MotionSpring}; + +/// Sampling step of the duration probes (`visible_end`) (s). const SUBSTEP: f64 = 1. / 480.; -/// A stalled frame never moves a spring more than this much time (s). -const MAX_FRAME: f64 = 0.1; +/// A stalled frame never moves a spring or a fade more than this much time (s). +pub(crate) const MAX_FRAME: f64 = 0.1; // --------------------------------------------------------------------------- // Springs @@ -49,6 +53,28 @@ impl SpringParameters { Self::new(self.response * scale, self.damping_fraction) } + /// SwiftUI `Spring(duration:bounce:)`: `duration` is the perceptual + /// duration (the response); `bounce` 0 is critically damped, above 0 + /// overshoots (0.15 is a subtle bounce), below 0 is over-damped. + pub fn from_duration_bounce(duration: f64, bounce: f64) -> Self { + let damping_fraction = if bounce >= 0. { 1. - bounce } else { 1. / (1. + bounce) }; + Self::new(duration, damping_fraction) + } + + /// SwiftUI's bounce for this damping fraction (0 critically damped). + pub fn bounce(&self) -> f64 { + if self.damping_fraction <= 1. { + 1. - self.damping_fraction + } else { + 1. / self.damping_fraction - 1. + } + } + + /// The same response with `bounce` (SwiftUI semantics). + pub fn with_bounce(&self, bounce: f64) -> Self { + Self::from_duration_bounce(self.response, bounce) + } + /// Closed-form position of a unit step (0 -> 1, from rest) after `t` /// seconds. Used as an easing curve for GPUI's timed `Animation`. pub fn step_response(&self, t: f64) -> f64 { @@ -131,9 +157,10 @@ impl SpringParameters { /// Settle distance for geometry (pt): 0.25 pt is invisible after pixel /// snapping at 2x. Velocity must also be under 8x this (2 pt/s). -pub const GEOMETRY_EPSILON: f32 = 0.25; +/// (design-tokens.json `motion.settle`.) +pub const GEOMETRY_EPSILON: f32 = SETTLE_GEOMETRY; /// Settle distance for opacity and other 0..1 values. -pub const UNIT_EPSILON: f32 = 0.001; +pub const UNIT_EPSILON: f32 = SETTLE_UNIT; /// One scalar driven by a damped spring toward `target`. Retargeting keeps /// `value` and `velocity` (units per second), so an interrupted animation @@ -151,25 +178,56 @@ impl SpringValue { Self { value, velocity: 0., target: value } } - /// Advances `dt` seconds with fixed 1/480 s substeps (semi-implicit - /// Euler): stable at any frame rate. `dt` is capped at 0.1 s so a - /// stalled frame never teleports. + /// Advances `dt` seconds with the closed-form solution of the damped + /// harmonic oscillator (under-, critically or over-damped) from the + /// current displacement and velocity, so the path is exact and the same + /// at every frame rate: two 60 Hz frames land where four 120 Hz frames + /// do. `dt` is capped at 0.1 s so a stalled frame never teleports. pub fn step(&mut self, dt: f64, p: SpringParameters) { - let (k, c) = (p.stiffness(), p.damping()); - let mut remaining = dt.clamp(0., MAX_FRAME); - let (mut x, mut v, t) = (self.value as f64, self.velocity as f64, self.target as f64); - while remaining > 0. { - let h = remaining.min(SUBSTEP); - v += (-k * (x - t) - c * v) * h; - x += v * h; - remaining -= h; + let t = dt.clamp(0., MAX_FRAME); + if t <= 0. { + return; } - self.value = x as f32; + let target = f64::from(self.target); + let (x, v) = Self::solve(f64::from(self.value) - target, f64::from(self.velocity), t, p); + self.value = (target + x) as f32; self.velocity = v as f32; } + /// Displacement from the target and velocity `time` seconds after a + /// state with displacement `x0` and velocity `v0`, for `p` (mass 1). + /// The one spring solution every port uses (macOS calls it through + /// cmux-app-ffi). + pub fn solve(x0: f64, v0: f64, time: f64, p: SpringParameters) -> (f64, f64) { + let w0 = p.omega(); + let zeta = p.damping_fraction.max(0.); + let t = time; + if (zeta - 1.).abs() < 1e-4 { + let decay = (-w0 * t).exp(); + let b = v0 + w0 * x0; + return ((x0 + b * t) * decay, (v0 - w0 * b * t) * decay); + } + if zeta < 1. { + let wd = w0 * (1. - zeta * zeta).sqrt(); + let decay = (-zeta * w0 * t).exp(); + let (s, c) = (wd * t).sin_cos(); + let b = (v0 + zeta * w0 * x0) / wd; + return ( + decay * (x0 * c + b * s), + decay * (v0 * c - (w0 * w0 * x0 + zeta * w0 * v0) / wd * s), + ); + } + let root = (zeta * zeta - 1.).sqrt(); + let (r1, r2) = (-w0 * (zeta - root), -w0 * (zeta + root)); + let c2 = (v0 - r1 * x0) / (r2 - r1); + let c1 = x0 - c2; + let (e1, e2) = ((r1 * t).exp(), (r2 * t).exp()); + (c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2) + } + pub fn is_settled(&self, epsilon: f32) -> bool { - (self.value - self.target).abs() < epsilon && self.velocity.abs() < epsilon * 8. + (self.value - self.target).abs() < epsilon + && self.velocity.abs() < epsilon * SETTLE_VELOCITY_FACTOR } pub fn is_moving(&self) -> bool { @@ -201,6 +259,35 @@ impl SpringValue { } } +/// `SpringValue::advance` on f64 state, for hosts whose geometry is f64 +/// (macOS `CGFloat`, through cmux-app-ffi): steps `dt` seconds in closed +/// form and snaps to `target` once within `epsilon` and moving slower than +/// `epsilon` times the settle velocity factor. True while still moving. +pub fn advance_spring( + value: &mut f64, + velocity: &mut f64, + target: f64, + dt: f64, + p: SpringParameters, + epsilon: f64, +) -> bool { + if *value == target && *velocity == 0. { + return false; + } + let t = dt.clamp(0., MAX_FRAME); + if t > 0. { + let (x, v) = SpringValue::solve(*value - target, *velocity, t, p); + (*value, *velocity) = (target + x, v); + } + if (*value - target).abs() < epsilon + && velocity.abs() < epsilon * f64::from(SETTLE_VELOCITY_FACTOR) + { + (*value, *velocity) = (target, 0.); + return false; + } + true +} + /// What a `Spring` animates. It decides whether a move toward 0 is a /// collapse (faster `disappear`) or an ordinary move. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] @@ -365,477 +452,3 @@ impl Spring { } } } - -// --------------------------------------------------------------------------- -// Timed fades - -/// Control points of Core Animation's `easeOut` -/// (`CAMediaTimingFunction(name: .easeOut)`, the curve of cmux-next's -/// `Motion.fadeCurve`): a cubic Bezier from (0, 0) to (1, 1) through -/// (0, 0) and (0.58, 1). -pub const EASE_OUT_CONTROL_POINTS: [f64; 4] = [0., 0., 0.58, 1.]; - -/// Core Animation's `easeOut`, used by every timed fade: the progress at -/// time fraction `t` (0..=1). Solves the curve's x for `t` (Newton, then -/// bisection), then evaluates its y; within 1e-5 of `CAMediaTimingFunction` -/// (whose own solver stops at a coarser tolerance: 7e-6 at most here). -pub fn ease_out(t: f32) -> f32 { - let [x1, y1, x2, y2] = EASE_OUT_CONTROL_POINTS; - cubic_bezier(f64::from(t.clamp(0., 1.)), x1, y1, x2, y2) as f32 -} - -/// Control points of Core Animation's `easeInEaseOut` -/// (`CAMediaTimingFunction(name: .easeInEaseOut)`): a cubic Bezier from -/// (0, 0) to (1, 1) through (0.42, 0) and (0.58, 1). cmux-next uses it for -/// the hover marquee's scroll (`Motion.marqueeAnimation`). -pub const EASE_IN_EASE_OUT_CONTROL_POINTS: [f64; 4] = [0.42, 0., 0.58, 1.]; - -/// Core Animation's `easeInEaseOut` at time fraction `t` (0..=1), solved -/// like `ease_out`; within 1e-5 of `CAMediaTimingFunction`. -pub fn ease_in_ease_out(t: f32) -> f32 { - let [x1, y1, x2, y2] = EASE_IN_EASE_OUT_CONTROL_POINTS; - cubic_bezier(f64::from(t.clamp(0., 1.)), x1, y1, x2, y2) as f32 -} - -/// A CSS / Core Animation timing curve through (0, 0), (x1, y1), (x2, y2), -/// (1, 1) at time fraction `t` (0..=1; x1 and x2 within 0..=1). -fn cubic_bezier(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { - if t <= 0. { - return 0.; - } - if t >= 1. { - return 1.; - } - // B(s) = 3(1-s)^2 s p1 + 3(1-s) s^2 p2 + s^3, as a polynomial in s. - let coefficients = |p1: f64, p2: f64| { - let c = 3. * p1; - let b = 3. * (p2 - p1) - c; - (1. - c - b, b, c) - }; - let (ax, bx, cx) = coefficients(x1, x2); - let (ay, by, cy) = coefficients(y1, y2); - let x = |s: f64| ((ax * s + bx) * s + cx) * s; - let dx = |s: f64| (3. * ax * s + 2. * bx) * s + cx; - let y = |s: f64| ((ay * s + by) * s + cy) * s; - const EPSILON: f64 = 1e-9; - let mut s = t; - for _ in 0..8 { - let error = x(s) - t; - if error.abs() < EPSILON { - return y(s); - } - let slope = dx(s); - if slope.abs() < 1e-12 { - break; - } - s -= error / slope; - } - // x is monotonic in s on 0..=1: bisection always converges. - let (mut lo, mut hi) = (0., 1.); - s = t; - for _ in 0..64 { - let v = x(s); - if (v - t).abs() < EPSILON { - break; - } - if v < t { - lo = s; - } else { - hi = s; - } - s = (lo + hi) / 2.; - } - y(s) -} - -/// A timed opacity or color change with ease-out. Retargeting restarts the -/// curve from the value on screen (as AppKit does for timed animators), so -/// a fade never jumps and never queues. -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(C)] -pub struct Fade { - pub token: MotionFade, - from: f32, - target: f32, - value: f32, - elapsed: f64, - duration: f64, -} - -impl Fade { - pub fn new(value: f32, token: MotionFade) -> Self { - Self { token, from: value, target: value, value, elapsed: 0., duration: 0. } - } - - pub fn value(&self) -> f32 { - self.value - } - - pub fn target(&self) -> f32 { - self.target - } - - pub fn is_moving(&self) -> bool { - self.value != self.target - } - - /// Starts toward `target` from the current value with this token's - /// duration under `policy` (0 applies at once). - pub fn set_target(&mut self, target: f32, policy: &MotionPolicy) { - self.set_target_with(target, self.token, policy); - } - - /// As `set_target`, with another token (e.g. `fadeIn` vs `fadeOut`). - pub fn set_target_with(&mut self, target: f32, token: MotionFade, policy: &MotionPolicy) { - if target == self.target && self.is_moving() { - return; - } - self.token = token; - self.from = self.value; - self.target = target; - self.elapsed = 0.; - self.duration = policy.fade(token); - if self.duration <= 0. { - self.value = target; - } - } - - pub fn snap_to(&mut self, target: f32) { - self.from = target; - self.target = target; - self.value = target; - } - - /// Advances `dt` seconds. True while still moving. - pub fn step(&mut self, dt: f64) -> bool { - if !self.is_moving() { - return false; - } - self.elapsed += dt.clamp(0., MAX_FRAME); - if self.duration <= 0. || self.elapsed >= self.duration { - self.value = self.target; - return false; - } - let t = self.token.curve().progress((self.elapsed / self.duration) as f32); - self.value = self.from + (self.target - self.from) * t; - true - } -} - -// --------------------------------------------------------------------------- -// Tokens - -/// `ui.animationSpeed`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -#[repr(u8)] -pub enum MotionSpeed { - /// Default: short springs tuned for a pro tool. - #[default] - Fast, - /// About Apple's system pacing: every time constant x1.5. - Normal, - /// No animation: every change applies in one frame, no crossfade. - Off, -} - -impl MotionSpeed { - /// Multiplier on every spring response and fade duration. - pub fn time_scale(self) -> f64 { - match self { - Self::Fast => 1., - Self::Normal => 1.5, - Self::Off => 0., - } - } - - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "fast" => Some(Self::Fast), - "normal" => Some(Self::Normal), - "off" | "none" | "0" => Some(Self::Off), - _ => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Fast => "fast", - Self::Normal => "normal", - Self::Off => "off", - } - } -} - -/// Spring tokens (values at speed `fast`). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MotionSpring { - /// An existing item moves or resizes: tab reflow and reorder, sidebar - /// rows, pane and divider frames, pane zoom, sidebar drag gap. - Move, - /// Something appears or expands: tab grow-in, group expand, row insert, - /// palette scale-in. - Appear, - /// Something disappears or collapses. Faster than `Appear`. - Disappear, - /// Release after direct manipulation; carries pointer velocity and - /// overshoots slightly (0.8 pt on 200 pt). - Settle, - /// Programmatic scroll: tab strip reveal, column reveal, wheel, fling. - Scroll, - /// Screen switch slide. - Screen, - /// An overlay tracking the pointer between targets: drop zones, ghost. - Track, - /// Selection indicator glide (sidebar pill). - Selection, - /// Floating panel slide (hover card). - Panel, - /// Home list pin drag: the tiles making room, the rows below the grid - /// and a dropped tile landing (MessagesLab `SidebarPinDragging.spring`: - /// `CASpringAnimation` mass 1, stiffness 320, damping 30). Overshoots - /// about 1.6 pt on 200 pt. - PinDrag, -} - -impl MotionSpring { - pub const ALL: [Self; 10] = [ - Self::Move, - Self::Appear, - Self::Disappear, - Self::Settle, - Self::Scroll, - Self::Screen, - Self::Track, - Self::Selection, - Self::Panel, - Self::PinDrag, - ]; - - pub const fn base(self) -> SpringParameters { - match self { - Self::Move => SpringParameters::new(0.20, 0.90), - Self::Appear => SpringParameters::new(0.18, 0.90), - Self::Disappear => SpringParameters::new(0.15, 0.90), - Self::Settle => SpringParameters::new(0.22, 0.85), - Self::Scroll => SpringParameters::new(0.22, 0.90), - Self::Screen => SpringParameters::new(0.22, 0.90), - Self::Track => SpringParameters::new(0.12, 0.90), - Self::Selection => SpringParameters::new(0.15, 0.90), - Self::Panel => SpringParameters::new(0.18, 0.85), - // SidebarPinDragging.swift `spring`: mass 1, stiffness 320, - // damping 30, so response 2 pi / sqrt(320) and damping fraction - // 30 / (2 sqrt(320)). - Self::PinDrag => { - SpringParameters::new(0.351_240_736_552_036_3, 0.838_525_491_562_421_1) - } - } - } -} - -/// Timed opacity and color changes (ease-out), seconds at speed `fast`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MotionFade { - /// Hover fills and hover-revealed buttons. - Hover, - /// Focus ring and inactive-pane dim. - Focus, - /// A view or panel fades in. - FadeIn, - /// A view or panel fades out. Faster than `FadeIn`. - FadeOut, - /// Content swap in place; also the Reduce Motion ceiling. - Crossfade, - /// Drag lift shadow. - Lift, - /// Theme switch recoloring in place. - Theme, - /// A highlighted row fading out after a jump to it (cmux-next: the - /// Settings row after a search jump or deep link). - Highlight, - /// The launch mark resolving on a window still connecting (cmux-next - /// `LaunchMarkView`, `Motion.revealLaunchMark`). - Launch, - /// Home list pin drag: the tile lifting to `SidebarPinDragState.lift` - /// (MessagesLab `beginPinDrag`, `transform.scale` over 0.15 s). - PinLift, - /// Home list pin drag: a dropped tile scaling back from the lift - /// (`landPinDrag`, `transform.scale` over 0.25 s). - PinSettle, - /// Home list pin drag: a ghost that is not a tile now shrinking onto - /// its row's avatar and fading out (`landPinDrag`, a 0.22 s transaction). - PinShrink, -} - -impl MotionFade { - pub const ALL: [Self; 12] = [ - Self::Hover, - Self::Focus, - Self::FadeIn, - Self::FadeOut, - Self::Crossfade, - Self::Lift, - Self::Theme, - Self::Highlight, - Self::Launch, - Self::PinLift, - Self::PinSettle, - Self::PinShrink, - ]; - - pub const fn base(self) -> f64 { - match self { - Self::Hover => 0.08, - Self::Focus => 0.10, - Self::FadeIn => 0.12, - Self::FadeOut => 0.08, - Self::Crossfade => 0.10, - Self::Lift => 0.12, - Self::Theme => 0.16, - // MotionTunables.swift:38-39 (fadeDefaults .highlight, .launch). - Self::Highlight => 1.2, - Self::Launch => 0.24, - // SidebarPinDragging.swift: lift.duration, settle.duration, - // setAnimationDuration (beginPinDrag, landPinDrag); the curves - // are `curve`. - Self::PinLift => 0.15, - Self::PinSettle => 0.25, - Self::PinShrink => 0.22, - } - } -} - -impl MotionFade { - /// The token's timing curve. Every fade eases out (cmux-next - /// `Motion.fadeCurve`) but MessagesLab's pin drag scales, which run - /// linear: `beginPinDrag`'s lift and `landPinDrag`'s settle are - /// `CABasicAnimation`s with no timing function (Core Animation paces - /// those linearly). `pinShrink` keeps ease-out: it is an implicit - /// animation in a `CATransaction` with no timing function set. - pub const fn curve(self) -> MotionCurve { - match self { - Self::PinLift | Self::PinSettle => MotionCurve::Linear, - _ => MotionCurve::EaseOut, - } - } -} - -/// A timed fade's curve (`MotionFade::curve`). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MotionCurve { - /// Core Animation's `easeOut` (`ease_out`). - EaseOut, - /// Core Animation's `linear` (a `CABasicAnimation` without a timing function). - Linear, -} - -impl MotionCurve { - /// The progress at time fraction `t` (0..=1). - pub fn progress(self, t: f32) -> f32 { - match self { - Self::EaseOut => ease_out(t), - Self::Linear => t.clamp(0., 1.), - } - } -} - -/// Repeating indicators. Speed does not scale them; `off` and Reduce Motion -/// stop them. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MotionLoop { - Spinner, - Pulse, - Flash, -} - -impl MotionLoop { - pub const fn period(self) -> f64 { - match self { - Self::Spinner => 0.9, - Self::Pulse => 1.8, - Self::Flash => 0.6, - } - } -} - -/// Resolves tokens for one speed setting and Reduce Motion state. -/// -/// - `off`: nothing animates. Wins over Reduce Motion. -/// - Reduce Motion: movement (position, size, scale, scroll) is instant; -/// fades stay, capped at one crossfade (0.1 s); loops stop. -/// - `normal`: every time constant is 1.5x `fast`; damping is unchanged. -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(C)] -pub struct MotionPolicy { - pub speed: MotionSpeed, - pub reduce_motion: bool, - /// Extra time multiplier for screen recordings (`CMUX2_ANIM_SCALE`); 1 - /// normally. Not part of the cmux-next contract. - pub debug_scale: f64, -} - -impl Default for MotionPolicy { - fn default() -> Self { - Self::new(MotionSpeed::Fast, false) - } -} - -impl MotionPolicy { - pub fn new(speed: MotionSpeed, reduce_motion: bool) -> Self { - Self { speed, reduce_motion, debug_scale: 1. } - } - - pub(crate) fn scale(&self) -> f64 { - self.speed.time_scale() * self.debug_scale.max(1.) - } - - /// Position, size, scale and scroll animate. When false, snap. - pub fn animates_movement(&self) -> bool { - self.speed != MotionSpeed::Off && !self.reduce_motion - } - - /// Opacity and color changes animate. When false, apply at once. - pub fn animates_fades(&self) -> bool { - self.speed != MotionSpeed::Off - } - - /// Spinners and pulses run. - pub fn animates_loops(&self) -> bool { - self.speed != MotionSpeed::Off && !self.reduce_motion - } - - /// The spring for `token`. `off` returns the `fast` spring so a caller - /// that steps anyway never divides by zero; callers snap when - /// `animates_movement` is false (`Spring::step` does). - pub fn spring(&self, token: MotionSpring) -> SpringParameters { - if self.speed == MotionSpeed::Off { - token.base() - } else { - token.base().scaled(self.scale()) - } - } - - /// Seconds for a fade; 0 applies at once. Reduce Motion caps it at one - /// crossfade. - pub fn fade(&self, token: MotionFade) -> f64 { - if !self.animates_fades() { - return 0.; - } - let scaled = token.base() * self.scale(); - if self.reduce_motion { scaled.min(MotionFade::Crossfade.base()) } else { scaled } - } - - /// Seconds that stand in for a spring when only a timed API exists: - /// the spring's visible end. 0 when movement does not animate. - pub fn spring_duration(&self, token: MotionSpring) -> f64 { - if self.animates_movement() { self.spring(token).visible_end() } else { 0. } - } - - /// Period of a loop, or `None` when loops are stopped. - pub fn period(&self, token: MotionLoop) -> Option { - self.animates_loops().then(|| token.period()) - } -} diff --git a/cmux-tui/crates/cmux-motion/src/tokens.rs b/cmux-tui/crates/cmux-motion/src/tokens.rs new file mode 100644 index 000000000000..e5f3adcf4807 --- /dev/null +++ b/cmux-tui/crates/cmux-motion/src/tokens.rs @@ -0,0 +1,365 @@ +//! Tokens: speed, springs, fades, loops, the bounce setting and the +//! per-kind switches. The numbers come from the spec's design-tokens.json +//! (`motion`), compiled in by build.rs; no timing constant lives here. + +use crate::{SpringParameters, ease_out}; + +// SPEED_NORMAL, SPRINGS, STRUCTURAL, SUBTLE_BOUNCE, DEFAULT_BOUNCE, +// DEFAULT_KINDS, SETTLE_*, FADES, LOOPS. + +include!(concat!(env!("OUT_DIR"), "/tokens.rs")); + +// --------------------------------------------------------------------------- +// Tokens + +/// `ui.animationSpeed`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum MotionSpeed { + /// Default: short springs tuned for a pro tool. + #[default] + Fast, + /// About Apple's system pacing: every time constant x1.5. + Normal, + /// No animation: every change applies in one frame, no crossfade. + Off, +} + +impl MotionSpeed { + /// Multiplier on every spring response and fade duration. + pub fn time_scale(self) -> f64 { + match self { + Self::Fast => 1., + Self::Normal => SPEED_NORMAL, + Self::Off => 0., + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "fast" => Some(Self::Fast), + "normal" => Some(Self::Normal), + "off" | "none" | "0" => Some(Self::Off), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Fast => "fast", + Self::Normal => "normal", + Self::Off => "off", + } + } +} + +/// Spring tokens (values at speed `fast`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionSpring { + /// An existing item moves or resizes: tab reflow and reorder, sidebar + /// rows, pane and divider frames, pane zoom, sidebar drag gap. + Move, + /// Something appears or expands: tab grow-in, group expand, row insert, + /// palette scale-in. + Appear, + /// Something disappears or collapses. Faster than `Appear`. + Disappear, + /// Release after direct manipulation; carries pointer velocity and + /// overshoots slightly (0.8 pt on 200 pt). + Settle, + /// Programmatic scroll: tab strip reveal, column reveal, wheel, fling. + Scroll, + /// Screen switch slide. + Screen, + /// An overlay tracking the pointer between targets: drop zones, ghost. + Track, + /// Selection indicator glide (sidebar pill). + Selection, + /// Floating panel slide (hover card). + Panel, + /// Home list pin drag: the tiles making room, the rows below the grid + /// and a dropped tile landing (MessagesLab `SidebarPinDragging.spring`: + /// `CASpringAnimation` mass 1, stiffness 320, damping 30). Overshoots + /// about 1.6 pt on 200 pt. + PinDrag, + /// A pane zooming to fill its screen: it owns the motion, its content + /// takes the full size once and the spring rect reveals it (Leo + /// 2026-10-13). + Zoom, + /// The panes a zoom leaves fading out, faster than anything arrives + /// (macOS `Motion.exit(nearness:)` lengthens it up to 25% for the + /// panes nearer the zoomed one, so the farthest is gone first). + Exit, +} + +impl MotionSpring { + pub const ALL: [Self; 12] = [ + Self::Move, + Self::Appear, + Self::Disappear, + Self::Settle, + Self::Scroll, + Self::Screen, + Self::Track, + Self::Selection, + Self::Panel, + Self::PinDrag, + Self::Zoom, + Self::Exit, + ]; + + /// The token's tuning at speed `fast` (design-tokens.json + /// `motion.springs`). Structural tokens are resolved further by + /// `MotionPolicy::spring` (the bounce setting). + pub const fn base(self) -> SpringParameters { + SPRINGS[self as usize] + } + + /// Structure: things that take or give up room (`move`, `appear`, + /// `disappear`; design-tokens.json `motion.structural`). These follow + /// the bounce setting (`MotionBounce`); release, scroll and overlay + /// springs keep their own damping. + pub const fn is_structural(self) -> bool { + STRUCTURAL[self as usize] + } +} + +/// Timed opacity and color changes (ease-out), seconds at speed `fast`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionFade { + /// Hover fills and hover-revealed buttons. + Hover, + /// Focus ring and inactive-pane dim. + Focus, + /// A view or panel fades in. + FadeIn, + /// A view or panel fades out. Faster than `FadeIn`. + FadeOut, + /// Content swap in place; also the Reduce Motion ceiling. + Crossfade, + /// Drag lift shadow. + Lift, + /// Theme switch recoloring in place. + Theme, + /// A highlighted row fading out after a jump to it (cmux-next: the + /// Settings row after a search jump or deep link). + Highlight, + /// The launch mark resolving on a window still connecting (cmux-next + /// `LaunchMarkView`, `Motion.revealLaunchMark`). + Launch, + /// Home list pin drag: the tile lifting to `SidebarPinDragState.lift` + /// (MessagesLab `beginPinDrag`, `transform.scale` over 0.15 s). + PinLift, + /// Home list pin drag: a dropped tile scaling back from the lift + /// (`landPinDrag`, `transform.scale` over 0.25 s). + PinSettle, + /// Home list pin drag: a ghost that is not a tile now shrinking onto + /// its row's avatar and fading out (`landPinDrag`, a 0.22 s transaction). + PinShrink, +} + +impl MotionFade { + pub const ALL: [Self; 12] = [ + Self::Hover, + Self::Focus, + Self::FadeIn, + Self::FadeOut, + Self::Crossfade, + Self::Lift, + Self::Theme, + Self::Highlight, + Self::Launch, + Self::PinLift, + Self::PinSettle, + Self::PinShrink, + ]; + + /// Seconds at speed `fast` (design-tokens.json `motion.fades`). + pub const fn base(self) -> f64 { + FADES[self as usize] + } +} + +impl MotionFade { + /// The token's timing curve. Every fade eases out (cmux-next + /// `Motion.fadeCurve`) but MessagesLab's pin drag scales, which run + /// linear: `beginPinDrag`'s lift and `landPinDrag`'s settle are + /// `CABasicAnimation`s with no timing function (Core Animation paces + /// those linearly). `pinShrink` keeps ease-out: it is an implicit + /// animation in a `CATransaction` with no timing function set. + pub const fn curve(self) -> MotionCurve { + match self { + Self::PinLift | Self::PinSettle => MotionCurve::Linear, + _ => MotionCurve::EaseOut, + } + } +} + +/// A timed fade's curve (`MotionFade::curve`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionCurve { + /// Core Animation's `easeOut` (`ease_out`). + EaseOut, + /// Core Animation's `linear` (a `CABasicAnimation` without a timing function). + Linear, +} + +impl MotionCurve { + /// The progress at time fraction `t` (0..=1). + pub fn progress(self, t: f32) -> f32 { + match self { + Self::EaseOut => ease_out(t), + Self::Linear => t.clamp(0., 1.), + } + } +} + +/// Repeating indicators. Speed does not scale them; `off` and Reduce Motion +/// stop them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionLoop { + Spinner, + Pulse, + Flash, +} + +impl MotionLoop { + /// Seconds per cycle (design-tokens.json `motion.loops`). + pub const fn period(self) -> f64 { + LOOPS[self as usize] + } +} + +/// `ui.animations.bounce`: how springy structural motion is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +#[repr(u8)] +pub enum MotionBounce { + /// Critically damped: a tab, row or pane never passes its target. The + /// default (design-tokens.json `motion.bounce.default`). + #[default] + None, + /// SwiftUI bounce `motion.bounce.subtle` (0.15): about 1% overshoot. + Subtle, +} + +impl MotionBounce { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "none" => Some(Self::None), + "subtle" => Some(Self::Subtle), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Subtle => "subtle", + } + } + + /// SwiftUI bounce of a structural spring under this setting. + pub const fn amount(self) -> f64 { + match self { + Self::None => 0., + Self::Subtle => SUBTLE_BOUNCE, + } + } +} + +/// The structural changes a user turns animation on for one by one, under +/// `ui.animationSpeed`, one per cmux.json key (`MotionKind::config_path`): +/// `ui.paneAnimations`, `ui.animations.newTab`, `ui.animations.newWorkspace`. +/// All off by default (design-tokens.json `motion.kinds`): the change +/// applies in one frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionKind { + /// `ui.paneAnimations`: pane layout (split grow-in, close, move, swap, + /// zoom and unzoom, column reveal, screen switch, a divider released). + PaneAnimations, + /// A new tab growing into its slot (every entry point). + NewTab, + /// A new workspace row opening in the sidebar. + NewWorkspace, +} + +impl MotionKind { + pub const ALL: [Self; 3] = [Self::PaneAnimations, Self::NewTab, Self::NewWorkspace]; + + /// The setting's last key (`paneAnimations`, `newTab`, `newWorkspace`). + pub fn as_str(self) -> &'static str { + match self { + Self::PaneAnimations => "paneAnimations", + Self::NewTab => "newTab", + Self::NewWorkspace => "newWorkspace", + } + } + + /// The full cmux.json path of the kind's switch. + pub fn config_path(self) -> &'static str { + match self { + Self::PaneAnimations => "ui.paneAnimations", + Self::NewTab => "ui.animations.newTab", + Self::NewWorkspace => "ui.animations.newWorkspace", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.as_str() == s.trim()) + } +} + +/// The motion settings: one switch per `MotionKind` (`ui.paneAnimations`, +/// `ui.animations.newTab`, `ui.animations.newWorkspace`) and the bounce +/// (`ui.animations.bounce`). +/// The host reads its settings file and passes these in +/// (`set_animation_switches`, or `MotionPolicy::with_animations`); this +/// crate never reads a settings file. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(C)] +pub struct AnimationSwitches { + pub pane_animations: bool, + pub new_tab: bool, + pub new_workspace: bool, + pub bounce: MotionBounce, +} + +impl AnimationSwitches { + /// The spec defaults (design-tokens.json `motion.kinds`, `motion.bounce`). + pub const DEFAULT: Self = Self { + pane_animations: DEFAULT_KINDS[0], + new_tab: DEFAULT_KINDS[1], + new_workspace: DEFAULT_KINDS[2], + bounce: DEFAULT_BOUNCE, + }; + + /// `kind`'s switch is on. + pub const fn enabled(&self, kind: MotionKind) -> bool { + match kind { + MotionKind::PaneAnimations => self.pane_animations, + MotionKind::NewTab => self.new_tab, + MotionKind::NewWorkspace => self.new_workspace, + } + } + + /// The same switches with `kind` set to `on`. + pub fn with(mut self, kind: MotionKind, on: bool) -> Self { + match kind { + MotionKind::PaneAnimations => self.pane_animations = on, + MotionKind::NewTab => self.new_tab = on, + MotionKind::NewWorkspace => self.new_workspace = on, + } + self + } +} + +impl Default for AnimationSwitches { + fn default() -> Self { + Self::DEFAULT + } +} diff --git a/plans/cmux-next/motion.md b/plans/cmux-next/motion.md index a98dd744e15b..cd8cca9ef78c 100644 --- a/plans/cmux-next/motion.md +++ b/plans/cmux-next/motion.md @@ -9,6 +9,68 @@ Cmd-Shift-D splits applied in one frame until 2026-10-10, when Leo asked for panes that grow from their edge (cx-f6i7); the sidebar is only shown or hidden; tab animations must be fast and smooth. +## Redesign (hq-1d, 2026-10-12) + +Lawrence: "new tab animation and new workspace animation needs to be redone +from first principles", "ensure there's option to toggle animations of +splits/new tabs", "better curves for animations of splits, think how apple +would do it, spring curves", "proper physics". Design: +`.cmux-scratch/motion-redesign/DESIGN.md` in hq (principles P1 to P7). + +- Physics: `SpringValue.step` is the closed-form damped harmonic + oscillator (under-, critically and over-damped), exact at any frame rate, + retargetable with its velocity. `SpringParameters(duration:bounce:)` is + SwiftUI's `Spring(duration:bounce:)`. +- One source (hq-1d D2, Lawrence: "decouple from swift app as much as + possible"): the tokens live in the spec's design-tokens.json `motion` + (springs, fades, loops, speed, settle thresholds, the structural set, the + bounce and the per-kind defaults). cmux-tui/crates/cmux-motion reads them + at build time (build.rs) and owns the closed-form step + (`SpringValue::solve`, `advance_spring`), the structural bounce rule + (`MotionPolicy::spring`) and the switches (`AnimationSwitches`, + `set_animation_switches`); cmux-app-ffi exposes them to macOS as + `cmux_motion_*` (include/cmux_motion_ffi.h). Until the app's FFI pin + carries that ABI, the Swift `SpringValue` keeps the same solution and + `scripts/cmux-next/motion-parity.py` compares its paths with + cmux-motion's (`debug.motion {action: "trajectory"}` vs the + `motion_trajectories` example). +- One clock: `MotionClock` is the one chrome animation client of a window's + `FrameScheduler`. The tab strips, the layout (panes, dividers, columns, + screens, drop zones) step on it with the same `dt` and it goes idle with + the last spring (`debug.motion` `display_links` is 0 at rest). +- Bounce: structural tokens (`move`, `appear`, `disappear`) are critically + damped by default (never past the target); `ui.animations.bounce` + "subtle" gives them bounce `motion.spring.subtleBounce` (0.15). +- Per kind, under `ui.animationSpeed`, all off by default (Lawrence + 2026-10-14: no big animations by default): `ui.paneAnimations` (pane + layout, rule 9), `ui.animations.newTab`, `ui.animations.newWorkspace`. + Off applies that change in one step; true opts into its spring. cmux-motion + `MotionKind` / `AnimationSwitches` carry exactly these three keys + (`MotionKind::config_path`). Palette: Toggle New Tab / New Workspace + Animations, Use Bouncy Animations, Use Animations Without Bounce. +- Reduce Motion: no movement and no crossfade standing in for it; chrome + takes its new places in one step (the tab strip and sidebar crossfades are + gone). +- Splits (only with `ui.paneAnimations` on): every pane edge and divider of a split, a close or a released + divider springs together from rest (or from the release velocity, in + fractions of the travel), so the change is one spring on the divider + fraction. A pane that changes size keeps one host frame for the motion + (`SplitMotion.heldFrame`) and is clipped to the presented rect: content + lays out once. A released divider flung onto a snap within + `motion.divider.releaseProjection` of its velocity lands there. +- New tab (only with `ui.animations.newTab` on; off, every entry point shows + the tab in its final slot at once, and a closed tab's room closes at once + too; the store's tab takes its provisional stand-in's cell in place, so a + new tab is one element): Cmd-T, +, New Terminal Tab and the socket all grow the tab in its + slot with the strip's spring; what a New Tab page turns into takes the + page's cell in place (`TabStripView.expectReplacement(of:)`). +- New workspace: the row opens at its index and the rows below slide; the + whole-sidebar snap for Cmd-N is gone, and rows open only on animated + applies. +- Check (DESIGN P1): `debug.new_tab` frame_strip asserts content in one step + (`content_intermediate`, `blank`) and chrome continuous (`moving`, + `reversals`, `jumps`, `max_step`). Proof: `scripts/cmux-next/motion-proof.py`. + ## Rules 1. One module. Every animation asks `Motion` for a token. No module @@ -40,9 +102,10 @@ shown or hidden; tab animations must be fast and smooth. `Motion.animateTimed`, the token's visible-end duration with ease-out; AppKit starts a new one from the value on screen. 7. Reduce Motion (`NSWorkspace.accessibilityDisplayShouldReduceMotion`): - movement (position, size, scale, scroll) is instant; opacity changes stay - as a crossfade of at most 0.1 s, and hover and pressed fills are - instant; spinners and pulses stop and show a static glyph. + movement (position, size, scale, scroll) is instant, and a structural + change (tab, row, pane) is not replaced by a crossfade; other opacity + changes stay as a crossfade of at most 0.1 s, and hover and pressed fills + are instant; spinners and pulses stop and show a static glyph. 8. `ui.animationSpeed` in cmux.json: `"fast"` (default), `"normal"` (every time constant x1.5, about Apple's system pacing), `"off"` (every change in one frame, no crossfade, no loops). Palette actions: Use Fast Animations, @@ -79,11 +142,11 @@ nxmot (MacBook Pro, 120 Hz). | Token | Response / damping | Visible end | Rest | Used by | | --- | --- | --- | --- | --- | -| `move` | 0.20 / 0.90 | 192 ms | 250 ms | tab reflow and reorder, sidebar row moves, inserts (a row grows in its own slot) and removals (it shrinks in place), all on this one spring (cx-bqm6), pane and divider frames (ratio, equalize, unzoom), sidebar drag gap | +| `move` | 0.15 / 1.00 | 183 ms | 258 ms | tab reflow and reorder, sidebar row moves, inserts (a row grows in its own slot) and removals (it shrinks in place), all on this one spring (cx-bqm6), pane and divider frames (ratio, equalize, unzoom), sidebar drag gap | | `zoom` | 0.32 / 0.90 | 292 ms | 375 ms | a pane zooming to fill its screen: its terminal takes the full size once and the spring rect reveals it (Leo 2026-10-13) | | `exit` | 0.12 / 1.00 | 158 ms | 233 ms | the panes a zoom leaves fading out; `Motion.exit(nearness:)` gives the farthest (the 2x2 opposite corner) this and neighbours up to 25% longer, so the opposite corner is gone first (under 0.05 opacity by 100 ms) | -| `appear` | 0.18 / 0.90 | 175 ms | 225 ms | palette scale-in (from `Motion.panelOpenScale` 0.97 about the panel center), tab grow-in, tab group expand, sidebar show (timed equivalent), browser toolbar show, ghost card/inline morph | -| `disappear` | 0.15 / 0.90 | 142 ms | 200 ms | tab close (width to 0), group collapse, sidebar hide (timed equivalent), browser toolbar hide | +| `appear` | 0.135 / 1.00 | 167 ms | 233 ms | palette scale-in (from `Motion.panelOpenScale` 0.97 about the panel center), tab grow-in, tab group expand, sidebar show (timed equivalent), browser toolbar show, ghost card/inline morph | +| `disappear` | 0.11 / 1.00 | 133 ms | 200 ms | tab close (width to 0), group collapse, sidebar hide (timed equivalent), browser toolbar hide | | `settle` | 0.22 / 0.85 | 175 ms | 342 ms | release after drag: tab drop, drag ghost landing, sidebar row drop (carries pointer velocity; 0.8 pt overshoot on 200 pt) | | `scroll` | 0.22 / 0.90 | 208 ms | 267 ms | tab strip reveal, strip column reveal, wheel notch, trackpad fling snap | | `screen` | 0.22 / 0.90 | 208 ms | 267 ms | screen switch slide | @@ -129,16 +192,18 @@ not change. Loops keep their period (they show state, not transitions). ## Why these values -- Damping 0.9, not 1. A critically damped spring spends its last third - crawling through sub-pixel distances: at response 0.22 it covers 95% in - 167 ms but reaches its last visible pixel at 269 ms. Damping 0.9 - overshoots 0.05% (0.1 pt on 200 pt, invisible after pixel snapping) and - ends visibly at 200 ms. Apple's own `.snappy` and UIKit's default - interactive spring are under-damped for the same reason. +- Structural springs (`move`, `appear`, `disappear`) are critically damped + (hq-1d D1, 2026-10-13): a tab, row or pane never passes its target, as + Apple's default `Spring(duration:bounce: 0)`. The long sub-pixel tail of + critical damping is paid with a shorter response instead of a lower + damping: `move` 0.15 s ends visibly at 183 ms (120 Hz), inside the 200 ms + budget the GPUI motion tests hold (225 ms tolerance). `ui.animations.bounce` + "subtle" (0.15, damping 0.85) is opt-in. Release, scroll and overlay + springs keep 0.85 to 0.9. - Response 0.15 to 0.22 s. Apple's system defaults (`.smooth` / `.snappy` at 0.5 s perceptual duration, NSAnimationContext 0.25 s) suit consumer apps; a tab strip is used hundreds of times an hour. Our `move` ends - visibly at 192 ms, with a spring's continuity: an interrupted move + visibly at 183 ms, with a spring's continuity: an interrupted move retargets instead of restarting. - `scroll` is a spring with stiffness 800 (response 0.222 s) and 0.9 damping; damping 1 leaves a slow sub-pixel tail. diff --git a/plans/cmux-next/spec-proposals/visuals/design-tokens.json b/plans/cmux-next/spec-proposals/visuals/design-tokens.json index 56077b5130ce..ab7b78bfb73d 100644 --- a/plans/cmux-next/spec-proposals/visuals/design-tokens.json +++ b/plans/cmux-next/spec-proposals/visuals/design-tokens.json @@ -2080,28 +2080,29 @@ "springs": { "move": { "value": { - "response": 0.2, - "dampingFraction": 0.9 + "response": 0.15, + "dampingFraction": 1 }, - "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:11 (MotionTunables.springDefaults)", + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", "unit": "s", - "use": "Tab reflow and reorder, sidebar row moves, pane and divider frames" + "use": "Tab reflow and reorder, sidebar row moves, pane and divider frames", + "note": "Structural (motion.structural): critically damped unless motion.bounce is subtle. Response 0.15 s ends visibly (within 0.5%) at 0.177 s, under the 0.2 s budget (hq-1d D1)." }, "appear": { "value": { - "response": 0.18, - "dampingFraction": 0.9 + "response": 0.135, + "dampingFraction": 1 }, - "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:12 (MotionTunables.springDefaults)", + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", "unit": "s", "use": "Palette scale-in, tab grow-in, group expand, row insert, sidebar show" }, "disappear": { "value": { - "response": 0.15, - "dampingFraction": 0.9 + "response": 0.11, + "dampingFraction": 1 }, - "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:13 (MotionTunables.springDefaults)", + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", "unit": "s", "use": "Tab close, group collapse, row removal, sidebar hide" }, @@ -2158,11 +2159,75 @@ "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:19 (MotionTunables.springDefaults)", "unit": "s", "use": "Hover card slide" + }, + "pinDrag": { + "value": { + "response": 0.3512407365520363, + "dampingFraction": 0.8385254915624211 + }, + "source": "cmux-tui/crates/cmux-motion (MotionSpring::PinDrag): MessagesLab SidebarPinDragging.spring, CASpringAnimation mass 1, stiffness 320, damping 30", + "unit": "s", + "use": "Home list pin drag: tiles making room, rows below the grid, a dropped tile landing" + }, + "zoom": { + "value": { + "response": 0.32, + "dampingFraction": 0.9 + }, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", + "unit": "s", + "use": "A pane zooming to fill its screen: its content takes the full size once and the spring rect reveals it (Leo 2026-10-13)" + }, + "exit": { + "value": { + "response": 0.12, + "dampingFraction": 1 + }, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", + "unit": "s", + "use": "The panes a zoom leaves fading out; Motion.exit(nearness:) lengthens it up to 25% for nearer panes, so the farthest (the 2x2 opposite corner) is gone first" } }, "springModel": { - "value": "omega = 2*pi/response; stiffness = omega^2; damping = 2*dampingFraction*omega; semi-implicit Euler at 1/480 s (SwiftUI .spring(response:dampingFraction:) equivalent)", - "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift:18-22,79-96 (SpringParameters.omega)" + "value": "omega = 2*pi/response; stiffness = omega^2; damping = 2*dampingFraction*omega (SwiftUI .spring(response:dampingFraction:), mass 1). Stepped in closed form from the current displacement AND velocity (exact damped harmonic oscillator, under-, critically or over-damped), so the path is identical at every frame rate; dt is capped at 0.1 s per frame. Retargeting keeps value and velocity. A spring rests (snaps to its target) once |value - target| < epsilon and |velocity| < epsilon * motion.settle.velocityFactor.", + "source": "cmux-tui/crates/cmux-motion/src/spring.rs (SpringValue::solve); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift (SpringValue.solve)" + }, + "settle": { + "value": { + "geometry": 0.25, + "unit": 0.001, + "velocityFactor": 8 + }, + "source": "cmux-tui/crates/cmux-motion/src/spring.rs (GEOMETRY_EPSILON, UNIT_EPSILON, SpringValue::is_settled)", + "unit": "pt (geometry), fraction (unit)", + "use": "Rest rule: 0.25 pt is invisible after pixel snapping at 2x; 0..1 values (opacity) use 0.001" + }, + "structural": { + "value": [ + "move", + "appear", + "disappear" + ], + "source": "cmux-tui/crates/cmux-motion (MotionSpring::is_structural); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift (MotionSpring.isStructural)", + "use": "Springs that take or give up room. They follow motion.bounce; release, scroll and overlay springs keep their own damping." + }, + "bounce": { + "value": { + "none": 0, + "subtle": 0.15, + "default": "none" + }, + "source": "ui.animations.bounce (cmux.json); cmux-tui/crates/cmux-motion (MotionBounce); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.subtleBounce)", + "use": "SwiftUI bounce of the structural springs: none = critically damped (never passes the target), subtle = damping fraction 1 - 0.15 (overshoots about 1%)." + }, + "kinds": { + "value": { + "paneAnimations": false, + "newTab": false, + "newWorkspace": false + }, + "source": "ui.paneAnimations / ui.animations.newTab / ui.animations.newWorkspace (cmux.json); cmux-tui/crates/cmux-motion (AnimationSwitches, MotionKind::config_path)", + "use": "Per-kind switches under ui.animationSpeed, all off by default (Lawrence 2026-10-14: no big animations by default): the change applies in one frame (no crossfade); true opts into its spring. paneAnimations: split grow-in, close, move, swap, zoom and unzoom, column reveal, screen switch, divider release. newTab: a tab growing into its slot. newWorkspace: a sidebar row opening." }, "fades": { "hover": { @@ -2171,6 +2236,12 @@ "unit": "s", "use": "Hover fills, hover-revealed buttons, divider hover" }, + "hoverOut": { + "value": 0, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.fadeDefaults)", + "unit": "s", + "use": "Hover and pressed fills clearing: 0 is instant" + }, "focus": { "value": 0.1, "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:32 (MotionTunables.fadeDefaults)", @@ -2218,6 +2289,38 @@ "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:39 (MotionTunables.fadeDefaults)", "unit": "s", "use": "Launch mark resolving on the glass of a window still connecting (Motion.revealLaunchMark); under 400 ms at normal speed" + }, + "clickPulse": { + "value": 0.25, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.fadeDefaults)", + "unit": "s", + "use": "Agent cursor click ripple grows and fades" + }, + "cursorHide": { + "value": 0.3, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.fadeDefaults)", + "unit": "s", + "use": "Agent cursor fades out once its agent goes idle" + }, + "pinLift": { + "value": 0.15, + "source": "cmux-tui/crates/cmux-motion (MotionFade): MessagesLab SidebarPinDragging.swift", + "unit": "s", + "use": "Home list pin drag: the tile lifting (linear)", + "curve": "linear" + }, + "pinSettle": { + "value": 0.25, + "source": "cmux-tui/crates/cmux-motion (MotionFade): MessagesLab SidebarPinDragging.swift", + "unit": "s", + "use": "Home list pin drag: a dropped tile scaling back (linear)", + "curve": "linear" + }, + "pinShrink": { + "value": 0.22, + "source": "cmux-tui/crates/cmux-motion (MotionFade): MessagesLab SidebarPinDragging.swift", + "unit": "s", + "use": "Home list pin drag: a ghost shrinking onto its row avatar" } }, "loops": { diff --git a/schemas/defaults/cmux-next.json b/schemas/defaults/cmux-next.json index 3010f245e083..eebefe76c6d9 100644 --- a/schemas/defaults/cmux-next.json +++ b/schemas/defaults/cmux-next.json @@ -534,6 +534,9 @@ "setting.terminal.fontFamily": null, "setting.terminal.fontSize": null, "setting.ui.animationSpeed": "fast", + "setting.ui.animations.bounce": "none", + "setting.ui.animations.newTab": false, + "setting.ui.animations.newWorkspace": false, "setting.ui.paneAnimations": false, "setting.updates.checkAutomatically": true, "setting.updates.checkIntervalSeconds": 3600, diff --git a/scripts/cmux-next/build-app-ffi.sh b/scripts/cmux-next/build-app-ffi.sh index 2a3b20658d73..8f10a83fec16 100755 --- a/scripts/cmux-next/build-app-ffi.sh +++ b/scripts/cmux-next/build-app-ffi.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Builds CCmuxAppFFI.xcframework: the ONE Rust static library the cmux macOS # app links (cmux-tui/crates/cmux-app-ffi = the remote desktop core C ABI + -# the sidebar layout reducer C ABI over one Rust runtime). Apple's compact +# the sidebar layout reducer C ABI + the cmux-motion C ABI over one Rust +# runtime). Apple's compact # unwind encodes at most three personality routines per image (C++, iroh, # this library); every extra Rust static library breaks the app link. The # headers keep their modules (CCmuxRdFFI, CCmuxLayoutReducerFFI), so Swift @@ -24,7 +25,8 @@ case "$out_root" in ""|"/") echo "error: CMUX_APP_FFI_OUT must name a directory" lib_name="libcmux_app_ffi.a" headers_in=("$crates/cmux-rd-ffi/include/cmux_rd_ffi.h:CCmuxRdFFI" "$crates/cmux-layout-reducer-ffi/include/cmux_layout_reducer_ffi.h:CCmuxLayoutReducerFFI" - "$crates/cmux-palette-ranker/include/cmux_palette_ranker.h:CCmuxPaletteRankerFFI") + "$crates/cmux-palette-ranker/include/cmux_palette_ranker.h:CCmuxPaletteRankerFFI" + "$crates/cmux-app-ffi/include/cmux_motion_ffi.h:CCmuxMotionFFI") for tool in cargo rustup xcodebuild lipo clang libtool; do command -v "$tool" >/dev/null 2>&1 || { echo "error: $tool is required" >&2; exit 1; } diff --git a/scripts/cmux-next/check-app-ffi-pin.sh b/scripts/cmux-next/check-app-ffi-pin.sh index 2ec25dce3919..4753e4963336 100755 --- a/scripts/cmux-next/check-app-ffi-pin.sh +++ b/scripts/cmux-next/check-app-ffi-pin.sh @@ -52,6 +52,11 @@ ffi_crates() { } paths=() while IFS= read -r crate; do paths+=("$crate"); done < <(ffi_crates) +# Not an input: plans/cmux-next/spec-proposals/visuals/design-tokens.json, +# which cmux-motion's build.rs reads for its token table. A theme edit there +# must not mark the pin stale; the app passes its own token values to the +# motion C ABI (cmux_motion_resolve), and scripts/cmux-next/motion-parity.py +# compares them with the spec's. paths+=(cmux-tui/rust-toolchain.toml scripts/cmux-next/build-app-ffi.sh) # --list-inputs prints them (tests/app-ffi-release-paths.test.sh keeps the # release workflow's push paths in step with this list). diff --git a/scripts/cmux-next/cmux-tui-tree-inputs.txt b/scripts/cmux-next/cmux-tui-tree-inputs.txt index e3b44290d82e..76aff30cf215 100644 --- a/scripts/cmux-next/cmux-tui-tree-inputs.txt +++ b/scripts/cmux-next/cmux-tui-tree-inputs.txt @@ -37,6 +37,7 @@ blob backend/packages/home-core/conformance/conversation-import-cases.json blob backend/packages/home-core/conformance/conversation-search-cases.json blob mux/packages/brain/conformance/chief-cases.json blob plans/cmux-next/action-surfaces.json +# cmux-theme-tokens embeds it; cmux-motion build.rs reads its `motion` tokens. blob plans/cmux-next/spec-proposals/visuals/design-tokens.json blob schemas/chief-home/vectors.json blob schemas/agent-url-policy/vectors.json diff --git a/scripts/cmux-next/motion-parity.py b/scripts/cmux-next/motion-parity.py new file mode 100755 index 000000000000..a5b619ec5ee4 --- /dev/null +++ b/scripts/cmux-next/motion-parity.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Compares two motion parity corpora: cmux-motion's (Rust, +`cargo run -p cmux-motion --example motion_trajectories`) and cmux-next +macOS's (Swift, `debug.motion {"action": "trajectory"}` on a tagged app). + +Every resolved token (response, damping fraction, visible end) and every +frame path (200 pt move and mid-flight retarget, 60 and 120 Hz, bounce none +and subtle) must agree: tokens within 1e-6, visible ends within one 1/480 s +probe step, path samples within 0.01 pt (Rust steps in f32, Swift in +CGFloat), and the frame a path rests on within one frame (the f32/f64 settle +test can flip at the boundary). Prints the largest differences and exits 1 +on any mismatch. + +usage: motion-parity.py RUST.json SWIFT.json [--report OUT.json] +""" +import json +import sys + +TOKEN_TOL = 1e-6 +VISIBLE_END_TOL = 1 / 480 + 1e-9 +SAMPLE_TOL = 0.01 + + +def load(path): + with open(path) as f: + doc = json.load(f) + # A debug socket reply wraps the document in {"ok": true, "result": ...}. + for key in ("result", "value"): + if isinstance(doc, dict) and key in doc and "tokens" not in doc: + doc = doc[key] + return doc + + +def main(argv): + args = [a for a in argv if not a.startswith("--")] + report_path = argv[argv.index("--report") + 1] if "--report" in argv else None + if report_path in args: + args.remove(report_path) + if len(args) != 2: + print(__doc__, file=sys.stderr) + return 2 + a, b = load(args[0]), load(args[1]) + failures = [] + key = lambda r: (r["token"], r["bounce"]) + ta = {key(r): r for r in a["tokens"]} + tb = {key(r): r for r in b["tokens"]} + if set(ta) != set(tb): + failures.append(f"token sets differ: {sorted(set(ta) ^ set(tb))}") + worst_token = worst_end = worst_sample = 0.0 + worst_frames = 0 + for k in sorted(set(ta) & set(tb)): + for field in ("response", "damping_fraction"): + d = abs(ta[k][field] - tb[k][field]) + worst_token = max(worst_token, d) + if d > TOKEN_TOL: + failures.append(f"{k} {field}: {ta[k][field]} vs {tb[k][field]}") + d = abs(ta[k]["visible_end"] - tb[k]["visible_end"]) + worst_end = max(worst_end, d) + if d > VISIBLE_END_TOL: + failures.append(f"{k} visible_end: {ta[k]['visible_end']} vs {tb[k]['visible_end']}") + pkey = lambda r: (r["token"], r["bounce"], r["hz"], r["case"]) + pa = {pkey(r): r["values"] for r in a["paths"]} + pb = {pkey(r): r["values"] for r in b["paths"]} + if set(pa) != set(pb): + failures.append(f"path sets differ: {sorted(set(pa) ^ set(pb))}") + for k in sorted(set(pa) & set(pb)): + va, vb = pa[k], pb[k] + frames = abs(len(va) - len(vb)) + worst_frames = max(worst_frames, frames) + if frames > 1: + failures.append(f"{k} rests after {len(va)} vs {len(vb)} frames") + for i, (x, y) in enumerate(zip(va, vb)): + d = abs(x - y) + worst_sample = max(worst_sample, d) + if d > SAMPLE_TOL: + failures.append(f"{k} frame {i}: {x} vs {y}") + break + summary = { + "ports": [a.get("port"), b.get("port")], + "tokens": len(set(ta) & set(tb)), + "paths": len(set(pa) & set(pb)), + "max_token_delta": worst_token, + "max_visible_end_delta_s": worst_end, + "max_sample_delta_pt": worst_sample, + "max_rest_frame_delta": worst_frames, + "failures": failures, + "ok": not failures, + } + print(json.dumps(summary, indent=1)) + if report_path: + with open(report_path, "w") as f: + json.dump(summary, f, indent=1) + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/cmux-next/motion-proof.py b/scripts/cmux-next/motion-proof.py new file mode 100644 index 000000000000..4f8811b2f0ad --- /dev/null +++ b/scripts/cmux-next/motion-proof.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Frame strips of the structural gestures under each motion setting (motion redesign, hq-1d). + +Runs on a GUI host through `nx-remote --needs gui --app-from-fleet cmux --tag TAG --ref SHA`, +never on a laptop. Launches the tagged app with a scratch config (no-activate), then for each +motion configuration (in memory through `debug.motion`: bounce, per-kind switches, Reduce Motion) +records `debug.new_tab` frame_strip around Cmd-T, the strip's +, closing a tab, Cmd-N, Cmd-D, +Cmd-Shift-D and the close of each split, with `debug.motion` spans and per-frame samples (clock dt, +travel left per animator) for each. Cmd-T and + start from a terminal tab (`newSurface`): on a New Tab +page Cmd-T only focuses the page, which is why the phase-1 run recorded no Cmd-T motion. Splits +exclude every pane's content from the chrome counts (`content: "panes"`), so a terminal painting its +prompt is not read as motion. It also saves the app's motion parity corpus (`trajectory`). After the last gesture it checks that no display link +is left (`display_links`) and samples the app's CPU while idle. Writes OUT/results.json and the +strips under OUT///. A build without the new `debug.motion` actions (the base) +ignores them; its results say which configurations it could not set. + +Usage: motion-proof.py --app APP --tag TAG [--out DIR] [--configs default,bouncy,...] +""" +import argparse, json, os, plistlib, socket, subprocess, sys, tempfile, time + +# Phase 3 (hq-1d 2026-10-14): every kind is off by default (instant, no spring); each opt-in alone, +# all of them bouncy, and all of them under Reduce Motion (which still moves nothing). +ALL_ON = [{"action": "kind", "name": k, "value": True} for k in ("paneAnimations", "newTab", "newWorkspace")] +CONFIGS = { + "default": [], + "panes-on": [{"action": "kind", "name": "paneAnimations", "value": True}], + "newtab-on": [{"action": "kind", "name": "newTab", "value": True}], + "newworkspace-on": [{"action": "kind", "name": "newWorkspace", "value": True}], + "bouncy": ALL_ON + [{"action": "bounce", "value": "subtle"}], + "reduce": ALL_ON + [{"action": "reduce", "value": True}], +} +RESET = [{"action": "bounce", "value": "none"}, {"action": "reduce", "value": None}, + {"action": "kind", "name": "paneAnimations", "value": False}, {"action": "kind", "name": "newTab", "value": False}, + {"action": "kind", "name": "newWorkspace", "value": False}, {"action": "speed", "value": "fast"}] + +parser = argparse.ArgumentParser() +parser.add_argument("--app", required=True) +parser.add_argument("--tag", required=True) +parser.add_argument("--out", default=os.environ.get("NX_ARTIFACTS", "/tmp/motion-proof")) +parser.add_argument("--configs", default=",".join(CONFIGS)) +parser.add_argument("--size", default="1280x800") +parser.add_argument("--duration-ms", type=float, default=600) +# A build whose frame_strip sends only Command and knows no `gesture` (before hq-1d phase 2): its +# Cmd-Shift-D would be a Cmd-D and its + a Cmd-T, so those gestures are skipped, not mislabeled. +parser.add_argument("--base", action="store_true") +# hq-1d phase 3 step 7: only a Cmd-T loop (N opens and closes from a terminal tab) under one +# debug.motion recording, with every kind on; writes OUT/loop.json with the per-frame main-thread +# work of the clock and each animator (`work`), its spans and the sidebar's travel samples. +parser.add_argument("--loop", type=int, default=0) +opts = parser.parse_args() +WIDTH, HEIGHT = (int(v) for v in opts.size.split("x")) +BINARY = os.path.join(opts.app, "Contents/MacOS", + plistlib.load(open(os.path.join(opts.app, "Contents/Info.plist"), "rb"))["CFBundleExecutable"]) +SOCKET = f"/tmp/cmux-debug-{opts.tag}.sock" +SCRATCH = tempfile.mkdtemp(prefix=f"motion-{opts.tag}-") +CONFIG = os.path.join(SCRATCH, "cmux.json") +GHOSTTY = os.path.join(SCRATCH, "ghostty") +open(CONFIG, "w").write("{}") +open(GHOSTTY, "w").write("theme = light:Apple System Colors Light,dark:Apple System Colors\nfont-size = 12\n") + + +def rpc(method, params=None, timeout=60): + try: + conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + conn.settimeout(timeout) + conn.connect(SOCKET) + conn.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = conn.recv(1 << 20) + if not chunk: + break + buf += chunk + conn.close() + reply = json.loads(buf) + return reply.get("result") if reply.get("ok") else {"error": reply.get("error")} + except (OSError, ValueError) as error: + return {"error": str(error)} + + +def wait(predicate, seconds, step=0.3): + end = time.time() + seconds + while time.time() < end: + value = predicate() + if value: + return value + time.sleep(step) # test harness wait, not app code + return None + + +def key(k, mods=None): + return rpc("debug.key", {"key": k, "modifiers": mods or []}) + + +def action(name): + return rpc("action.run", {"id": name}) + + +def tab_count(): + snap = rpc("snapshot.get") or {} + topo = snap.get("topology") or snap + focus = (topo.get("focus") or {}).get("workspace") + for ws in topo.get("workspaces", []): + if ws.get("id") == focus: + return sum(len(p.get("tabs", [])) for s in ws.get("screens", []) for p in s.get("panes", [])) + return 0 + + +def terminal_tab(): + """Opens a terminal tab in the focused pane (a script's newSurface is a terminal) and waits for it.""" + before = tab_count() + action("newSurface") + wait(lambda: tab_count() > before, 8) + time.sleep(1.0) # test harness wait: the terminal's first paint + + +def pane_count(): + snap = rpc("snapshot.get") or {} + topo = snap.get("topology") or snap + focus = (topo.get("focus") or {}).get("workspace") + for ws in topo.get("workspaces", []): + if ws.get("id") == focus: + return sum(len(s.get("panes", [])) for s in ws.get("screens", [])) + return 0 + + +def workspace_count(): + snap = rpc("snapshot.get") or {} + topo = snap.get("topology") or snap + return len(topo.get("workspaces", [])) + + +def spare_state(): + """Whether a prewarmed New Tab spare was waiting (read before a gesture).""" + state = rpc("debug.new_tab", {"action": "state"}) or {} + spares = state.get("spares") + return len(spares) > 0 if isinstance(spares, list) else None + + +def tab_order(): + """The focused workspace's pane with the most tabs (one pane here): its tabs in model order.""" + snap = rpc("snapshot.get") or {} + topo = snap.get("topology") or snap + focus = (topo.get("focus") or {}).get("workspace") + panes = [p for ws in topo.get("workspaces", []) if ws.get("id") == focus for s in ws.get("screens", []) for p in s.get("panes", [])] + if not panes: + return [] + pane = max(panes, key=lambda p: len(p.get("tabs", []))) + return [(t.get("id"), t.get("title"), t.get("kind") or t.get("type")) for t in pane.get("tabs", [])] + + +def set_config(values, settings): + """Rewrites the scratch cmux.json (the app reloads it), waits for the reload, then sets the + configuration's motion switches again: the reload applies the file's (default) kinds.""" + open(CONFIG, "w").write(json.dumps(values)) + time.sleep(1.5) # test harness wait: the config watcher's reload + for change in RESET + settings: + rpc("debug.motion", change) + + +def strip(name, folder, key_name, modifiers, content="focused", gesture=None): + rpc("debug.motion", {"action": "start"}) + params = {"action": "frame_strip", "key": key_name, "modifiers": modifiers, "dir": folder, + "duration_ms": opts.duration_ms, "content": "none" if content == "panes" else content, "tolerance": 0.0005} + if gesture: + params["gesture"] = gesture + reply = rpc("debug.new_tab", params) or {} + time.sleep(0.4) # test harness wait: late springs reach rest before the spans are read + motion = rpc("debug.motion", {"action": "stop"}) or {} + frames = reply.get("frames") or [] + result = {k: reply.get(k) for k in ("frame_count", "mean_interval_ms", "intermediate", "moving", "reversals", "jumps", + "max_step", "content_intermediate", "blank", "error")} + for k in ("tabs_moving_frames", "tabs_restarts", "tabs_peak_travel"): + result[k] = reply.get(k) + result.update({"gesture": name, "dir": folder, "spans": motion.get("spans") or [], "samples": motion.get("samples") or [], + "notes": motion.get("notes") or [], "spare": spare_state(), + "progress": [[f.get("ms"), f.get("progress", None), f.get("intermediate")] for f in frames]}) + print(json.dumps({k: result.get(k) for k in ("gesture", "frame_count", "moving", "reversals", "jumps", "max_step", + "content_intermediate", "blank", "tabs_moving_frames", "tabs_restarts", + "tabs_peak_travel", "error")}), flush=True) + return result + + +def run_config(name, settings): + for change in RESET + settings: + rpc("debug.motion", change) + state = rpc("debug.motion", {"action": "read"}) or {} + out = os.path.join(opts.out, name) + results = {"settings": settings, "state": {k: state.get(k) for k in ("speed", "reduce_motion", "bounce", "enabled_kinds", "pane_animations")}, + "gestures": []} + # Cmd-T from a terminal tab (Cmd-T on a New Tab page only focuses it), then its close. + terminal_tab() + results["gestures"].append(strip("cmd-t", os.path.join(out, "cmd-t"), "t", ["command"])) + time.sleep(0.6) # test harness wait: the page settles + results["gestures"].append(strip("close-tab", os.path.join(out, "close-tab"), "w", ["command"])) + time.sleep(0.6) # test harness wait + # The strip's + (the same path as Cmd-T, P7), from a terminal tab again; close what it opened. + if not opts.base: + terminal_tab() + results["gestures"].append(strip("plus", os.path.join(out, "plus"), "t", ["command"], gesture="plus")) + time.sleep(0.6) # test harness wait + key("w", ["command"]) + time.sleep(0.8) # test harness wait + # The + bug (phase 3 step 4): + and Cmd-T again, each with a cold spare (right after an open, + # before the pool rewarms) and a warm one; with ui.animations.newTab off both must be instant. + if not opts.base: + for warm in (False, True): + for gesture in ("plus", "cmd-t"): + terminal_tab() + if warm: + time.sleep(3) # test harness wait: the New Tab spare rewarms + label = f"{gesture}-{'warm' if warm else 'cold'}" + results["gestures"].append(strip(label, os.path.join(out, label), "t", ["command"], + gesture="plus" if gesture == "plus" else None)) + time.sleep(0.6) # test harness wait + key("w", ["command"]) + time.sleep(0.6) # test harness wait + # hq-4e review item 1: a tab drag while a CLI new tab lands keeps the drag's springs. + if not opts.base: + terminal_tab() + results["gestures"].append(strip("drag-new", os.path.join(out, "drag-new"), "t", ["command"], gesture="drag_new")) + time.sleep(0.8) # test harness wait + key("w", ["command"]) + time.sleep(0.6) # test harness wait + # After current (tabs.newTabPosition afterCurrent): Cmd-T on tab 1 of three shows the page in + # slot 2 from its first frame; submitting it (`!ls`, Return) puts the terminal in that slot. + if not opts.base: + set_config({"tabs": {"newTabPosition": "afterCurrent"}}, settings) + terminal_tab() + terminal_tab() + # A terminal tab with tabs after it (Cmd-T on the window's first tab, a New Tab page, only focuses it). + order = tab_order() + anchor = next((i for i, t in enumerate(order[:-1]) if "terminal" in str(t[2]).lower() and i < 8), 1) + rpc("tab.focus", {"tab": order[anchor][0], "focus": True}) # Cmd-1..9 switch workspaces, not tabs + time.sleep(0.6) # test harness wait + before = tab_order() + results["gestures"].append(strip("after-current", os.path.join(out, "after-current"), "t", ["command"])) + time.sleep(0.8) # test harness wait: the page's field takes keys + page_order = tab_order() + for char in "!ls": + key(char) + time.sleep(0.3) # test harness wait + results["gestures"].append(strip("page-submit", os.path.join(out, "page-submit"), "return", [])) + time.sleep(1.5) # test harness wait: NewTabSlot's move lands + after = tab_order() + slot = anchor + 1 + results["slots"] = {"before": before, "page": page_order, "after": after, "anchor": anchor, + "page_after_anchor": len(page_order) > slot and page_order[slot][0] not in [t[0] for t in before], + "submit_after_anchor": len(after) > slot and after[slot][0] not in [t[0] for t in page_order] + and "terminal" in str(after[slot][2]).lower()} + print("slots", json.dumps(results["slots"])[:400], flush=True) + for _ in range(len(after) - 1): + key("w", ["command"]) + time.sleep(0.4) # test harness wait + set_config({}, settings) + # Cmd-N: a new workspace row; Cmd-W closes its only tab (and the workspace). + before = workspace_count() + results["gestures"].append(strip("cmd-n", os.path.join(out, "cmd-n"), "n", ["command"])) + if workspace_count() > before: + key("w", ["command"]) + time.sleep(0.8) # test harness wait + terminal_tab() + # Cmd-D, its close, Cmd-Shift-D, its close: pane content is out of the counts (pane chrome only). + for gesture, mods in (("cmd-d", ["command"]), ("cmd-shift-d", ["command", "shift"]))[: 1 if opts.base else 2]: + panes = pane_count() + results["gestures"].append(strip(gesture, os.path.join(out, gesture), "d", mods, content="panes")) + wait(lambda: pane_count() > panes, 5) + time.sleep(0.6) # test harness wait: the split's terminal starts + results["gestures"].append(strip(f"close-{gesture}", os.path.join(out, f"close-{gesture}"), "w", ["command"], content="panes")) + wait(lambda: pane_count() <= panes, 5) + time.sleep(0.6) # test harness wait + return results + + +def idle_cpu(pid): + sample = subprocess.run(["top", "-l", "4", "-s", "2", "-pid", str(pid), "-stats", "pid,cpu"], capture_output=True, text=True).stdout + values = [float(line.split()[1]) for line in sample.splitlines() if line.strip().startswith(str(pid))] + # The first sample has no interval; the rest are 2 s each. + return values[1:] + + +app = None +os.makedirs(opts.out, exist_ok=True) +report = {"tag": opts.tag, "configs": {}} +try: + if os.path.exists(SOCKET): + os.unlink(SOCKET) + env = {"HOME": os.environ["HOME"], "USER": os.environ.get("USER", ""), "TMPDIR": os.environ.get("TMPDIR", "/tmp"), + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "CMUX_NEXT_NO_ACTIVATE": "1", "CMUX_NEXT_SOCKET_MODE": "automation", + "CMUX_NEXT_TEST_WINDOW_SCREEN": "last", "CMUX_NEXT_CONFIG_FILE": CONFIG, "CMUX_NEXT_GHOSTTY_CONFIG": GHOSTTY, + "CMUX_NEXT_TEST_WINDOW_FRAME": f"40,40,{WIDTH},{HEIGHT}"} + log = open(os.path.join(opts.out, f"app-{opts.tag}.log"), "a") + app = subprocess.Popen([BINARY], env=env, stdout=log, stderr=log, stdin=subprocess.DEVNULL) + print(f"launched pid {app.pid}", flush=True) + if not wait(lambda: os.path.exists(SOCKET) and (rpc("debug.surfaces") or {}).get("windows"), 120): + sys.exit("the tagged app did not come up") + time.sleep(3) # test harness wait: first frame and the New Tab spare settle + corpus = rpc("debug.motion", {"action": "trajectory"}) or {} + if corpus.get("tokens"): + json.dump(corpus, open(os.path.join(opts.out, "swift-trajectory.json"), "w")) + if opts.loop: + for change in RESET + ALL_ON: + rpc("debug.motion", change) + terminal_tab() + rpc("debug.motion", {"action": "start"}) + for _ in range(opts.loop): + key("t", ["command"]) + time.sleep(0.5) # test harness wait: the tab's springs and the page settle + key("w", ["command"]) + time.sleep(0.5) # test harness wait + loop = rpc("debug.motion", {"action": "stop"}) or {} + report["loop"] = {"opens": opts.loop, "work": loop.get("work"), "spans": len(loop.get("spans") or []), + "sidebar_frames": sum(1 for x in loop.get("samples") or [] if x.get("name") == "sidebar.travel"), + "state": {k: loop.get(k) for k in ("enabled_kinds", "pane_animations", "display_links")}} + print("loop", json.dumps(report["loop"]), flush=True) + json.dump(report["loop"], open(os.path.join(opts.out, "loop.json"), "w"), indent=1) + for name in ([] if opts.loop else opts.configs.split(",")): + report["configs"][name] = run_config(name, CONFIGS[name]) + json.dump(report, open(os.path.join(opts.out, "results.json"), "w"), indent=1) + for change in RESET: + rpc("debug.motion", change) + time.sleep(2) # test harness wait: every spring at rest + state = rpc("debug.motion", {"action": "read"}) or {} + report["idle"] = {"display_links": state.get("display_links"), "active_clients": state.get("active_clients"), + "cpu_percent_samples": idle_cpu(app.pid)} + print("idle", report["idle"], flush=True) + json.dump(report, open(os.path.join(opts.out, "results.json"), "w"), indent=1) +finally: + if app and app.poll() is None: + # The DEBUG quit fixture (a scripted quitEndSessions is user-only since cx-zk9t). + reply = rpc("debug.quit", {"fixture_quit": "end-sessions"}, timeout=10) + print("quit:", json.dumps(reply)[:200], flush=True) + try: + app.wait(30) + except subprocess.TimeoutExpired: + # nx-remote's stop for --app-from-fleet jobs (never a kill of the tagged app). + if os.environ.get("NX_APP_STOP"): + subprocess.run([os.environ["NX_APP_STOP"]], capture_output=True, timeout=60) + acpmux = os.path.join(opts.app, "Contents/Resources/bin/acpmux") + cli = os.path.join(opts.app, "Contents/Resources/bin/cmux") + acpmux_home = os.path.join(os.path.expanduser("~"), ".cmux/chief/isolated", opts.tag, "acpmux") + if os.path.exists(acpmux): + subprocess.run([acpmux, "daemon", "shutdown"], env={**os.environ, "ACPMUX_HOME": acpmux_home, + "ACPMUX_SOCKET": os.path.join(acpmux_home, "acpmux.sock")}, capture_output=True, timeout=30) + if os.path.exists(cli): + subprocess.run([cli, "server", "stop", "--session", f"cmux-app-{opts.tag}", "--end-terminals"], + env={k: v for k, v in os.environ.items() if not k.startswith("CMUX_")}, capture_output=True, timeout=30) + ps = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True).stdout + left = [line for line in ps.splitlines() if (opts.app + "/") in line] + print(f"leftovers of this bundle: {len(left)}", flush=True) From 409e2da82d3d4b9b6d26516ff724bdf3f82826aa Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 05:23:05 -0700 Subject: [PATCH 049/183] Mac product analytics: Cloud upgrade funnel, welcome card and retention events (#20571) * Add a typed product analytics emitter for the Mac app CmuxProductAnalytics holds the event catalog (ProductAnalyticsEvent) and a non-blocking emitter: calls enqueue onto an AsyncStream and an actor dedupes, derives funnel events, merges the shared context and sends through the shared AnalyticsEmitting seam. The app's sink is the PostHog SDK it already embeds, so batching and the offline queue are unchanged, and the emitter is disabled whenever PostHog capture is (telemetry opt-out, managed DisableTelemetry, XCTest, debug builds). Co-Authored-By: Claude Opus 5.5 * Track the Cloud welcome card and Upgrade buttons through the funnel The welcome card records shown (with its trigger), every button and one closed outcome. The sidebar Upgrade badge and the Cloud panel's upgrade prompts record impressions, and every Upgrade click keeps cmux_upgrade_entrypoint_opened with the ProUpgradeSource token that reaches checkout as cmux_source, so client and server billing steps join. Co-Authored-By: Claude Opus 5.5 * Add activation, Cloud and retention events Sign-in, plan changes, Cloud enable/disable, machine creates from any launcher, Cloud workspaces, attach latency, plan limits, agent sessions from hooks, activation milestones and daily feature use. Properties are enums, counts and buckets only. Co-Authored-By: Claude Opus 5.5 * Document the Mac product analytics catalog and insights Co-Authored-By: Claude Opus 5.5 * Fix analytics review findings - Report a failed create's refusal code (set it on the finished value in CloudMachineCreateCoordinator) and count a create once even when a retry only reopens the machine. - Count user splits from the split action and tab-bar splits, not session restore or layouts. - Leave the stack_team group to the identity owner: a PostHog group lasts until reset. - Count agent sessions only from SessionStart, evict oldest first. - Bind an upgrade click to its account and use it for one activation. - Count right-sidebar use from the person's show and tab changes, not narrow-window auto-collapse or restore. - Restore the signed-in context when a sign-out does not happen. - Import CmuxCloud where CloudMachinesFeature is read; update the badge source guard; deterministic welcome timing and cleaned test defaults; write ledger keys only on change. Co-Authored-By: Claude Opus 5.5 * Address PR review comments on product analytics - Leave identity and the stack_team group wholly to the sign-in identity observer: the package sink is plain AnalyticsEmitting and no team path remains. - Record first_workspace only when a workspace was created; count remote tmux splits; mark socket sign-ins as started. - Refresh workspaces_open whenever a window's workspace count changes. - The welcome card renders and reports from one step resolver, so a plan refresh while it is open is reflected in the close event. - Omit memory_gb for Base setup and forks, which send no size. - Give each test emitter its own defaults suite; doc names upgrade_source for the plan-activation step. Co-Authored-By: Claude Opus 5.5 * Track the sidebar Cloud button cmux_cloud_button_shown (once per launch) and cmux_cloud_button_clicked with action open_cloud or introduce_cloud. The welcome card it opens reports trigger sidebar_cloud_button, so the button joins the welcome, upgrade and checkout funnel. presentIntroduction passes the trigger the merged welcome show() now requires. Co-Authored-By: Claude Opus 5.5 * Fit analytics to the merged identity observer; address review threads - An identity reset (#20565) clears registered super properties; restore the product context with the desktop tags so existing events keep account_plan, is_signed_in and cloud_enabled. - Record first_workspace at the workspace-list transition (a window that already had a workspace gains one, outside restore), not when a New Workspace action that may only open a terminal returns true. - Record the welcome Upgrade impression when the displayed step first becomes Upgrade, not only at open. - Count accepted remote tmux tab-bar splits. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../Billing/CheckoutAttribution.swift | 11 - .../VMClient/VMClientTelemetry.swift | 13 +- .../VMClientTelemetryTests.swift | 17 + .../CloudMachineCreateCoordinator.swift | 2 + .../CloudMachineCreateCoordinatorTests.swift | 3 +- .../macOS/CmuxProductAnalytics/Package.swift | 39 +++ Packages/macOS/CmuxProductAnalytics/README.md | 35 ++ .../ActivationMilestone.swift | 19 ++ .../CmuxProductAnalytics/AgentHookPhase.swift | 9 + .../CmuxProductAnalytics/AgentKind.swift | 24 ++ .../CmuxProductAnalytics/AnalyticsPlan.swift | 7 + .../AppUpdateDirection.swift | 5 + .../CloudButtonAction.swift | 7 + .../CloudEnableResult.swift | 10 + .../CloudErrorClass.swift | 35 ++ .../CmuxProductAnalytics/CloudPlanLimit.swift | 24 ++ .../CloudRequestObservation.swift | 22 ++ .../CloudWelcomeButton.swift | 19 ++ .../CloudWelcomeOutcome.swift | 21 ++ .../CloudWelcomeStep.swift | 11 + .../CloudWelcomeTrigger.swift | 13 + .../CloudWorkspaceMachineChoice.swift | 7 + .../CmuxProductAnalytics/DurationBucket.swift | 31 ++ .../ProductAnalytics.swift | 156 +++++++++ .../ProductAnalyticsCommand.swift | 17 + .../ProductAnalyticsContext.swift | 68 ++++ .../ProductAnalyticsContextChange.swift | 7 + .../ProductAnalyticsEvent.swift | 223 ++++++++++++ .../ProductAnalyticsLedger.swift | 104 ++++++ .../ProductAnalyticsPipeline.swift | 304 +++++++++++++++++ .../CmuxProductAnalytics/ProductFeature.swift | 16 + .../SignInFailureReason.swift | 13 + .../UpgradeDestination.swift | 7 + .../ProductAnalyticsEventCatalogTests.swift | 124 +++++++ .../ProductAnalyticsPipelineTests.swift | 301 ++++++++++++++++ .../RecordingAnalyticsSink.swift | 40 +++ Sources/AccountAnalyticsState.swift | 69 ++++ Sources/AppDelegate+NewCloudWorkspace.swift | 24 +- Sources/AppDelegate+ProductAnalytics.swift | 42 +++ Sources/AppDelegate.swift | 5 +- .../Auth/HostAccountFlow+TeamSelection.swift | 2 + Sources/Auth/HostAccountFlow.swift | 25 +- .../Cloud/CloudActivationCoordinator.swift | 26 +- .../Cloud/CloudWelcomeAnalyticsSession.swift | 77 +++++ .../Cloud/CloudWelcomeWindowController.swift | 76 ++++- Sources/Cloud/MachineCreateCoordinator.swift | 27 ++ .../Cloud/MachinesControlBarComponents.swift | 1 + Sources/Cloud/MachinesPanelView.swift | 1 + Sources/Cloud/NewMachineModel.swift | 4 + Sources/Cloud/SidebarCloudButtonAction.swift | 6 + Sources/Cloud/VMClientTelemetry+Live.swift | 13 +- Sources/ContentView.swift | 2 + .../DeviceWorkspaceCreationCoordinator.swift | 2 + Sources/FileExplorerState.swift | 12 +- Sources/HostAccountFlow+Analytics.swift | 38 +++ .../Panels/MachineCreateLoadingContent.swift | 1 + Sources/PostHogAnalytics.swift | 28 +- Sources/PostHogContextCalls.swift | 13 + Sources/PostHogProductAnalyticsSink.swift | 29 ++ Sources/PricingPlansScreen.swift | 28 +- Sources/ProBadgeActions.swift | 27 ++ Sources/ProBadgeStyle.swift | 5 +- Sources/ProductAnalytics+App.swift | 37 ++ Sources/ProductAnalyticsEvent+Upgrade.swift | 19 ++ Sources/RightSidebarMode+ProductFeature.swift | 16 + Sources/TabManager+TerminalSplitAction.swift | 5 + Sources/TabManager.swift | 11 + ...alController+ControlWorkspaceContext.swift | 6 + Sources/UpgradePromptImpression.swift | 16 + ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 2 + Sources/Workspace.swift | 13 +- cmux.xcodeproj/project.pbxproj | 64 ++++ cmux.xcworkspace/contents.xcworkspacedata | 3 + cmuxTests/AuthEnvironmentTests.swift | 8 - cmuxTests/CloudWelcomePresentationTests.swift | 11 +- cmuxTests/UpgradeAnalyticsTests.swift | 320 ++++++++++++++++++ docs/posthog/billing-attribution.md | 2 +- docs/posthog/mac-product-analytics.md | 190 +++++++++++ .../test_cloud_upgrade_rollout_retirement.py | 5 +- 79 files changed, 3009 insertions(+), 66 deletions(-) create mode 100644 Packages/macOS/CmuxProductAnalytics/Package.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/README.md create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ActivationMilestone.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentHookPhase.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentKind.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AnalyticsPlan.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AppUpdateDirection.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudButtonAction.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudEnableResult.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudErrorClass.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudPlanLimit.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudRequestObservation.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeButton.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeOutcome.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeStep.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeTrigger.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWorkspaceMachineChoice.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/DurationBucket.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalytics.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsCommand.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContext.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContextChange.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsEvent.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsLedger.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsPipeline.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductFeature.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/SignInFailureReason.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/UpgradeDestination.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsEventCatalogTests.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsPipelineTests.swift create mode 100644 Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/RecordingAnalyticsSink.swift create mode 100644 Sources/AccountAnalyticsState.swift create mode 100644 Sources/AppDelegate+ProductAnalytics.swift create mode 100644 Sources/Cloud/CloudWelcomeAnalyticsSession.swift create mode 100644 Sources/HostAccountFlow+Analytics.swift create mode 100644 Sources/PostHogContextCalls.swift create mode 100644 Sources/PostHogProductAnalyticsSink.swift create mode 100644 Sources/ProBadgeActions.swift create mode 100644 Sources/ProductAnalytics+App.swift create mode 100644 Sources/ProductAnalyticsEvent+Upgrade.swift create mode 100644 Sources/RightSidebarMode+ProductFeature.swift create mode 100644 Sources/UpgradePromptImpression.swift create mode 100644 cmuxTests/UpgradeAnalyticsTests.swift create mode 100644 docs/posthog/mac-product-analytics.md diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/CheckoutAttribution.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/CheckoutAttribution.swift index 544f69fab44b..ca439f1a37ed 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/CheckoutAttribution.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/CheckoutAttribution.swift @@ -47,17 +47,6 @@ public enum CheckoutAttribution: Sendable { return components.url ?? url } - /// PostHog properties for the Mac-side intent event, so the funnel has a - /// client-side count of upgrade clicks per surface and channel even before - /// the web page loads. - public nonisolated static func intentProperties( - source: ProUpgradeSource, - flavor: BuildFlavor = BuildFlavor.current, - plan: CheckoutPlan = .pro - ) -> [String: Any] { - ["source": source.rawValue, "client": "mac", "channel": flavor.rawValue, "plan": plan.rawValue] - } - /// The checkout URL for `plan`, attributed to `source`. /// /// - Parameters: diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClientTelemetry.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClientTelemetry.swift index c4f9e7dcd98e..59c3c7e611b6 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClientTelemetry.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClientTelemetry.swift @@ -67,7 +67,8 @@ public enum VMRequestOutcome: Sendable, Equatable { /// No HTTP response: DNS, connect, TLS, timeout, or a local precondition. case transportFailure(kind: VMTransportFailureKind, detail: String) - var isSuccess: Bool { + /// Whether the server answered below 400. + public var isSuccess: Bool { if case .response(let status, _, _) = self { return status < 400 } return false } @@ -149,7 +150,8 @@ public enum VMRequestFailureSeverity: String, Sendable { /// - a Sentry breadcrumb for every request and a Sentry event for failures /// (5xx and transport failures are `error`, 4xx are `warning`); /// - PostHog `cmux_cloud_vm_request` for every failure and for the successes a -/// user waits on (create, attach, base open, ...), with the duration. +/// user waits on (create, attach, base open, ...), with the duration; +/// - an injected record observer, which the app maps to product funnel events. /// /// The app injects the PostHog and Sentry sinks (`VMClientTelemetry+Live.swift`); /// the defaults drop events so tests and previews report nothing. @@ -166,11 +168,13 @@ public final class VMClientTelemetry: @unchecked Sendable { public typealias Capture = @Sendable (String, [String: Any]) -> Void public typealias SentryCapture = @Sendable (String, VMRequestFailureSeverity, [String: Any]) -> Void public typealias Breadcrumb = @Sendable (String, [String: Any]) -> Void + public typealias RecordObserver = @Sendable (VMRequestTelemetryRecord) -> Void private let logger = Logger(subsystem: "com.cmuxterm.app", category: "CloudVM") private let capturePostHog: Capture private let captureSentry: SentryCapture private let addBreadcrumb: Breadcrumb + private let observeRecord: RecordObserver private let now: @Sendable () -> Date private let channel: String private let lock = NSLock() @@ -181,12 +185,14 @@ public final class VMClientTelemetry: @unchecked Sendable { capturePostHog: @escaping Capture = { _, _ in }, captureSentry: @escaping SentryCapture = { _, _, _ in }, addBreadcrumb: @escaping Breadcrumb = { _, _ in }, + observeRecord: @escaping RecordObserver = { _ in }, now: @escaping @Sendable () -> Date = { Date() }, channel: String = BuildFlavor.current.rawValue ) { self.capturePostHog = capturePostHog self.captureSentry = captureSentry self.addBreadcrumb = addBreadcrumb + self.observeRecord = observeRecord self.now = now self.channel = channel } @@ -209,6 +215,9 @@ public final class VMClientTelemetry: @unchecked Sendable { public func record(_ record: VMRequestTelemetryRecord) { log(record) + // Unthrottled: product analytics derives funnel events (machine + // created, plan limit hit) from every outcome and dedupes itself. + observeRecord(record) addBreadcrumb( "\(record.operation) \(record.outcome.isSuccess ? "ok" : "failed")", Self.breadcrumbData(record) diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/VMClientTelemetryTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/VMClientTelemetryTests.swift index ab33f2d59b16..758a44ba1bfb 100644 --- a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/VMClientTelemetryTests.swift +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/VMClientTelemetryTests.swift @@ -112,6 +112,22 @@ struct VMClientTelemetryTests { #expect(VMClientTelemetry.sentryData(unreachable)["detail"] as? String == "https://x: timed out") } + @Test("the record observer sees every outcome, even throttled failures") + func recordObserverIsUnthrottled() { + let captured = CaptureLog() + let telemetry = VMClientTelemetry( + capturePostHog: { event, properties in captured.postHog.append((event, properties)) }, + observeRecord: { captured.observed.append($0.outcome.errorCode ?? "ok") }, + channel: "dev" + ) + let failure = record(outcome: .response(status: 409, errorCode: "vm_active_limit_exceeded", serverTraceId: "t1")) + telemetry.record(failure) + telemetry.record(failure) + telemetry.record(record(outcome: .response(status: 201, errorCode: nil, serverTraceId: "t2"))) + #expect(captured.postHog.count == 2) + #expect(captured.observed == ["vm_active_limit_exceeded", "vm_active_limit_exceeded", "ok"]) + } + @Test("failures are throttled per operation and code; successes never are") func throttling() { let clock = ManualClock() @@ -174,4 +190,5 @@ private final class CaptureLog: @unchecked Sendable { var postHog: [(String, [String: Any])] = [] var sentry: [(String, VMRequestFailureSeverity)] = [] var breadcrumbs = 0 + var observed: [String] = [] } diff --git a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCoordinator.swift b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCoordinator.swift index 0aea04e502ea..39b66442d5d4 100644 --- a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCoordinator.swift +++ b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineCreateCoordinator.swift @@ -176,6 +176,8 @@ public final class CloudMachineCreateCoordinator { outcome = .failed(output: completion.failureOutput) projection.operations[index].phase = .failed(output: completion.failureOutput) projection.operations[index].refusalCode = completion.refusalCode + // The finished value reports this attempt's refusal, not the stale copy's. + operation.refusalCode = completion.refusalCode } transition.finished = .init(operation: operation, outcome: outcome) return transition diff --git a/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineCreateCoordinatorTests.swift b/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineCreateCoordinatorTests.swift index 32b11e66278e..88d3ef2ac62b 100644 --- a/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineCreateCoordinatorTests.swift +++ b/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineCreateCoordinatorTests.swift @@ -31,12 +31,13 @@ struct CloudMachineCreateCoordinatorTests { let owner = makeOwner() let attempt = owner.reserve(request()) let id = attempt.operationID - _ = owner.finish(CloudMachineCreateCompletion( + let failed = owner.finish(CloudMachineCreateCompletion( succeeded: false, wasCancelled: false, output: "", failureOutput: "Shown text (HTTP 402: vm_active_limit_exceeded)", machineID: nil, workspaceID: nil, refusalCode: "vm_resource_pool_exceeded" ), from: attempt) #expect(owner.projection.operations.first?.refusalCode == "vm_resource_pool_exceeded") + #expect(failed.finished?.operation.refusalCode == "vm_resource_pool_exceeded") let retry = try #require(owner.retry(id)) #expect(owner.projection.operations.first?.refusalCode == nil) _ = owner.finish(CloudMachineCreateCompletion( diff --git a/Packages/macOS/CmuxProductAnalytics/Package.swift b/Packages/macOS/CmuxProductAnalytics/Package.swift new file mode 100644 index 000000000000..8c16a15afe9e --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxProductAnalytics", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxProductAnalytics", + targets: ["CmuxProductAnalytics"] + ), + ], + dependencies: [ + .package(path: "../../Shared/CMUXMobileCore"), + ], + targets: [ + .target( + name: "CmuxProductAnalytics", + dependencies: ["CMUXMobileCore"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxProductAnalyticsTests", + dependencies: ["CmuxProductAnalytics", "CMUXMobileCore"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxProductAnalytics/README.md b/Packages/macOS/CmuxProductAnalytics/README.md new file mode 100644 index 000000000000..be119f819c5e --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/README.md @@ -0,0 +1,35 @@ +# CmuxProductAnalytics + +The macOS product analytics catalog and emitter. Every product event the Mac +app sends is a case of `ProductAnalyticsEvent`; `ProductAnalytics` queues it +off the calling thread, deduplicates, derives funnel events, merges the shared +context and hands it to an `AnalyticsEmitting` sink. The app's sink forwards to +the PostHog SDK it already embeds, which owns batching and the offline queue. + +The sink is `AnalyticsEmitting` from `CMUXMobileCore`, the seam the iOS +emitter uses, so both apps share one analytics vocabulary. + +The event catalog, the questions each event answers and the insights built on +them are documented in [docs/posthog/mac-product-analytics.md](../../../docs/posthog/mac-product-analytics.md). + +## Testing + +```swift +let sink = RecordingAnalyticsSink() +let analytics = ProductAnalytics( + isEnabled: true, + sink: sink, + ledger: ProductAnalyticsLedger(defaults: UserDefaults(suiteName: "test")!), + context: ProductAnalyticsContext(appVersion: "1.0", appBuild: "1", channel: "dev", osVersion: "15.0") +) +analytics.track(.signInStarted) +await analytics.drain() +#expect(sink.events.map(\.name).contains("cmux_sign_in_started")) +``` + +`ProductAnalytics(isEnabled: false, ...)` and `ProductAnalytics.disabled` +enqueue nothing. + +```bash +swift test --package-path Packages/macOS/CmuxProductAnalytics +``` diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ActivationMilestone.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ActivationMilestone.swift new file mode 100644 index 000000000000..78b988031732 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ActivationMilestone.swift @@ -0,0 +1,19 @@ +/// A first-time action that marks a step of activation. Each fires at most +/// once per install. +/// +/// There is no "first terminal" milestone: every launch opens a terminal, so +/// it would only duplicate ``firstLaunch``. +public enum ActivationMilestone: String, Sendable, CaseIterable { + case firstLaunch = "first_launch" + /// A workspace the user created, beyond the one every launch opens. + case firstWorkspace = "first_workspace" + case firstSplit = "first_split" + case firstBrowserPane = "first_browser_pane" + case firstAgentSession = "first_agent_session" + case firstSignIn = "first_sign_in" + case firstUpgradeClick = "first_upgrade_click" + case firstCloudEnable = "first_cloud_enable" + case firstCloudMachine = "first_cloud_machine" + case firstCloudWorkspace = "first_cloud_workspace" + case firstPaidPlan = "first_paid_plan" +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentHookPhase.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentHookPhase.swift new file mode 100644 index 000000000000..b5b33d8d29ab --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentHookPhase.swift @@ -0,0 +1,9 @@ +/// The part of an agent session lifecycle a hook reports. +public enum AgentHookPhase: Sendable, Equatable { + case sessionStart + case sessionEnd + /// Any other hook (prompt, tool use, stop). Tracks a session the app first + /// sees mid-flight, such as one that outlived a relaunch, without + /// counting it as started. + case activity +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentKind.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentKind.swift new file mode 100644 index 000000000000..04ad719940d2 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentKind.swift @@ -0,0 +1,24 @@ +/// The coding agent behind an agent hook, from its hook source token. +public enum AgentKind: String, Sendable, CaseIterable { + case claudeCode = "claude_code" + case codex + case opencode + case gemini + case cursor + case pi + case other + + /// Maps a hook `source` token. Unknown tokens become ``other`` so a new + /// integration can never leak an arbitrary string. + public init(hookSource: String) { + switch hookSource.lowercased() { + case "claude", "claude-code", "claude_code", "claudecode": self = .claudeCode + case "codex": self = .codex + case "opencode", "open-code": self = .opencode + case "gemini", "gemini-cli": self = .gemini + case "cursor", "cursor-agent": self = .cursor + case "pi", "cmux-pi-extension": self = .pi + default: self = .other + } + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AnalyticsPlan.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AnalyticsPlan.swift new file mode 100644 index 000000000000..ea94d535b0c7 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AnalyticsPlan.swift @@ -0,0 +1,7 @@ +/// The account's plan as the app knows it. +public enum AnalyticsPlan: String, Sendable, CaseIterable { + case free + case paid + /// Signed out, or the plan has not loaded. + case unknown +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AppUpdateDirection.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AppUpdateDirection.swift new file mode 100644 index 000000000000..4933a1b4712f --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AppUpdateDirection.swift @@ -0,0 +1,5 @@ +/// Whether a launch runs a newer or older build than the previous one. +public enum AppUpdateDirection: String, Sendable, CaseIterable { + case upgrade + case downgrade +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudButtonAction.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudButtonAction.swift new file mode 100644 index 000000000000..73247e18e2c6 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudButtonAction.swift @@ -0,0 +1,7 @@ +/// What a press of the sidebar footer Cloud button did. +public enum CloudButtonAction: String, Sendable, CaseIterable { + /// Cloud was on: the press revealed Cloud Machines. + case openCloud = "open_cloud" + /// Cloud was off or signed out: the press also opened the welcome card. + case introduceCloud = "introduce_cloud" +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudEnableResult.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudEnableResult.swift new file mode 100644 index 000000000000..e2b76162a7a5 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudEnableResult.swift @@ -0,0 +1,10 @@ +/// How a Cloud enable attempt settled. +public enum CloudEnableResult: String, Sendable, CaseIterable { + case success + case requiresPaidPlan = "requires_paid_plan" + case signInRequired = "sign_in_required" + case serviceUnavailable = "service_unavailable" + /// Managed policy or rollout made Cloud unavailable. + case unavailable + case cancelled +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudErrorClass.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudErrorClass.swift new file mode 100644 index 000000000000..7d3f51ca3d01 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudErrorClass.swift @@ -0,0 +1,35 @@ +/// A coarse failure class for a Cloud request, safe to aggregate on. +public enum CloudErrorClass: String, Sendable, CaseIterable { + case planLimit = "plan_limit" + case auth + case network + case server + case client + case cancelled + + /// Classifies a client error code: a server `error` token, `http_`, + /// or a transport failure kind. + public init(errorCode: String) { + if CloudPlanLimit(errorCode: errorCode) != nil { + self = .planLimit + return + } + switch errorCode { + case "not_signed_in", "session_refresh_failed", "http_401", "http_403", "vm_access_revoked", + "vm_owner_mismatch", "vm_bound_token_required": + self = .auth + case "backend_unreachable", "url_error": + self = .network + case "cancelled": + self = .cancelled + case "malformed_response", "unknown", "vm_error", "vm_cloud_service_unavailable": + self = .server + default: + if errorCode.hasPrefix("http_5") { + self = .server + } else { + self = .client + } + } + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudPlanLimit.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudPlanLimit.swift new file mode 100644 index 000000000000..d30ba06bfd05 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudPlanLimit.swift @@ -0,0 +1,24 @@ +/// Which plan limit a Cloud request ran into. +public enum CloudPlanLimit: String, Sendable, CaseIterable { + case requiresPaidPlan = "requires_paid_plan" + case activeMachines = "active_machines" + case machineMemory = "machine_memory" + case resourcePool = "resource_pool" + case resizeSize = "resize_size" + case credits + case teamBilling = "team_billing" + + /// The limit named by a server error code, or nil when it is not a plan limit. + public init?(errorCode: String) { + switch errorCode { + case "vm_requires_pro": self = .requiresPaidPlan + case "vm_active_limit_exceeded": self = .activeMachines + case "vm_memory_requires_plan": self = .machineMemory + case "vm_resource_pool_exceeded": self = .resourcePool + case "vm_resize_plan_limit": self = .resizeSize + case "vm_create_credits_insufficient": self = .credits + case "vm_billing_team_required", "vm_billing_team_not_found": self = .teamBilling + default: return nil + } + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudRequestObservation.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudRequestObservation.swift new file mode 100644 index 000000000000..fb86f52dae33 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudRequestObservation.swift @@ -0,0 +1,22 @@ +/// One finished Cloud VM API request, as the client's request telemetry saw it. +/// ``ProductAnalytics`` derives machine create, delete, attach and plan-limit +/// events from these, so no Cloud call site needs its own analytics. +public struct CloudRequestObservation: Sendable, Equatable { + /// `GET`, `POST`, `DELETE`, ... + public let method: String + /// The normalized route, machine ids replaced by `{id}` (`/api/vm/{id}/attach-endpoint`). + public let route: String + public let success: Bool + /// The machine-readable failure token, nil on success. + public let errorCode: String? + public let durationMs: Double + + /// Creates an observation. + public init(method: String, route: String, success: Bool, errorCode: String?, durationMs: Double) { + self.method = method.uppercased() + self.route = route + self.success = success + self.errorCode = errorCode + self.durationMs = durationMs + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeButton.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeButton.swift new file mode 100644 index 000000000000..10fd925e5553 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeButton.swift @@ -0,0 +1,19 @@ +/// A button on the welcome card. +public enum CloudWelcomeButton: String, Sendable, CaseIterable { + case upgrade + case enable + case signIn = "sign_in" + case openCloud = "open_cloud" + /// Not Now, the card's own close button, or Escape. + case notNow = "not_now" + + /// The button that performs `step`. + public init(step: CloudWelcomeStep) { + switch step { + case .upgrade: self = .upgrade + case .enable: self = .enable + case .signIn: self = .signIn + case .openCloud: self = .openCloud + } + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeOutcome.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeOutcome.swift new file mode 100644 index 000000000000..03f2fb2cf83d --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeOutcome.swift @@ -0,0 +1,21 @@ +/// How a shown welcome card ended. +public enum CloudWelcomeOutcome: String, Sendable, CaseIterable { + case upgrade + case enable + case signIn = "sign_in" + case openCloud = "open_cloud" + case notNow = "not_now" + /// The window closed with no button (Cmd-W, or the app quit). + case windowClosed = "window_closed" + + /// The outcome recorded when `button` closed the card. + public init(button: CloudWelcomeButton) { + switch button { + case .upgrade: self = .upgrade + case .enable: self = .enable + case .signIn: self = .signIn + case .openCloud: self = .openCloud + case .notNow: self = .notNow + } + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeStep.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeStep.swift new file mode 100644 index 000000000000..324f0eb9926a --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeStep.swift @@ -0,0 +1,11 @@ +/// The welcome card's primary action, decided by sign-in and plan. +public enum CloudWelcomeStep: String, Sendable, CaseIterable { + /// Free or signed out: Upgrade opens pricing. + case upgrade + /// Paid plan or a shared team: Enable turns Cloud on. + case enable + /// Sign in first. + case signIn = "sign_in" + /// The plan is not known yet: open the Cloud sidebar, which decides. + case openCloud = "open_cloud" +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeTrigger.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeTrigger.swift new file mode 100644 index 000000000000..9fdc0dcff43c --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeTrigger.swift @@ -0,0 +1,13 @@ +/// What opened the "Introducing cmux Cloud" welcome card. +public enum CloudWelcomeTrigger: String, Sendable, CaseIterable { + /// The one-time release announcement on the first main window. + case launchAnnouncement = "launch_announcement" + /// New Cloud Workspace (shortcut, menu or palette) while Cloud is off or signed out. + case newCloudWorkspace = "new_cloud_workspace" + /// New Cloud Machine (shortcut, menu or palette) while Cloud is off or signed out. + case newCloudMachine = "new_cloud_machine" + /// The sidebar footer Cloud button while Cloud is off or signed out. + case sidebarCloudButton = "sidebar_cloud_button" + /// The Debug menu preview. Debug builds only. + case debugMenu = "debug_menu" +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWorkspaceMachineChoice.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWorkspaceMachineChoice.swift new file mode 100644 index 000000000000..e68747e71440 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWorkspaceMachineChoice.swift @@ -0,0 +1,7 @@ +/// How a new Cloud workspace picked its machine. +public enum CloudWorkspaceMachineChoice: String, Sendable, CaseIterable { + /// New Cloud Workspace chose the window's last or first machine. + case resolved + /// The user picked the machine (its row, context menu, or palette entry). + case explicit +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/DurationBucket.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/DurationBucket.swift new file mode 100644 index 000000000000..c4570db68896 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/DurationBucket.swift @@ -0,0 +1,31 @@ +/// A coarse duration, so latency and dwell time aggregate without raw timings. +public enum DurationBucket: String, Sendable, CaseIterable { + case under1s = "lt_1s" + case from1to3s = "1s_3s" + case from3to10s = "3s_10s" + case from10to30s = "10s_30s" + case from30to60s = "30s_60s" + case from1to3m = "1m_3m" + case from3to10m = "3m_10m" + case from10to60m = "10m_60m" + case from1to24h = "1h_24h" + case from1to7d = "1d_7d" + case over7d = "gt_7d" + + /// The bucket containing `seconds`. Negative values count as zero. + public init(seconds: Double) { + switch max(0, seconds) { + case ..<1: self = .under1s + case ..<3: self = .from1to3s + case ..<10: self = .from3to10s + case ..<30: self = .from10to30s + case ..<60: self = .from30to60s + case ..<180: self = .from1to3m + case ..<600: self = .from3to10m + case ..<3_600: self = .from10to60m + case ..<86_400: self = .from1to24h + case ..<604_800: self = .from1to7d + default: self = .over7d + } + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalytics.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalytics.swift new file mode 100644 index 000000000000..381947417d27 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalytics.swift @@ -0,0 +1,156 @@ +public import CMUXMobileCore +public import Foundation + +/// The macOS product analytics emitter. +/// +/// Every method is synchronous, non-throwing and returns immediately: it +/// enqueues a command and an off-main actor does the work in call order, so +/// call sites never block typing, rendering or drag and drop. When disabled +/// (telemetry opt-out, managed `DisableTelemetry`, XCTest), every method is a +/// no-op and nothing is enqueued. +/// +/// The app builds one instance at its composition root with a PostHog sink. +/// Tests build their own with a recording sink and a suite `UserDefaults`: +/// +/// ```swift +/// let sink = RecordingAnalyticsSink() +/// let analytics = ProductAnalytics( +/// isEnabled: true, +/// sink: sink, +/// ledger: ProductAnalyticsLedger(defaults: UserDefaults(suiteName: #function)!), +/// context: ProductAnalyticsContext(appVersion: "1.0", appBuild: "1", channel: "dev", osVersion: "15.0") +/// ) +/// analytics.track(.signInStarted) +/// await analytics.drain() +/// ``` +public final class ProductAnalytics: Sendable { + /// Whether this instance sends anything. Frozen for its lifetime, like + /// the telemetry setting it comes from. + public let isEnabled: Bool + private let continuation: AsyncStream.Continuation? + + /// An instance that drops everything, for previews and tests that do not + /// observe analytics. + public static let disabled = ProductAnalytics() + + private init() { + isEnabled = false + continuation = nil + } + + /// Creates an emitter and records this launch (first launch, version change). + /// + /// - Parameters: + /// - isEnabled: False for opt-out, managed telemetry off, or tests. + /// - sink: Where events go. + /// - ledger: Persistent dedupe and attribution state. + /// - context: The shared properties at launch. + /// - isPreexistingInstall: True when the app ran before this catalog + /// existed, so its first launch is not reported now. + /// - now: The clock. Defaults to the system clock. + public init( + isEnabled: Bool, + sink: any AnalyticsEmitting, + ledger: ProductAnalyticsLedger, + context: ProductAnalyticsContext, + isPreexistingInstall: Bool = false, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.isEnabled = isEnabled + guard isEnabled else { + continuation = nil + return + } + let (stream, continuation) = AsyncStream.makeStream(of: ProductAnalyticsCommand.self) + self.continuation = continuation + let pipeline = ProductAnalyticsPipeline( + sink: sink, + ledger: ledger, + context: context, + isPreexistingInstall: isPreexistingInstall, + now: now + ) + Task.detached(priority: .utility) { + await pipeline.run(stream) + } + continuation.yield(.launch) + } + + deinit { + continuation?.finish() + } + + /// Records `event`. Impressions (`cmux_upgrade_prompt_shown` per source, + /// `cmux_cloud_button_shown`) are deduplicated per launch. + public func track(_ event: ProductAnalyticsEvent) { + continuation?.yield(.track(event)) + } + + /// Records `milestone` the first time it is reached on this install. + public func reach(_ milestone: ActivationMilestone) { + continuation?.yield(.milestone(milestone)) + } + + /// Records a use of `feature`, at most once per UTC day. + public func featureUsed(_ feature: ProductFeature) { + continuation?.yield(.feature(feature)) + } + + /// Updates one shared property for this and later events. + public func updateContext(_ change: ProductAnalyticsContextChange) { + continuation?.yield(.context(change)) + } + + /// Marks later events signed in and tells the sink the account once per + /// change. The sink may own the person identity or, like the Mac app's, + /// leave identity and team groups to the host's sign-in identity + /// observer, the only place that also resets them on sign-out. + /// + /// - Parameter userID: The account's Stack user id, the same distinct id + /// the server's billing events use. + public func identify(userID: String) { + continuation?.yield(.identify(userID: userID)) + } + + /// Records `cmux_signed_out` once and tells the sink the account is gone. + /// Call it before the sign-out runs so the event joins the signed-in person. + public func signedOut() { + continuation?.yield(.signOut) + } + + /// Reports the signed-in account's plan; fires `cmux_plan_activated` or + /// `cmux_plan_deactivated` on a change. + public func notePlan(accountID: String, isPaid: Bool) { + continuation?.yield(.plan(accountID: accountID, isPaid: isPaid)) + } + + /// Reports one agent hook. The session id is used only for dedupe and is + /// never sent. + public func noteAgentHook(source: String, sessionID: String, phase: AgentHookPhase) { + continuation?.yield(.agentHook(source: source, sessionID: sessionID, phase: phase)) + } + + /// Reports one finished Cloud VM API request. + public func noteCloudRequest(_ request: CloudRequestObservation) { + continuation?.yield(.cloudRequest(request)) + } + + /// Reports a finished Cloud machine create from any launcher. + /// + /// - Parameters: + /// - succeeded: The machine was created (even if opening it then failed). + /// - errorCode: The server's refusal code on failure, or nil when unknown. + /// - seconds: From submit to finish. + public func noteMachineCreateFinished(succeeded: Bool, errorCode: String?, seconds: Double) { + continuation?.yield(.machineCreateFinished(succeeded: succeeded, errorCode: errorCode, seconds: seconds)) + } + + /// Waits until every command enqueued before this call has been processed. + /// For tests and shutdown; never call on a hot path. + public func drain() async { + guard let continuation else { return } + await withCheckedContinuation { (resume: CheckedContinuation) in + continuation.yield(.barrier(resume)) + } + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsCommand.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsCommand.swift new file mode 100644 index 000000000000..f154a2b9417f --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsCommand.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Work queued from ``ProductAnalytics`` to its pipeline, in call order. +enum ProductAnalyticsCommand: Sendable { + case launch + case track(ProductAnalyticsEvent) + case milestone(ActivationMilestone) + case feature(ProductFeature) + case context(ProductAnalyticsContextChange) + case identify(userID: String) + case signOut + case plan(accountID: String, isPaid: Bool) + case agentHook(source: String, sessionID: String, phase: AgentHookPhase) + case cloudRequest(CloudRequestObservation) + case machineCreateFinished(succeeded: Bool, errorCode: String?, seconds: Double) + case barrier(CheckedContinuation) +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContext.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContext.swift new file mode 100644 index 000000000000..8e3884500208 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContext.swift @@ -0,0 +1,68 @@ +public import CMUXMobileCore + +/// The shared properties attached to every product event and registered as +/// PostHog super properties, so older events (`cmux_daily_active`, +/// `cmux_cloud_vm_request`) carry them too. +public struct ProductAnalyticsContext: Sendable, Equatable { + /// `CFBundleShortVersionString`. + public var appVersion: String + /// `CFBundleVersion`. + public var appBuild: String + /// Release channel: `stable`, `nightly`, `rc` or `dev`. + public var channel: String + /// `major.minor` of macOS. + public var osVersion: String + public var plan: AnalyticsPlan + public var isSignedIn: Bool + public var cloudEnabled: Bool + /// Open workspaces across windows, refreshed when the app becomes active. + public var workspacesOpen: Int + + /// Creates a context. Account and Cloud state start unknown and are + /// updated through ``ProductAnalytics/updateContext(_:)``. + public init( + appVersion: String, + appBuild: String, + channel: String, + osVersion: String, + plan: AnalyticsPlan = .unknown, + isSignedIn: Bool = false, + cloudEnabled: Bool = false, + workspacesOpen: Int = 0 + ) { + self.appVersion = appVersion + self.appBuild = appBuild + self.channel = channel + self.osVersion = osVersion + self.plan = plan + self.isSignedIn = isSignedIn + self.cloudEnabled = cloudEnabled + self.workspacesOpen = workspacesOpen + } + + /// The context as event properties. + public var properties: [String: AnalyticsValue] { + [ + "app_version": .string(appVersion), + "app_build": .string(appBuild), + "channel": .string(channel), + "os_version": .string(osVersion), + "account_plan": .string(plan.rawValue), + "is_signed_in": .bool(isSignedIn), + "cloud_enabled": .bool(cloudEnabled), + "workspaces_open": .string(Self.countBucket(workspacesOpen)), + ] + } + + /// `0`, `1`, `2_3`, `4_7`, `8_15` or `16_plus`. + static func countBucket(_ count: Int) -> String { + switch count { + case ..<1: "0" + case 1: "1" + case 2...3: "2_3" + case 4...7: "4_7" + case 8...15: "8_15" + default: "16_plus" + } + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContextChange.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContextChange.swift new file mode 100644 index 000000000000..ecc571ac75ef --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContextChange.swift @@ -0,0 +1,7 @@ +/// One field of ``ProductAnalyticsContext`` that changed. +public enum ProductAnalyticsContextChange: Sendable, Equatable { + case signedIn(Bool) + case plan(AnalyticsPlan) + case cloudEnabled(Bool) + case workspacesOpen(Int) +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsEvent.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsEvent.swift new file mode 100644 index 000000000000..113265085760 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsEvent.swift @@ -0,0 +1,223 @@ +public import CMUXMobileCore + +/// One product analytics event from the macOS app. This enum is the event +/// catalog: every name and property the app sends is declared here, pinned by +/// `ProductAnalyticsEventCatalogTests`, and documented in +/// `docs/posthog/mac-product-analytics.md`. +/// +/// Names follow `cmux__` in snake_case, matching the server's +/// `cmux_billing_*` events so client and server steps share one funnel. +/// Properties are enums, counts, booleans and buckets only: never terminal +/// content, paths, commands, prompts, hostnames, emails or raw error text. +/// +/// The shared context (``ProductAnalyticsContext``) is merged into every event +/// by ``ProductAnalytics``, so cases carry only what is specific to them. +/// +/// ```swift +/// analytics.track(.upgradePromptShown(source: "mac_sidebar_badge", plan: "pro")) +/// ``` +public enum ProductAnalyticsEvent: Sendable, Equatable { + // MARK: Upgrade funnel + + /// An upgrade entrypoint became visible. Deduplicated per source per launch. + case upgradePromptShown(source: String, plan: String) + /// An upgrade entrypoint was clicked. `source` is the `ProUpgradeSource` + /// token that also travels to checkout as `cmux_source`. + case upgradeEntrypointOpened(source: String, plan: String, destination: UpgradeDestination) + /// The user hid an upgrade badge. + case upgradeBadgeDismissed(source: String) + /// The signed-in account's plan became paid. `upgradeSource` is the last + /// upgrade click within the attribution window, or nil. + case planActivated(upgradeSource: String?, sinceUpgradeClick: DurationBucket?) + /// The signed-in account's plan went from paid to free. + case planDeactivated + + // MARK: Sidebar Cloud button + + /// The sidebar footer Cloud button became visible. Deduplicated per launch. + case cloudButtonShown + /// The sidebar footer Cloud button was pressed. + case cloudButtonClicked(action: CloudButtonAction) + + // MARK: Cloud welcome card + + /// The welcome card was shown. + case cloudWelcomeShown(trigger: CloudWelcomeTrigger, step: CloudWelcomeStep) + /// A button on the welcome card was clicked. + case cloudWelcomeButtonClicked(trigger: CloudWelcomeTrigger, step: CloudWelcomeStep, button: CloudWelcomeButton) + /// The welcome card closed, with how and after how long. + case cloudWelcomeClosed(trigger: CloudWelcomeTrigger, step: CloudWelcomeStep, outcome: CloudWelcomeOutcome, visibleFor: DurationBucket) + + // MARK: Account + + case signInStarted + case signInSucceeded + case signInFailed(reason: SignInFailureReason) + case signedOut + + // MARK: Cloud + + /// A Cloud enable attempt settled. + case cloudEnableCompleted(result: CloudEnableResult) + case cloudDisabled + /// The New Machine sheet submitted a create. `memoryGB` is nil when the + /// request sends no size (Base setup, forks). + case cloudMachineCreateStarted(memoryGB: Int?) + case cloudMachineCreateSucceeded(duration: DurationBucket) + /// `consecutiveFailures` counts create failures since the last success. + case cloudMachineCreateFailed(errorClass: CloudErrorClass, errorCode: String, consecutiveFailures: Int) + case cloudMachineDeleted + /// The attach endpoint request for a machine finished. + case cloudMachineAttached(success: Bool, latency: DurationBucket, errorClass: CloudErrorClass?) + case cloudWorkspaceCreated(machineChoice: CloudWorkspaceMachineChoice, duration: DurationBucket) + /// A Cloud request was refused by a plan limit. + case cloudPlanLimitHit(limit: CloudPlanLimit, operation: String) + + // MARK: Activation and engagement + + /// A first-time action. `isNewInstall` is false for installs that predate + /// this catalog, whose real first use was never recorded. + case activationMilestoneReached(milestone: ActivationMilestone, isNewInstall: Bool, daysSinceInstall: Int) + case agentSessionStarted(agent: AgentKind) + case agentSessionEnded(agent: AgentKind) + /// A key surface was used. Deduplicated per feature per UTC day. + case featureUsed(feature: ProductFeature) + /// This launch runs a different version than the previous launch. + case appUpdated(direction: AppUpdateDirection, fromVersion: String, toVersion: String) + + /// The PostHog event name. + public var name: String { + switch self { + case .upgradePromptShown: "cmux_upgrade_prompt_shown" + case .upgradeEntrypointOpened: "cmux_upgrade_entrypoint_opened" + case .upgradeBadgeDismissed: "cmux_upgrade_badge_dismissed" + case .planActivated: "cmux_plan_activated" + case .planDeactivated: "cmux_plan_deactivated" + case .cloudButtonShown: "cmux_cloud_button_shown" + case .cloudButtonClicked: "cmux_cloud_button_clicked" + case .cloudWelcomeShown: "cmux_cloud_welcome_shown" + case .cloudWelcomeButtonClicked: "cmux_cloud_welcome_button_clicked" + case .cloudWelcomeClosed: "cmux_cloud_welcome_closed" + case .signInStarted: "cmux_sign_in_started" + case .signInSucceeded: "cmux_sign_in_succeeded" + case .signInFailed: "cmux_sign_in_failed" + case .signedOut: "cmux_signed_out" + case .cloudEnableCompleted: "cmux_cloud_enable_completed" + case .cloudDisabled: "cmux_cloud_disabled" + case .cloudMachineCreateStarted: "cmux_cloud_machine_create_started" + case .cloudMachineCreateSucceeded: "cmux_cloud_machine_create_succeeded" + case .cloudMachineCreateFailed: "cmux_cloud_machine_create_failed" + case .cloudMachineDeleted: "cmux_cloud_machine_deleted" + case .cloudMachineAttached: "cmux_cloud_machine_attached" + case .cloudWorkspaceCreated: "cmux_cloud_workspace_created" + case .cloudPlanLimitHit: "cmux_cloud_plan_limit_hit" + case .activationMilestoneReached: "cmux_activation_milestone_reached" + case .agentSessionStarted: "cmux_agent_session_started" + case .agentSessionEnded: "cmux_agent_session_ended" + case .featureUsed: "cmux_feature_used" + case .appUpdated: "cmux_app_updated" + } + } + + /// The event-specific properties, before the shared context is merged. + public var properties: [String: AnalyticsValue] { + switch self { + case let .upgradePromptShown(source, plan): + return ["source": .string(Self.token(source)), "plan": .string(Self.token(plan))] + case let .upgradeEntrypointOpened(source, plan, destination): + // `source`, `client` and `plan` keep the keys this event had before + // the catalog existed, so existing insights keep working. + return [ + "source": .string(Self.token(source)), + "client": .string("mac"), + "plan": .string(Self.token(plan)), + "destination": .string(destination.rawValue), + ] + case let .upgradeBadgeDismissed(source): + return ["source": .string(Self.token(source))] + case let .planActivated(upgradeSource, since): + return [ + "upgrade_source": .string(upgradeSource.map(Self.token) ?? "none"), + "since_upgrade_click": .string(since?.rawValue ?? "none"), + ] + case .planDeactivated, .signInStarted, .signInSucceeded, .signedOut, .cloudDisabled, .cloudMachineDeleted, + .cloudButtonShown: + return [:] + case let .cloudButtonClicked(action): + return ["action": .string(action.rawValue)] + case let .cloudWelcomeShown(trigger, step): + return ["trigger": .string(trigger.rawValue), "next_step": .string(step.rawValue)] + case let .cloudWelcomeButtonClicked(trigger, step, button): + return [ + "trigger": .string(trigger.rawValue), + "next_step": .string(step.rawValue), + "button": .string(button.rawValue), + ] + case let .cloudWelcomeClosed(trigger, step, outcome, visibleFor): + return [ + "trigger": .string(trigger.rawValue), + "next_step": .string(step.rawValue), + "outcome": .string(outcome.rawValue), + "visible_for": .string(visibleFor.rawValue), + ] + case let .signInFailed(reason): + return ["reason": .string(reason.rawValue)] + case let .cloudEnableCompleted(result): + return ["result": .string(result.rawValue)] + case let .cloudMachineCreateStarted(memoryGB): + return memoryGB.map { ["memory_gb": .int($0)] } ?? [:] + case let .cloudMachineCreateSucceeded(duration): + return ["duration": .string(duration.rawValue)] + case let .cloudMachineCreateFailed(errorClass, errorCode, consecutiveFailures): + return [ + "error_class": .string(errorClass.rawValue), + "error_code": .string(Self.token(errorCode)), + "consecutive_failures": .int(consecutiveFailures), + ] + case let .cloudMachineAttached(success, latency, errorClass): + var properties: [String: AnalyticsValue] = [ + "success": .bool(success), + "latency": .string(latency.rawValue), + ] + if let errorClass { properties["error_class"] = .string(errorClass.rawValue) } + return properties + case let .cloudWorkspaceCreated(machineChoice, duration): + return ["machine_choice": .string(machineChoice.rawValue), "duration": .string(duration.rawValue)] + case let .cloudPlanLimitHit(limit, operation): + return ["limit": .string(limit.rawValue), "operation": .string(Self.token(operation))] + case let .activationMilestoneReached(milestone, isNewInstall, daysSinceInstall): + return [ + "milestone": .string(milestone.rawValue), + "is_new_install": .bool(isNewInstall), + "days_since_install": .int(daysSinceInstall), + ] + case let .agentSessionStarted(agent), let .agentSessionEnded(agent): + return ["agent": .string(agent.rawValue)] + case let .featureUsed(feature): + return ["feature": .string(feature.rawValue)] + case let .appUpdated(direction, fromVersion, toVersion): + return [ + "direction": .string(direction.rawValue), + "from_version": .string(Self.version(fromVersion)), + "to_version": .string(Self.version(toVersion)), + ] + } + } + + /// Lowercase `[a-z0-9_]` tokens up to 64 characters pass; anything else + /// becomes `other`, so a caller can never send free text by mistake. + static func token(_ raw: String) -> String { + let allowed = raw.utf8.allSatisfy { byte in + (byte >= 0x61 && byte <= 0x7A) || (byte >= 0x30 && byte <= 0x39) || byte == 0x5F + } + return allowed && !raw.isEmpty && raw.utf8.count <= 64 ? raw : "other" + } + + /// Dotted version strings (`0.65.2`) pass; anything else becomes `unknown`. + static func version(_ raw: String) -> String { + let allowed = raw.utf8.allSatisfy { byte in + (byte >= 0x30 && byte <= 0x39) || byte == 0x2E || (byte >= 0x61 && byte <= 0x7A) || byte == 0x2D + } + return allowed && !raw.isEmpty && raw.utf8.count <= 32 ? raw : "unknown" + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsLedger.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsLedger.swift new file mode 100644 index 000000000000..5fb91c9af73a --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsLedger.swift @@ -0,0 +1,104 @@ +public import Foundation + +/// The analytics state that must survive relaunch: install cohort, reached +/// milestones, per-day feature dedupe, the last version, the last upgrade +/// click and the last known plan. Stored in an injected `UserDefaults` under +/// `cmux.analytics.*`. +/// +/// Confined to the ``ProductAnalytics`` pipeline actor; never read elsewhere. +// `UserDefaults` is documented thread-safe; the ledger is only touched from +// the pipeline actor, so `@unchecked Sendable` adds no shared mutation. +public struct ProductAnalyticsLedger: @unchecked Sendable { + private let defaults: UserDefaults + + /// - Parameter defaults: The store. The app passes `.standard`; tests pass a suite. + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + private enum Key { + static let installFirstSeenAt = "cmux.analytics.installFirstSeenAt" + static let installIsNew = "cmux.analytics.installIsNew" + static let milestones = "cmux.analytics.milestones" + static let featureDays = "cmux.analytics.featureDays" + static let lastVersion = "cmux.analytics.lastVersion" + static let lastUpgradeSource = "cmux.analytics.lastUpgradeSource" + static let lastUpgradeAt = "cmux.analytics.lastUpgradeAt" + static let lastUpgradeAccount = "cmux.analytics.lastUpgradeAccount" + static let lastPlanAccount = "cmux.analytics.lastPlanAccount" + static let lastPlanPaid = "cmux.analytics.lastPlanPaid" + } + + /// When this install was first seen by the catalog, recording it on the + /// first call. Returns whether that first call happened now. + mutating func registerInstall(now: Date, isPreexisting: Bool) -> (firstSeenAt: Date, isNewInstall: Bool, isFirstRun: Bool) { + if let date = defaults.object(forKey: Key.installFirstSeenAt) as? Date { + return (date, defaults.bool(forKey: Key.installIsNew), false) + } + defaults.set(now, forKey: Key.installFirstSeenAt) + defaults.set(!isPreexisting, forKey: Key.installIsNew) + return (now, !isPreexisting, true) + } + + var installFirstSeenAt: Date? { defaults.object(forKey: Key.installFirstSeenAt) as? Date } + var installIsNew: Bool { defaults.bool(forKey: Key.installIsNew) } + + /// Marks `milestone` reached; true only the first time. + func claimMilestone(_ milestone: ActivationMilestone) -> Bool { + var reached = Set(defaults.stringArray(forKey: Key.milestones) ?? []) + guard reached.insert(milestone.rawValue).inserted else { return false } + defaults.set(reached.sorted(), forKey: Key.milestones) + return true + } + + /// Marks `feature` used on `day`; true only for the first use that day. + func claimFeatureDay(_ feature: ProductFeature, day: String) -> Bool { + var days = defaults.dictionary(forKey: Key.featureDays) as? [String: String] ?? [:] + guard days[feature.rawValue] != day else { return false } + days[feature.rawValue] = day + defaults.set(days, forKey: Key.featureDays) + return true + } + + /// Records `version` and returns the previous launch's version. Writes + /// only on a change: every defaults write notifies app-wide observers. + func swapLastVersion(_ version: String) -> String? { + let previous = defaults.string(forKey: Key.lastVersion) + if previous != version { defaults.set(version, forKey: Key.lastVersion) } + return previous + } + + /// Records an upgrade click. `account` is the signed-in account, or nil + /// when signed out (the click may then lead to any account's checkout). + func recordUpgradeClick(source: String, account: String?, at date: Date) { + defaults.set(source, forKey: Key.lastUpgradeSource) + defaults.set(date, forKey: Key.lastUpgradeAt) + defaults.set(account, forKey: Key.lastUpgradeAccount) + } + + /// The last unattributed click that may apply to `account`. + func upgradeClick(for account: String) -> (source: String, at: Date)? { + guard let source = defaults.string(forKey: Key.lastUpgradeSource), + let date = defaults.object(forKey: Key.lastUpgradeAt) as? Date else { return nil } + if let clickAccount = defaults.string(forKey: Key.lastUpgradeAccount), clickAccount != account { return nil } + return (source, date) + } + + /// Forgets the click once a plan activation used it. + func clearUpgradeClick() { + defaults.removeObject(forKey: Key.lastUpgradeSource) + defaults.removeObject(forKey: Key.lastUpgradeAt) + defaults.removeObject(forKey: Key.lastUpgradeAccount) + } + + /// Records the plan for `account` and returns what was known for that + /// account before, or nil when it was a different or no account. Writes + /// only on a change. + func swapPlan(account: String, isPaid: Bool) -> Bool? { + let previousAccount = defaults.string(forKey: Key.lastPlanAccount) + let previous = previousAccount == account ? defaults.object(forKey: Key.lastPlanPaid) as? Bool : nil + if previousAccount != account { defaults.set(account, forKey: Key.lastPlanAccount) } + if previous != isPaid { defaults.set(isPaid, forKey: Key.lastPlanPaid) } + return previous + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsPipeline.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsPipeline.swift new file mode 100644 index 000000000000..a034429d1caf --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsPipeline.swift @@ -0,0 +1,304 @@ +import CMUXMobileCore +import Foundation + +/// Processes ``ProductAnalyticsCommand``s in order, off the caller's thread: +/// deduplicates, derives funnel events, merges the shared context and hands +/// the result to the sink. +actor ProductAnalyticsPipeline { + /// How long after an upgrade click a newly paid plan is attributed to it. + static let upgradeAttributionWindow: TimeInterval = 7 * 24 * 60 * 60 + static let maximumTrackedAgentSessions = 1_024 + + private let sink: any AnalyticsEmitting + private var ledger: ProductAnalyticsLedger + private var context: ProductAnalyticsContext + private let isPreexistingInstall: Bool + private let now: @Sendable () -> Date + private var shownThisLaunch: Set = [] + /// Sessions seen this launch, with whether their start was counted. + private var agentSessions: [String: (agent: AgentKind, counted: Bool)] = [:] + /// Session ids in first-seen order, for oldest-first eviction. + private var agentSessionOrder: [String] = [] + private var identifiedUserID: String? + private var consecutiveCreateFailures = 0 + + init( + sink: any AnalyticsEmitting, + ledger: ProductAnalyticsLedger, + context: ProductAnalyticsContext, + isPreexistingInstall: Bool, + now: @escaping @Sendable () -> Date + ) { + self.sink = sink + self.ledger = ledger + self.context = context + self.isPreexistingInstall = isPreexistingInstall + self.now = now + } + + func run(_ commands: AsyncStream) async { + for await command in commands { + handle(command) + } + } + + private func handle(_ command: ProductAnalyticsCommand) { + switch command { + case .launch: + launch() + case let .track(event): + track(event) + case let .milestone(milestone): + reach(milestone) + case let .feature(feature): + guard ledger.claimFeatureDay(feature, day: Self.utcDay(now())) else { return } + track(.featureUsed(feature: feature)) + case let .context(change): + apply(change) + case let .identify(userID): + identify(userID: userID) + case .signOut: + signOut() + case let .plan(accountID, isPaid): + notePlan(accountID: accountID, isPaid: isPaid) + case let .agentHook(source, sessionID, phase): + noteAgentHook(source: source, sessionID: sessionID, phase: phase) + case let .cloudRequest(observation): + noteCloudRequest(observation) + case let .machineCreateFinished(succeeded, errorCode, seconds): + noteMachineCreateFinished(succeeded: succeeded, errorCode: errorCode, seconds: seconds) + case let .barrier(continuation): + continuation.resume() + } + } + + // MARK: - Capture + + private func track(_ event: ProductAnalyticsEvent) { + switch event { + case let .upgradePromptShown(source, _): + guard shownThisLaunch.insert("\(event.name)|\(source)").inserted else { return } + case .cloudButtonShown: + guard shownThisLaunch.insert(event.name).inserted else { return } + default: + break + } + var properties = context.properties + properties.merge(event.properties) { _, eventValue in eventValue } + sink.capture(event.name, properties) + followUp(after: event) + } + + /// Milestones and ledger updates implied by an event, so call sites + /// report the action once. + private func followUp(after event: ProductAnalyticsEvent) { + switch event { + case let .upgradeEntrypointOpened(source, _, _): + ledger.recordUpgradeClick(source: source, account: identifiedUserID, at: now()) + reach(.firstUpgradeClick) + case .signInSucceeded: + reach(.firstSignIn) + case .cloudEnableCompleted(.success): + reach(.firstCloudEnable) + case .cloudMachineCreateSucceeded: + reach(.firstCloudMachine) + case .cloudWorkspaceCreated: + reach(.firstCloudWorkspace) + case .agentSessionStarted: + reach(.firstAgentSession) + case .planActivated: + reach(.firstPaidPlan) + case .featureUsed(.split): + reach(.firstSplit) + case .featureUsed(.browserPane): + reach(.firstBrowserPane) + default: + break + } + } + + private func reach(_ milestone: ActivationMilestone) { + guard ledger.claimMilestone(milestone) else { return } + let firstSeen = ledger.installFirstSeenAt ?? now() + let days = max(0, Int(now().timeIntervalSince(firstSeen) / 86_400)) + track(.activationMilestoneReached(milestone: milestone, isNewInstall: ledger.installIsNew, daysSinceInstall: days)) + } + + // MARK: - Launch and context + + private func launch() { + sink.setSuperProperties(context.properties) + let install = ledger.registerInstall(now: now(), isPreexisting: isPreexistingInstall) + if install.isNewInstall { + reach(.firstLaunch) + } else if install.isFirstRun { + // An install from before this catalog: its first launch already + // happened, unrecorded. Claim it silently so it never fires late. + _ = ledger.claimMilestone(.firstLaunch) + } + let previous = ledger.swapLastVersion(context.appVersion) + if let previous, previous != context.appVersion, + let direction = Self.direction(from: previous, to: context.appVersion) { + track(.appUpdated(direction: direction, fromVersion: previous, toVersion: context.appVersion)) + } + } + + private func apply(_ change: ProductAnalyticsContextChange) { + var next = context + switch change { + case let .signedIn(value): next.isSignedIn = value + case let .plan(value): next.plan = value + case let .cloudEnabled(value): next.cloudEnabled = value + case let .workspacesOpen(value): next.workspacesOpen = value + } + guard next != context else { return } + context = next + sink.setSuperProperties(context.properties) + } + + // MARK: - Identity + + private func identify(userID: String) { + if identifiedUserID != userID { + identifiedUserID = userID + sink.identify(userId: userID, alias: nil, properties: ["stack_user_id": .string(userID)]) + } + apply(.signedIn(true)) + } + + private func signOut() { + guard identifiedUserID != nil || context.isSignedIn else { return } + // Captured before the reset so the sign-out joins the signed-in person. + track(.signedOut) + identifiedUserID = nil + context.isSignedIn = false + context.plan = .unknown + sink.identify(userId: nil, alias: nil, properties: [:]) + sink.setSuperProperties(context.properties) + } + + private func notePlan(accountID: String, isPaid: Bool) { + apply(.plan(isPaid ? .paid : .free)) + let previous = ledger.swapPlan(account: accountID, isPaid: isPaid) + if isPaid, previous != true { + let click = ledger.upgradeClick(for: accountID).flatMap { click -> (source: String, at: Date)? in + now().timeIntervalSince(click.at) <= Self.upgradeAttributionWindow ? click : nil + } + // A first sighting of a paid account is an activation only right + // after an upgrade click; otherwise it is an existing subscriber. + guard previous == false || click != nil else { return } + // One click attributes one activation. + if click != nil { ledger.clearUpgradeClick() } + track(.planActivated( + upgradeSource: click?.source, + sinceUpgradeClick: click.map { DurationBucket(seconds: now().timeIntervalSince($0.at)) } + )) + } else if !isPaid, previous == true { + track(.planDeactivated) + } + } + + // MARK: - Agents + + private func noteAgentHook(source: String, sessionID: String, phase: AgentHookPhase) { + guard !sessionID.isEmpty else { return } + switch phase { + case .sessionEnd: + guard let session = agentSessions.removeValue(forKey: sessionID) else { return } + agentSessionOrder.removeAll { $0 == sessionID } + // Ends pair with counted starts, so started minus ended stays meaningful. + if session.counted { track(.agentSessionEnded(agent: session.agent)) } + case .sessionStart, .activity: + guard agentSessions[sessionID] == nil else { return } + if agentSessionOrder.count >= Self.maximumTrackedAgentSessions { + agentSessions.removeValue(forKey: agentSessionOrder.removeFirst()) + } + let agent = AgentKind(hookSource: source) + agentSessions[sessionID] = (agent, phase == .sessionStart) + agentSessionOrder.append(sessionID) + // Only a SessionStart is a new session. Activity from a session the + // app first sees mid-flight (it outlived a relaunch) is tracked so + // its end counts, but it is not started again. + if phase == .sessionStart { + track(.agentSessionStarted(agent: agent)) + } + } + } + + // MARK: - Cloud + + /// Creates are reported by ``noteMachineCreateFinished(succeeded:errorCode:seconds:)``, + /// which sees every launcher; their requests are skipped here so a create + /// counts once. + private func noteCloudRequest(_ request: CloudRequestObservation) { + let errorCode = request.errorCode ?? (request.success ? nil : "unknown") + if errorCode == "cancelled" { return } + if request.method == "POST", request.route == "/api/vm" { return } + let duration = DurationBucket(seconds: request.durationMs / 1_000) + let isMachineSubresource = request.route.hasPrefix("/api/vm/{id}") + switch (request.method, request.route) { + case ("DELETE", "/api/vm/{id}"): + if request.success { track(.cloudMachineDeleted) } + case (_, "/api/vm/{id}/attach-endpoint"): + track(.cloudMachineAttached( + success: request.success, + latency: duration, + errorClass: errorCode.map(CloudErrorClass.init(errorCode:)) + )) + default: + break + } + if let errorCode, let limit = CloudPlanLimit(errorCode: errorCode) { + let operation: String + switch (request.method, request.route) { + case (_, "/api/vm/{id}/resize"): operation = "resize" + case (_, "/api/vm/restore"): operation = "restore" + case (_, "/api/vm/{id}/fork"): operation = "fork" + default: operation = isMachineSubresource ? "machine_action" : "other" + } + track(.cloudPlanLimitHit(limit: limit, operation: operation)) + } + } + + private func noteMachineCreateFinished(succeeded: Bool, errorCode: String?, seconds: Double) { + if succeeded { + consecutiveCreateFailures = 0 + track(.cloudMachineCreateSucceeded(duration: DurationBucket(seconds: seconds))) + return + } + let code = errorCode ?? "unknown" + consecutiveCreateFailures += 1 + track(.cloudMachineCreateFailed( + errorClass: CloudErrorClass(errorCode: code), + errorCode: code, + consecutiveFailures: consecutiveCreateFailures + )) + if let limit = CloudPlanLimit(errorCode: code) { + track(.cloudPlanLimitHit(limit: limit, operation: "create")) + } + } + + // MARK: - Helpers + + static func utcDay(_ date: Date) -> String { + var calendar = Calendar(identifier: .iso8601) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) + } + + /// Compares dotted numeric versions; nil when either is not one. + static func direction(from previous: String, to current: String) -> AppUpdateDirection? { + let lhs = previous.split(separator: ".").map { Int($0) } + let rhs = current.split(separator: ".").map { Int($0) } + guard !lhs.isEmpty, !rhs.isEmpty, !lhs.contains(nil), !rhs.contains(nil) else { return nil } + let left = lhs.compactMap { $0 } + let right = rhs.compactMap { $0 } + for index in 0..)] = [ + (.upgradePromptShown(source: "mac_sidebar_badge", plan: "pro"), "cmux_upgrade_prompt_shown", ["source", "plan"]), + (.upgradeEntrypointOpened(source: "mac_cloud_welcome", plan: "pro", destination: .pricingPage), + "cmux_upgrade_entrypoint_opened", ["source", "client", "plan", "destination"]), + (.upgradeBadgeDismissed(source: "mac_sidebar_badge"), "cmux_upgrade_badge_dismissed", ["source"]), + (.planActivated(upgradeSource: "mac_sidebar_badge", sinceUpgradeClick: .from1to3m), "cmux_plan_activated", + ["upgrade_source", "since_upgrade_click"]), + (.planDeactivated, "cmux_plan_deactivated", []), + (.cloudButtonShown, "cmux_cloud_button_shown", []), + (.cloudButtonClicked(action: .introduceCloud), "cmux_cloud_button_clicked", ["action"]), + (.cloudWelcomeShown(trigger: .launchAnnouncement, step: .upgrade), "cmux_cloud_welcome_shown", ["trigger", "next_step"]), + (.cloudWelcomeButtonClicked(trigger: .newCloudMachine, step: .enable, button: .enable), + "cmux_cloud_welcome_button_clicked", ["trigger", "next_step", "button"]), + (.cloudWelcomeClosed(trigger: .newCloudWorkspace, step: .upgrade, outcome: .notNow, visibleFor: .from3to10s), + "cmux_cloud_welcome_closed", ["trigger", "next_step", "outcome", "visible_for"]), + (.signInStarted, "cmux_sign_in_started", []), + (.signInSucceeded, "cmux_sign_in_succeeded", []), + (.signInFailed(reason: .timedOut), "cmux_sign_in_failed", ["reason"]), + (.signedOut, "cmux_signed_out", []), + (.cloudEnableCompleted(result: .success), "cmux_cloud_enable_completed", ["result"]), + (.cloudDisabled, "cmux_cloud_disabled", []), + (.cloudMachineCreateStarted(memoryGB: 8), "cmux_cloud_machine_create_started", ["memory_gb"]), + (.cloudMachineCreateStarted(memoryGB: nil), "cmux_cloud_machine_create_started", []), + (.cloudMachineCreateSucceeded(duration: .from10to30s), "cmux_cloud_machine_create_succeeded", ["duration"]), + (.cloudMachineCreateFailed(errorClass: .server, errorCode: "http_500", consecutiveFailures: 2), + "cmux_cloud_machine_create_failed", ["error_class", "error_code", "consecutive_failures"]), + (.cloudMachineDeleted, "cmux_cloud_machine_deleted", []), + (.cloudMachineAttached(success: false, latency: .from1to3s, errorClass: .network), + "cmux_cloud_machine_attached", ["success", "latency", "error_class"]), + (.cloudWorkspaceCreated(machineChoice: .resolved, duration: .from3to10s), "cmux_cloud_workspace_created", + ["machine_choice", "duration"]), + (.cloudPlanLimitHit(limit: .activeMachines, operation: "create"), "cmux_cloud_plan_limit_hit", ["limit", "operation"]), + (.activationMilestoneReached(milestone: .firstSplit, isNewInstall: true, daysSinceInstall: 0), + "cmux_activation_milestone_reached", ["milestone", "is_new_install", "days_since_install"]), + (.agentSessionStarted(agent: .codex), "cmux_agent_session_started", ["agent"]), + (.agentSessionEnded(agent: .claudeCode), "cmux_agent_session_ended", ["agent"]), + (.featureUsed(feature: .commandPalette), "cmux_feature_used", ["feature"]), + (.appUpdated(direction: .upgrade, fromVersion: "0.65.1", toVersion: "0.65.2"), "cmux_app_updated", + ["direction", "from_version", "to_version"]), + ] + + @Test("every event has its pinned name and property keys") + func pinnedNamesAndKeys() { + for (event, name, keys) in Self.catalog { + #expect(event.name == name) + #expect(Set(event.properties.keys) == keys, "\(name)") + } + } + + @Test("names are unique, snake_case and cmux_ prefixed") + func namingConvention() { + let names = Set(Self.catalog.map(\.1)) + #expect(names.count == 28) + for name in names { + #expect(name.hasPrefix("cmux_")) + #expect(name.allSatisfy { $0.isLowercase || $0 == "_" }) + } + } + + @Test("the shared context keys are pinned and never collide with event keys") + func contextKeys() { + let context = ProductAnalyticsContext(appVersion: "0.65.2", appBuild: "1", channel: "nightly", osVersion: "15.5") + #expect(Set(context.properties.keys) == [ + "app_version", "app_build", "channel", "os_version", + "account_plan", "is_signed_in", "cloud_enabled", "workspaces_open", + ]) + for (event, name, _) in Self.catalog { + #expect(Set(event.properties.keys).isDisjoint(with: context.properties.keys), "\(name)") + } + } + + @Test("free text cannot reach a token property") + func tokensRejectFreeText() { + let event = ProductAnalyticsEvent.cloudMachineCreateFailed( + errorClass: .client, + errorCode: "Machine my-host.example.com failed: /Users/me", + consecutiveFailures: 1 + ) + #expect(event.properties["error_code"] == .string("other")) + let upgrade = ProductAnalyticsEvent.upgradePromptShown(source: "user@example.com", plan: "pro") + #expect(upgrade.properties["source"] == .string("other")) + let update = ProductAnalyticsEvent.appUpdated(direction: .upgrade, fromVersion: "/tmp/x", toVersion: "0.65.2") + #expect(update.properties["from_version"] == .string("unknown")) + } + + @Test("agent sources map to a closed set") + func agentKinds() { + #expect(AgentKind(hookSource: "claude") == .claudeCode) + #expect(AgentKind(hookSource: "codex") == .codex) + #expect(AgentKind(hookSource: "opencode") == .opencode) + #expect(AgentKind(hookSource: "cmux-pi-extension") == .pi) + #expect(AgentKind(hookSource: "my secret agent") == .other) + } + + @Test("Cloud error codes classify without the raw message") + func errorClasses() { + #expect(CloudErrorClass(errorCode: "vm_active_limit_exceeded") == .planLimit) + #expect(CloudErrorClass(errorCode: "not_signed_in") == .auth) + #expect(CloudErrorClass(errorCode: "backend_unreachable") == .network) + #expect(CloudErrorClass(errorCode: "http_503") == .server) + #expect(CloudErrorClass(errorCode: "vm_not_found") == .client) + #expect(CloudPlanLimit(errorCode: "vm_memory_requires_plan") == .machineMemory) + #expect(CloudPlanLimit(errorCode: "vm_not_found") == nil) + } + + @Test("durations bucket at their boundaries") + func durationBuckets() { + #expect(DurationBucket(seconds: -1) == .under1s) + #expect(DurationBucket(seconds: 0.999) == .under1s) + #expect(DurationBucket(seconds: 1) == .from1to3s) + #expect(DurationBucket(seconds: 59) == .from30to60s) + #expect(DurationBucket(seconds: 7 * 86_400) == .over7d) + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsPipelineTests.swift b/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsPipelineTests.swift new file mode 100644 index 000000000000..633bc49fc5f7 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsPipelineTests.swift @@ -0,0 +1,301 @@ +import CMUXMobileCore +import Foundation +import os +import Testing + +@testable import CmuxProductAnalytics + +/// A clock tests can move. +final class TestClock: Sendable { + private let state = OSAllocatedUnfairLock(initialState: Date(timeIntervalSince1970: 1_800_000_000)) + var now: Date { state.withLock { $0 } } + func advance(_ seconds: TimeInterval) { state.withLock { $0 += seconds } } +} + +@Suite("Product analytics pipeline") +struct ProductAnalyticsPipelineTests { + /// A class so the suite it created is removed when the test drops it. + final class Harness { + let sink: RecordingAnalyticsSink + let clock: TestClock + let defaults: UserDefaults + let analytics: ProductAnalytics + private let ownedSuiteName: String? + + init( + enabled: Bool = true, + preexisting: Bool = false, + version: String = "0.66.0", + defaults: UserDefaults? = nil + ) { + let suiteName = defaults == nil ? "product-analytics-\(UUID().uuidString)" : nil + let defaults = defaults ?? UserDefaults(suiteName: suiteName)! + ownedSuiteName = suiteName + let sink = RecordingAnalyticsSink() + let clock = TestClock() + self.defaults = defaults + self.sink = sink + self.clock = clock + analytics = ProductAnalytics( + isEnabled: enabled, + sink: sink, + ledger: ProductAnalyticsLedger(defaults: defaults), + context: ProductAnalyticsContext(appVersion: version, appBuild: "100", channel: "dev", osVersion: "15.5"), + isPreexistingInstall: preexisting, + now: { clock.now } + ) + } + + deinit { + if let ownedSuiteName { defaults.removePersistentDomain(forName: ownedSuiteName) } + } + + func milestones() -> [String] { + sink.events.filter { $0.name == "cmux_activation_milestone_reached" }.compactMap { + if case let .string(value)? = $0.properties["milestone"] { return value } + return nil + } + } + } + + @Test("opt-out sends nothing at all") + func optOutStopsAllCapture() async { + let harness = Harness(enabled: false) + harness.analytics.track(.signInStarted) + harness.analytics.track(.upgradeEntrypointOpened(source: "mac_sidebar_badge", plan: "pro", destination: .pricingPage)) + harness.analytics.featureUsed(.commandPalette) + harness.analytics.identify(userID: "user-1") + harness.analytics.notePlan(accountID: "user-1", isPaid: true) + harness.analytics.noteAgentHook(source: "codex", sessionID: "s", phase: .sessionStart) + harness.analytics.signedOut() + await harness.analytics.drain() + #expect(harness.sink.calls.isEmpty) + #expect(ProductAnalytics.disabled.isEnabled == false) + } + + @Test("events carry the shared context; event keys win") + func contextMerged() async throws { + let harness = Harness() + harness.analytics.updateContext(.cloudEnabled(true)) + harness.analytics.updateContext(.plan(.free)) + harness.analytics.track(.upgradePromptShown(source: "mac_sidebar_badge", plan: "pro")) + await harness.analytics.drain() + let event = try #require(harness.sink.events.first { $0.name == "cmux_upgrade_prompt_shown" }) + #expect(event.properties["source"] == .string("mac_sidebar_badge")) + #expect(event.properties["plan"] == .string("pro")) + #expect(event.properties["account_plan"] == .string("free")) + #expect(event.properties["cloud_enabled"] == .bool(true)) + #expect(event.properties["channel"] == .string("dev")) + #expect(event.properties["app_version"] == .string("0.66.0")) + } + + @Test("a new install reports first launch once; a pre-catalog install never does") + func firstLaunch() async { + let fresh = Harness() + await fresh.analytics.drain() + #expect(fresh.milestones() == ["first_launch"]) + + let relaunch = Harness(defaults: fresh.defaults) + await relaunch.analytics.drain() + #expect(relaunch.milestones().isEmpty) + + withExtendedLifetime(fresh) {} + + let existing = Harness(preexisting: true) + existing.analytics.featureUsed(.split) + await existing.analytics.drain() + #expect(existing.milestones() == ["first_split"]) + let milestone = existing.sink.events.first { $0.name == "cmux_activation_milestone_reached" } + #expect(milestone?.properties["is_new_install"] == .bool(false)) + } + + @Test("upgrade prompts dedupe per source per launch; clicks never dedupe") + func impressionsDedupe() async { + let harness = Harness() + for _ in 0..<3 { + harness.analytics.track(.upgradePromptShown(source: "mac_sidebar_badge", plan: "pro")) + harness.analytics.track(.upgradeEntrypointOpened(source: "mac_sidebar_badge", plan: "pro", destination: .pricingPage)) + } + harness.analytics.track(.upgradePromptShown(source: "mac_cloud_welcome", plan: "pro")) + harness.analytics.track(.cloudButtonShown) + harness.analytics.track(.cloudButtonShown) + await harness.analytics.drain() + let names = harness.sink.eventNames + #expect(names.filter { $0 == "cmux_upgrade_prompt_shown" }.count == 2) + #expect(names.filter { $0 == "cmux_cloud_button_shown" }.count == 1) + #expect(names.filter { $0 == "cmux_upgrade_entrypoint_opened" }.count == 3) + #expect(harness.milestones() == ["first_launch", "first_upgrade_click"]) + } + + @Test("features count once per UTC day") + func featureDailyDedupe() async { + let harness = Harness() + harness.analytics.featureUsed(.commandPalette) + harness.analytics.featureUsed(.commandPalette) + await harness.analytics.drain() + harness.clock.advance(86_400) + harness.analytics.featureUsed(.commandPalette) + await harness.analytics.drain() + #expect(harness.sink.eventNames.filter { $0 == "cmux_feature_used" }.count == 2) + } + + @Test("a paid plan after an upgrade click is attributed to that click's source") + func planActivationAttribution() async throws { + let harness = Harness() + harness.analytics.identify(userID: "user-1") + harness.analytics.notePlan(accountID: "user-1", isPaid: false) + harness.analytics.track(.upgradeEntrypointOpened(source: "mac_cloud_welcome", plan: "pro", destination: .pricingPage)) + await harness.analytics.drain() + harness.clock.advance(120) + harness.analytics.notePlan(accountID: "user-1", isPaid: true) + harness.analytics.notePlan(accountID: "user-1", isPaid: true) + await harness.analytics.drain() + let activated = harness.sink.events.filter { $0.name == "cmux_plan_activated" } + #expect(activated.count == 1) + let event = try #require(activated.first) + #expect(event.properties["upgrade_source"] == .string("mac_cloud_welcome")) + #expect(event.properties["since_upgrade_click"] == .string("1m_3m")) + #expect(event.properties["account_plan"] == .string("paid")) + #expect(harness.milestones().contains("first_paid_plan")) + + harness.analytics.notePlan(accountID: "user-1", isPaid: false) + await harness.analytics.drain() + #expect(harness.sink.eventNames.last == "cmux_plan_deactivated") + } + + @Test("a click attributes one activation, and only for the account that clicked") + func attributionIsPerAccountAndSingleUse() async { + let harness = Harness() + harness.analytics.identify(userID: "user-a") + harness.analytics.notePlan(accountID: "user-a", isPaid: false) + harness.analytics.track(.upgradeEntrypointOpened(source: "mac_sidebar_badge", plan: "pro", destination: .pricingPage)) + // Switching to an already-paid account is not an activation from A's click. + harness.analytics.signedOut() + harness.analytics.identify(userID: "user-b") + harness.analytics.notePlan(accountID: "user-b", isPaid: true) + await harness.analytics.drain() + #expect(!harness.sink.eventNames.contains("cmux_plan_activated")) + + harness.analytics.signedOut() + harness.analytics.identify(userID: "user-a") + harness.analytics.notePlan(accountID: "user-a", isPaid: true) + harness.analytics.notePlan(accountID: "user-a", isPaid: false) + harness.analytics.notePlan(accountID: "user-a", isPaid: true) + await harness.analytics.drain() + let activations = harness.sink.events.filter { $0.name == "cmux_plan_activated" } + #expect(activations.map { $0.properties["upgrade_source"] } == [.string("mac_sidebar_badge"), .string("none")]) + } + + @Test("an existing subscriber seen for the first time is not an activation") + func existingSubscriber() async { + let harness = Harness() + harness.analytics.notePlan(accountID: "user-1", isPaid: true) + await harness.analytics.drain() + #expect(!harness.sink.eventNames.contains("cmux_plan_activated")) + } + + @Test("identify once per user, and sign out after the event") + func identityLifecycle() async { + let harness = Harness() + harness.analytics.identify(userID: "user-1") + harness.analytics.identify(userID: "user-1") + harness.analytics.signedOut() + harness.analytics.signedOut() + await harness.analytics.drain() + let identity: [String] = harness.sink.calls.compactMap { + switch $0 { + case let .identify(user): return "identify:\(user ?? "nil")" + case let .capture(name, _): return name == "cmux_signed_out" ? name : nil + case .superProperties: return nil + } + } + #expect(identity == ["identify:user-1", "cmux_signed_out", "identify:nil"]) + let signedOut = harness.sink.events.first { $0.name == "cmux_signed_out" } + #expect(signedOut?.properties["is_signed_in"] == .bool(true)) + } + + @Test("agent sessions start once, end once, and never send the session id") + func agentSessions() async { + let harness = Harness() + harness.analytics.noteAgentHook(source: "claude", sessionID: "secret-session", phase: .sessionStart) + harness.analytics.noteAgentHook(source: "claude", sessionID: "secret-session", phase: .activity) + // A session first seen mid-flight (it outlived a relaunch) is neither + // started nor ended again. + harness.analytics.noteAgentHook(source: "codex", sessionID: "other", phase: .activity) + harness.analytics.noteAgentHook(source: "codex", sessionID: "other", phase: .sessionEnd) + harness.analytics.noteAgentHook(source: "claude", sessionID: "secret-session", phase: .sessionEnd) + harness.analytics.noteAgentHook(source: "claude", sessionID: "secret-session", phase: .sessionEnd) + await harness.analytics.drain() + let names = harness.sink.eventNames + #expect(names.filter { $0 == "cmux_agent_session_started" }.count == 1) + #expect(names.filter { $0 == "cmux_agent_session_ended" }.count == 1) + #expect(harness.milestones().contains("first_agent_session")) + for event in harness.sink.events { + #expect(!event.properties.values.contains(.string("secret-session"))) + } + } + + @Test("Cloud requests become delete, attach and plan-limit events; creates count once") + func cloudRequests() async { + let harness = Harness() + let analytics = harness.analytics + // A create's own request is skipped: the create coordinator reports it. + analytics.noteCloudRequest(.init(method: "POST", route: "/api/vm", success: false, errorCode: "vm_active_limit_exceeded", durationMs: 300)) + analytics.noteCloudRequest(.init(method: "GET", route: "/api/vm/{id}/attach-endpoint", success: true, errorCode: nil, durationMs: 450)) + analytics.noteCloudRequest(.init(method: "GET", route: "/api/vm/{id}/attach-endpoint", success: false, errorCode: "backend_unreachable", durationMs: 4_000)) + analytics.noteCloudRequest(.init(method: "DELETE", route: "/api/vm/{id}", success: true, errorCode: nil, durationMs: 800)) + analytics.noteCloudRequest(.init(method: "POST", route: "/api/vm/{id}/resize", success: false, errorCode: "vm_resize_plan_limit", durationMs: 100)) + analytics.noteCloudRequest(.init(method: "GET", route: "/api/vm", success: false, errorCode: "cancelled", durationMs: 1)) + await analytics.drain() + let events = harness.sink.events.filter { $0.name != "cmux_activation_milestone_reached" } + #expect(events.map(\.name) == [ + "cmux_cloud_machine_attached", + "cmux_cloud_machine_attached", + "cmux_cloud_machine_deleted", + "cmux_cloud_plan_limit_hit", + ]) + #expect(events[0].properties["latency"] == .string("lt_1s")) + #expect(events[1].properties["success"] == .bool(false)) + #expect(events[1].properties["error_class"] == .string("network")) + #expect(events[3].properties["limit"] == .string("resize_size")) + #expect(events[3].properties["operation"] == .string("resize")) + } + + @Test("machine creates report outcome, consecutive failures and the plan limit") + func machineCreates() async { + let harness = Harness() + let analytics = harness.analytics + analytics.noteMachineCreateFinished(succeeded: false, errorCode: nil, seconds: 2) + analytics.noteMachineCreateFinished(succeeded: false, errorCode: "vm_active_limit_exceeded", seconds: 0.3) + analytics.noteMachineCreateFinished(succeeded: true, errorCode: nil, seconds: 14) + await analytics.drain() + let events = harness.sink.events.filter { $0.name != "cmux_activation_milestone_reached" } + #expect(events.map(\.name) == [ + "cmux_cloud_machine_create_failed", + "cmux_cloud_machine_create_failed", + "cmux_cloud_plan_limit_hit", + "cmux_cloud_machine_create_succeeded", + ]) + #expect(events[0].properties["error_class"] == .string("server")) + #expect(events[1].properties["consecutive_failures"] == .int(2)) + #expect(events[1].properties["error_class"] == .string("plan_limit")) + #expect(events[2].properties["limit"] == .string("active_machines")) + #expect(events[2].properties["operation"] == .string("create")) + #expect(events[3].properties["duration"] == .string("10s_30s")) + #expect(harness.milestones().contains("first_cloud_machine")) + } + + @Test("a version change reports its direction") + func appUpdated() async { + let first = Harness(version: "0.65.2") + await first.analytics.drain() + let upgraded = Harness(version: "0.66.0", defaults: first.defaults) + await upgraded.analytics.drain() + let downgraded = Harness(version: "0.65.2", defaults: first.defaults) + await downgraded.analytics.drain() + #expect(upgraded.sink.events.first { $0.name == "cmux_app_updated" }?.properties["direction"] == .string("upgrade")) + #expect(downgraded.sink.events.first { $0.name == "cmux_app_updated" }?.properties["direction"] == .string("downgrade")) + #expect(!first.sink.eventNames.contains("cmux_app_updated")) + withExtendedLifetime(first) {} + } +} diff --git a/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/RecordingAnalyticsSink.swift b/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/RecordingAnalyticsSink.swift new file mode 100644 index 000000000000..5ee5a4d68295 --- /dev/null +++ b/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/RecordingAnalyticsSink.swift @@ -0,0 +1,40 @@ +import CMUXMobileCore +import Foundation +import os + +/// Records every sink call in order. +final class RecordingAnalyticsSink: AnalyticsEmitting { + enum Call: Equatable { + case capture(String, [String: AnalyticsValue]) + case identify(String?) + case superProperties([String: AnalyticsValue]) + } + + // Sink calls arrive from the pipeline actor while tests read on their own task. + private let state = OSAllocatedUnfairLock<[Call]>(initialState: []) + + var calls: [Call] { state.withLock { $0 } } + + var events: [(name: String, properties: [String: AnalyticsValue])] { + calls.compactMap { call in + if case let .capture(name, properties) = call { return (name: name, properties: properties) } + return nil + } + } + + var eventNames: [String] { events.map(\.name) } + + func capture(_ event: String, _ properties: [String: AnalyticsValue]) { + state.withLock { $0.append(.capture(event, properties)) } + } + + func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) { + state.withLock { $0.append(.identify(userId)) } + } + + func setSuperProperties(_ properties: [String: AnalyticsValue]) { + state.withLock { $0.append(.superProperties(properties)) } + } + + func flush() async {} +} diff --git a/Sources/AccountAnalyticsState.swift b/Sources/AccountAnalyticsState.swift new file mode 100644 index 000000000000..7363c4342291 --- /dev/null +++ b/Sources/AccountAnalyticsState.swift @@ -0,0 +1,69 @@ +import CmuxProductAnalytics + +/// Turns account observations into analytics actions: identify on sign-in, +/// a success or failure for each sign-in the user started, and sign-out. Pure, so the transitions are unit tested without auth. +struct AccountAnalyticsState: Equatable { + enum Action: Equatable { + case identify(userID: String) + case signInSucceeded + case signInFailed(SignInFailureReason) + case signedOut + } + + private(set) var userID: String? + /// A user-started sign-in has no result yet. Session restore never sets it. + private(set) var isSignInPending = false + private var reportedFailure: SignInFailureReason? + + mutating func signInStarted() { + isSignInPending = true + reportedFailure = nil + } + + /// - Parameters: + /// - userID: The signed-in Stack user id, or nil. + /// - isSettling: A restore or sign-in is still running, so nil is not yet signed out. + /// - failure: The browser sign-in's latest failure. + mutating func observe( + userID newUserID: String?, + isSettling: Bool, + failure: SignInFailureReason? + ) -> [Action] { + var actions: [Action] = [] + if let newUserID { + if newUserID != userID { + actions.append(.identify(userID: newUserID)) + } + if userID != newUserID, isSignInPending { + actions.append(.signInSucceeded) + isSignInPending = false + } + userID = newUserID + } else if !isSettling, userID != nil { + actions.append(.signedOut) + userID = nil + } + if isSignInPending, let failure, failure != reportedFailure { + actions.append(.signInFailed(failure)) + reportedFailure = failure + isSignInPending = false + } + return actions + } + + static func reason(_ failure: AccountSignInModel.Failure?) -> SignInFailureReason? { + guard let failure else { return nil } + return switch failure { + case .cancelled: .cancelled + case .offline: .offline + case .network, .unknown: .network + case .timedOut: .timedOut + case .server: .server + case .invalidLink: .invalidLink + case .browserUnavailable: .browserUnavailable + case .unauthorized: .unauthorized + case .rejected: .rejected + case .rateLimited: .rateLimited + } + } +} diff --git a/Sources/AppDelegate+NewCloudWorkspace.swift b/Sources/AppDelegate+NewCloudWorkspace.swift index 0d06029514ba..6e9cb66c2573 100644 --- a/Sources/AppDelegate+NewCloudWorkspace.swift +++ b/Sources/AppDelegate+NewCloudWorkspace.swift @@ -1,6 +1,7 @@ import AppKit import CmuxCloud import CmuxCloudMachines +import CmuxProductAnalytics import Foundation // MARK: - Cloud creation actions @@ -16,6 +17,7 @@ extension AppDelegate { ) -> Bool { if introduceCloudIfNeeded( over: preferredWindow ?? preferredTabManager.flatMap { mainWindowContext(for: $0) }.flatMap { resolvedWindow(for: $0) }, + trigger: .newCloudWorkspace, resume: { [weak self, weak preferredTabManager, weak preferredWindow] in _ = self?.performNewCloudWorkspaceOnResolvedMachineAction( tabManager: preferredTabManager, @@ -78,9 +80,16 @@ extension AppDelegate { } var accessBecameUnavailable = false do { + let startedAt = Date() let workspaceID = try await coordinator.createOnResolvedMachine( selection: selection, windowID: windowID, scopeID: scopeID, selectionRevision: revision ) + if workspaceID != nil { + ProductAnalytics.app.track(.cloudWorkspaceCreated( + machineChoice: .resolved, + duration: DurationBucket(seconds: Date().timeIntervalSince(startedAt)) + )) + } if workspaceID == nil { accessBecameUnavailable = !coordinator.isAvailable || coordinator.scopeIdentifier != scopeID @@ -149,8 +158,13 @@ extension AppDelegate { } } } - guard let workspaceID = try await coordinator.createOnMachine(request), - !Task.isCancelled, coordinator.isAvailable, coordinator.scopeIdentifier == scopeID else { return } + let startedAt = Date() + guard let workspaceID = try await coordinator.createOnMachine(request) else { return } + ProductAnalytics.app.track(.cloudWorkspaceCreated( + machineChoice: .explicit, + duration: DurationBucket(seconds: Date().timeIntervalSince(startedAt)) + )) + guard !Task.isCancelled, coordinator.isAvailable, coordinator.scopeIdentifier == scopeID else { return } resolvedDestination?.apply(workspaceID: workspaceID) selectedWorkspace = self?.focusCreatedCloudWorkspace( workspaceID, manager: tabManager, revision: revision, windowID: request.windowID @@ -208,6 +222,7 @@ extension AppDelegate { ?? event?.window if introduceCloudIfNeeded( over: introductionWindow, + trigger: .newCloudMachine, resume: { [weak self, weak preferredTabManager, weak introductionWindow] in _ = self?.performNewCloudMachineAction( tabManager: preferredTabManager, @@ -259,7 +274,8 @@ extension AppDelegate { }, presentIntroduction: { [weak self] in self?.cloudWelcomeWindowController.presentIntroduction(over: window) - } + }, + analytics: .app ).perform() } @@ -268,11 +284,13 @@ extension AppDelegate { /// Enable then resumes `resume`. private func introduceCloudIfNeeded( over window: NSWindow?, + trigger: CloudWelcomeTrigger, resume: @escaping @MainActor () -> Void ) -> Bool { guard cloudCreationGate() == .introduceCloud else { return false } cloudWelcomeWindowController.present( over: window ?? NSApp.keyWindow ?? NSApp.mainWindow, + trigger: trigger, onEnabled: resume ) return true diff --git a/Sources/AppDelegate+ProductAnalytics.swift b/Sources/AppDelegate+ProductAnalytics.swift new file mode 100644 index 000000000000..1a9901265910 --- /dev/null +++ b/Sources/AppDelegate+ProductAnalytics.swift @@ -0,0 +1,42 @@ +import AppKit +import CMUXAgentLaunch +import CmuxProductAnalytics + +extension AppDelegate { + /// Starts product analytics for the launch: records the launch itself and + /// counts agent sessions from accepted hook events. No-op when telemetry + /// is off, because ``ProductAnalytics/app`` is then disabled. + func startProductAnalytics() { + let analytics = ProductAnalytics.app + guard analytics.isEnabled else { return } + refreshProductAnalyticsActivityContext() + Task { @MainActor in + for await notification in NotificationCenter.default.notifications(named: .workstreamEventReceived) { + guard let event = notification.object as? WorkstreamEvent else { continue } + analytics.noteAgentHook( + source: event.source, + sessionID: event.sessionId, + phase: Self.agentHookPhase(event.hookEventName) + ) + } + } + } + + /// Refreshes the open-workspace count carried by later events. Runs on + /// activation (before the daily active event) and whenever a window's + /// workspace count changes. + func refreshProductAnalyticsActivityContext() { + let analytics = ProductAnalytics.app + guard analytics.isEnabled else { return } + let count = mainWindowContexts.values.reduce(0) { $0 + $1.tabManager.tabs.count } + analytics.updateContext(.workspacesOpen(count)) + } + + nonisolated static func agentHookPhase(_ name: WorkstreamEvent.HookEventName) -> AgentHookPhase { + switch name { + case .sessionStart: .sessionStart + case .sessionEnd: .sessionEnd + default: .activity + } + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a3b087ddd313..a71fcac0125f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1,5 +1,6 @@ import CmuxCloud import CmuxCloudBannerCore +import CmuxProductAnalytics import CmuxCloudTui import CmuxComputerUse import CmuxCloudMachines @@ -847,7 +848,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private(set) var cloudMachinePinStore: CloudMachinePinStore? /// "Introducing cmux cloud", shown once from the first main window. private(set) lazy var cloudWelcomeWindowController = CloudWelcomeWindowController() - private(set) lazy var cloudActivationCoordinator = CloudActivationCoordinator(prepare: { try await CmuxTuiSurfaceProviderRegistry.shared.prepareForActivation() }, cleanup: { await CmuxTuiSurfaceProviderRegistry.shared.cancelActivationPreparation() }) + private(set) lazy var cloudActivationCoordinator = CloudActivationCoordinator(prepare: { try await CmuxTuiSurfaceProviderRegistry.shared.prepareForActivation() }, cleanup: { await CmuxTuiSurfaceProviderRegistry.shared.cancelActivationPreparation() }, analytics: .app) var cloudWorkspaceCoordinator: CloudWorkspaceCoordinator? var cloudWorkspaceOperationController: CloudWorkspaceOperationController? var deviceWorkspaceCreationCoordinator: DeviceWorkspaceCreationCoordinator? @@ -1914,6 +1915,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent PostHogAnalytics.shared.startIfNeeded() StartupBreadcrumbLog.append("appDelegate.didFinish.posthog.complete") } + startProductAnalytics() // `DisableTelemetry` (MDM) also stops the remote feature-flag fetch: it // carries the same anonymous install id, and flags then use their // built-in defaults. @@ -2164,6 +2166,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "tabCount": tabManager?.tabs.count ?? 0 ]) if TelemetrySettings.enabledForCurrentLaunch && !isRunningUnderXCTestCached { + refreshProductAnalyticsActivityContext() PostHogAnalytics.shared.trackActive(reason: "didBecomeActive") } // A Mac that slept through the reply nudge picks parked replies up here. diff --git a/Sources/Auth/HostAccountFlow+TeamSelection.swift b/Sources/Auth/HostAccountFlow+TeamSelection.swift index a3ce786b6fa0..f93b66e79e12 100644 --- a/Sources/Auth/HostAccountFlow+TeamSelection.swift +++ b/Sources/Auth/HostAccountFlow+TeamSelection.swift @@ -22,11 +22,13 @@ extension HostAccountFlow { _ = coordinator.isAuthenticated _ = coordinator.isLoading _ = coordinator.isRestoringSession + _ = lastSignInFailure } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.teamObservationRevision &+= 1 self.syncReceivedInvitationsPolling() + self.syncAnalytics() self.observeCoordinator() } } diff --git a/Sources/Auth/HostAccountFlow.swift b/Sources/Auth/HostAccountFlow.swift index 15be22654e19..d41e91179256 100644 --- a/Sources/Auth/HostAccountFlow.swift +++ b/Sources/Auth/HostAccountFlow.swift @@ -1,4 +1,5 @@ import CmuxCloud +import CmuxProductAnalytics import AppKit import CMUXAuthCore import CmuxAuthRuntime @@ -50,11 +51,19 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { @ObservationIgnored var receivedInvitationsPoll: Task? @ObservationIgnored var receivedInvitationsLoaded = false var isCreatingTeam: Bool { coordinator.isCreatingTeam } - - init(coordinator: AuthCoordinator, browserSignIn: HostBrowserSignInFlow) { + @ObservationIgnored let analytics: ProductAnalytics + @ObservationIgnored var analyticsState = AccountAnalyticsState() + + init( + coordinator: AuthCoordinator, + browserSignIn: HostBrowserSignInFlow, + analytics: ProductAnalytics = .app + ) { self.coordinator = coordinator self.browserSignIn = browserSignIn + self.analytics = analytics startCoordinatorObservation() + syncAnalytics() } var currentIdentity: AccountIdentity? { @@ -136,10 +145,12 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { } func startSignIn() { + noteSignInStarted() browserSignIn.beginSignIn() } func startSignInForPane() -> URL? { + noteSignInStarted() browserSignIn.beginSignIn() return browserSignIn.activeAttemptSignInURL } @@ -151,7 +162,8 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { /// Runs the same hosted Stack sign-in used by every UI entrypoint, while /// allowing socket callers to await a bounded result. func signIn(timeout: TimeInterval) async -> Bool { - await browserSignIn.signIn(timeout: timeout) + noteSignInStarted() + return await browserSignIn.signIn(timeout: timeout) } /// Issues the manual hosted Stack sign-in URL through the same callback @@ -190,8 +202,11 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { } func signOut() async { + // Captured before the sign-out so it joins the signed-in person. + analytics.signedOut() await browserSignIn.signOut() billingPlanState = .unknown + restoreAnalyticsIfStillSignedIn() } /// Set for the whole switch so sign-in gates show its progress instead of @@ -220,6 +235,7 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { switchAttempt = nil } await signOut() + noteSignInStarted() var attempt = browserSignIn.beginSignIn(selectAccount: true) switchAttempt = attempt // Stay switching until the newest attempt settles: a replaced one @@ -234,8 +250,10 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { /// Socket variant of sign-out. The underlying sign-out continues if the /// caller's deadline expires, matching the browser flow contract. func signOut(timeout: TimeInterval) async { + analytics.signedOut() await browserSignIn.signOut(timeout: timeout) billingPlanState = .unknown + restoreAnalyticsIfStillSignedIn() } func refreshCurrentUser() async { @@ -274,6 +292,7 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { isPro: details.isPro, canManageBilling: details.canManageBilling ) + analytics.notePlan(accountID: identityID, isPaid: details.isPro) return true } catch { // A cancelled request (the panel went away) says nothing about the plan. diff --git a/Sources/Cloud/CloudActivationCoordinator.swift b/Sources/Cloud/CloudActivationCoordinator.swift index cdec5243d08c..14b76462e729 100644 --- a/Sources/Cloud/CloudActivationCoordinator.swift +++ b/Sources/Cloud/CloudActivationCoordinator.swift @@ -1,4 +1,5 @@ import CmuxCloud +import CmuxProductAnalytics import CmuxSettings import Foundation import Observation @@ -43,6 +44,7 @@ final class CloudActivationCoordinator { private let isAvailable: @MainActor () -> Bool private let prepare: @MainActor () async throws -> Void private let cleanup: @MainActor () async -> Void + private let analytics: ProductAnalytics @ObservationIgnored var activationTask: Task? private var activationID: UUID? @ObservationIgnored var cleanupTask: Task? @@ -56,8 +58,10 @@ final class CloudActivationCoordinator { isAvailable: @escaping @MainActor () -> Bool = { CloudMachinesFeature.isAvailable }, prepare: @escaping @MainActor () async throws -> Void, cleanup: @escaping @MainActor () async -> Void = {}, - observeChanges: Bool = true + observeChanges: Bool = true, + analytics: ProductAnalytics = .disabled ) { + self.analytics = analytics self.defaults = defaults self.notificationCenter = notificationCenter self.isAvailable = isAvailable @@ -167,6 +171,8 @@ final class CloudActivationCoordinator { await self.settle(id: id, state: .unavailable) return } + self.analytics.track(.cloudEnableCompleted(result: .success)) + self.analytics.updateContext(.cloudEnabled(true)) } catch is CancellationError { // User cancellation clears activationID first, so settle() // ignores it. A cancellation from preparation is a transient @@ -215,6 +221,8 @@ final class CloudActivationCoordinator { notificationCenter.post(name: RightSidebarBetaFeatureSettings.didChangeNotification, object: nil) state = isAvailable() ? .disabled : .unavailable scheduleCleanup(after: nil) + analytics.track(.cloudDisabled) + analytics.updateContext(.cloudEnabled(false)) } /// Replays the current state and then emits every transition. @@ -243,6 +251,7 @@ final class CloudActivationCoordinator { rollbackOptimisticActivation() scheduleCleanup(after: task) state = .cancelled + analytics.track(.cloudEnableCompleted(result: .cancelled)) } /// Retries a failed or cancelled activation through the same setup path. @@ -258,6 +267,21 @@ final class CloudActivationCoordinator { await cleanup() guard activationID == id else { return } self.state = state + if let result = Self.enableResult(for: state) { + analytics.track(.cloudEnableCompleted(result: result)) + } + } + + /// The analytics result of a failed enable; nil for states that are not one. + static func enableResult(for state: State) -> CloudEnableResult? { + switch state { + case .failed(.requiresPro): .requiresPaidPlan + case .failed(.signInRequired): .signInRequired + case .failed(.serviceUnavailable): .serviceUnavailable + case .unavailable: .unavailable + case .cancelled: .cancelled + case .disabled, .enabling, .enabled: nil + } } private func scheduleCleanup(after activationTask: Task?) { diff --git a/Sources/Cloud/CloudWelcomeAnalyticsSession.swift b/Sources/Cloud/CloudWelcomeAnalyticsSession.swift new file mode 100644 index 000000000000..12848ec98258 --- /dev/null +++ b/Sources/Cloud/CloudWelcomeAnalyticsSession.swift @@ -0,0 +1,77 @@ +import CmuxCloud +import CmuxProductAnalytics +import Foundation + +/// The analytics for one shown welcome card: shown, each button, and how it +/// closed. Exactly one `cmux_cloud_welcome_closed` per shown card. +@MainActor +final class CloudWelcomeAnalyticsSession { + let trigger: CloudWelcomeTrigger + private let analytics: ProductAnalytics + private let now: @MainActor () -> Date + /// The controller's step resolver, which the card also renders from. + private let currentStep: @MainActor () -> CloudWelcomeNextStep + private let shownAt: Date + private var recordedUpgradeImpression = false + private(set) var isClosed = false + + init( + trigger: CloudWelcomeTrigger, + currentStep: @escaping @MainActor () -> CloudWelcomeNextStep, + analytics: ProductAnalytics, + now: @escaping @MainActor () -> Date + ) { + self.trigger = trigger + self.analytics = analytics + self.now = now + self.currentStep = currentStep + self.shownAt = now() + let step = currentStep() + analytics.track(.cloudWelcomeShown(trigger: trigger, step: Self.step(step))) + stepDisplayed(step) + } + + /// The card now offers `step`. A plan that loads after the card opened can + /// turn Open Cloud into Upgrade; the Upgrade impression is recorded the + /// first time it is on screen. + func stepDisplayed(_ step: CloudWelcomeNextStep) { + guard step == .upgrade, !recordedUpgradeImpression, !isClosed else { return } + recordedUpgradeImpression = true + analytics.track(.upgradeShown(.cloudWelcome)) + } + + /// A button closed the card. `step` is the step its primary button + /// offered; Not Now passes nil and records the step on screen now. + func clicked(_ button: CloudWelcomeButton, step: CloudWelcomeNextStep?) { + guard !isClosed else { return } + let offered = Self.step(step ?? currentStep()) + analytics.track(.cloudWelcomeButtonClicked(trigger: trigger, step: offered, button: button)) + close(.init(button: button), step: offered) + } + + /// The window closed with no button. + func windowClosed() { + guard !isClosed else { return } + close(.windowClosed, step: Self.step(currentStep())) + } + + private func close(_ outcome: CloudWelcomeOutcome, step: CloudWelcomeStep) { + guard !isClosed else { return } + isClosed = true + analytics.track(.cloudWelcomeClosed( + trigger: trigger, + step: step, + outcome: outcome, + visibleFor: DurationBucket(seconds: now().timeIntervalSince(shownAt)) + )) + } + + static func step(_ step: CloudWelcomeNextStep) -> CloudWelcomeStep { + switch step { + case .upgrade: .upgrade + case .enable: .enable + case .signIn: .signIn + case .openCloud: .openCloud + } + } +} diff --git a/Sources/Cloud/CloudWelcomeWindowController.swift b/Sources/Cloud/CloudWelcomeWindowController.swift index 634f6b436b20..10b9fea458b2 100644 --- a/Sources/Cloud/CloudWelcomeWindowController.swift +++ b/Sources/Cloud/CloudWelcomeWindowController.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxProductAnalytics import CmuxSettings import SwiftUI @@ -46,6 +47,10 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { private let defaults: UserDefaults private let nextStep: @MainActor () -> CloudWelcomeNextStep private let performNextStep: @MainActor (CloudWelcomeNextStep, (@MainActor () -> Void)?) -> Void + private let analytics: ProductAnalytics + private let now: @MainActor () -> Date + /// Analytics for the card on screen, nil when none is. + private(set) var analyticsSession: CloudWelcomeAnalyticsSession? init( defaults: UserDefaults = .standard, @@ -60,11 +65,15 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { ) ) }, - performNextStep: @escaping @MainActor (CloudWelcomeNextStep, (@MainActor () -> Void)?) -> Void = CloudWelcomeWindowController.perform + performNextStep: @escaping @MainActor (CloudWelcomeNextStep, (@MainActor () -> Void)?) -> Void = CloudWelcomeWindowController.perform, + analytics: ProductAnalytics = .app, + now: @escaping @MainActor () -> Date = { Date() } ) { self.defaults = defaults self.nextStep = nextStep self.performNextStep = performNextStep + self.analytics = analytics + self.now = now super.init() } @@ -122,7 +131,7 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { isRunningUnderXCTest: MacSentryStartupPolicy.isRunningUnderXCTest(environment: environment), isUITestMode: environment["CMUX_UI_TEST_MODE"] == "1" ) else { return } - show(over: parent) + show(over: parent, trigger: .launchAnnouncement) } /// Claims the release announcement synchronously before creating its window. @@ -150,12 +159,18 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { /// Every normal presentation claims the same persisted marker. A direct /// Cloud action from a returning user continues at the card's next step. - func present(over parent: NSWindow?, onEnabled: (@MainActor () -> Void)? = nil) { + /// + /// - Parameter trigger: The Cloud action that asked, for analytics. + func present( + over parent: NSWindow?, + trigger: CloudWelcomeTrigger, + onEnabled: (@MainActor () -> Void)? = nil + ) { guard claimPresentation() else { continueToNextStep(nextStep(), onEnabled: onEnabled) return } - show(over: parent, onEnabled: onEnabled) + show(over: parent, trigger: trigger, onEnabled: onEnabled) } private func claimPresentation() -> Bool { @@ -169,7 +184,7 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { /// recorded it as seen, and still records it so the launch never repeats it. func presentIntroduction(over parent: NSWindow?) { Self.markCampaignSeen(in: defaults) - show(over: parent) + show(over: parent, trigger: .sidebarCloudButton) } #if DEBUG @@ -184,6 +199,7 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { Self.markCampaignSeen(in: defaults) show( over: parent, + trigger: .debugMenu, posterShowsTitle: posterShowsTitle, posterShowsIcon: posterShowsIcon, showsNewBadge: showsNewBadge, @@ -200,6 +216,7 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { private func show( over parent: NSWindow?, + trigger: CloudWelcomeTrigger, sliderShowsFeatureList: Bool = true, sliderListUsesDots: Bool = false, posterHeadline: String? = CloudWelcomeView.headline, @@ -211,6 +228,12 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { ) { window?.close() self.onEnabled = onEnabled + analyticsSession = CloudWelcomeAnalyticsSession( + trigger: trigger, + currentStep: nextStep, + analytics: analytics, + now: now + ) let window = makeWindow( sliderShowsFeatureList: sliderShowsFeatureList, sliderListUsesDots: sliderListUsesDots, @@ -238,6 +261,8 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { let shortcut = KeyboardShortcutSettings.shortcut(for: .newCloudMachine) let rootView = CloudWelcomeAccountView( accountFlow: AppDelegate.shared?.auth?.accountFlow, + resolveStep: nextStep, + onStepDisplayed: { [weak self] step in self?.analyticsSession?.stepDisplayed(step) }, sliderShowsFeatureList: sliderShowsFeatureList, sliderListUsesDots: sliderListUsesDots, posterHeadline: posterHeadline, @@ -246,10 +271,8 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { posterShowsIcon: posterShowsIcon, showsNewBadge: showsNewBadge, footerHasNotNow: footerHasNotNow, - onNotNow: { [weak self] in self?.dismiss() }, - onNext: { [weak self] step in - self?.continueToNextStep(step, onEnabled: self?.onEnabled) - } + onNotNow: { [weak self] in self?.handleNotNow() }, + onNext: { [weak self] step in self?.handleNext(step) } ) let hosting = NSHostingView(rootView: rootView) // The content clears the traffic lights with its own top padding, so it @@ -324,6 +347,18 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { window.setFrameOrigin(origin) } + /// Not Now, the card's ×, or Escape. + func handleNotNow() { + analyticsSession?.clicked(.notNow, step: nil) + dismiss() + } + + /// The card's primary button, which offered `step`. + func handleNext(_ step: CloudWelcomeNextStep) { + analyticsSession?.clicked(CloudWelcomeButton(step: CloudWelcomeAnalyticsSession.step(step)), step: step) + continueToNextStep(step, onEnabled: onEnabled) + } + private func continueToNextStep(_ step: CloudWelcomeNextStep, onEnabled: (@MainActor () -> Void)?) { dismiss() performNextStep(step, onEnabled) @@ -367,11 +402,19 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { window?.close() window = nil onEnabled = nil + endAnalyticsSession() } func windowWillClose(_ notification: Notification) { window = nil onEnabled = nil + endAnalyticsSession() + } + + /// A card that closes without a button records `window_closed`. + private func endAnalyticsSession() { + analyticsSession?.windowClosed() + analyticsSession = nil } } @@ -379,6 +422,10 @@ final class CloudWelcomeWindowController: NSObject, NSWindowDelegate { /// the plan once on show, so the view itself stays free of app objects. private struct CloudWelcomeAccountView: View { let accountFlow: HostAccountFlow? + /// The controller's resolver, the one owner of the offered step; reading + /// the account inside it during `body` keeps the view observing it. + let resolveStep: @MainActor () -> CloudWelcomeNextStep + let onStepDisplayed: @MainActor (CloudWelcomeNextStep) -> Void let sliderShowsFeatureList: Bool let sliderListUsesDots: Bool let posterHeadline: String? @@ -391,15 +438,9 @@ private struct CloudWelcomeAccountView: View { let onNext: (CloudWelcomeNextStep) -> Void var body: some View { + let step = resolveStep() CloudWelcomeView( - nextStep: CloudWelcomeNextStep.resolve( - isAuthenticated: accountFlow?.isAuthenticated == true, - isPlanKnown: accountFlow?.hasLoadedBillingPlan == true, - isPro: accountFlow?.isProActive == true, - hasCloudTeamAccess: CloudWelcomeNextStep.hasSharedTeamAccess( - availableTeamCount: accountFlow?.availableTeams.count ?? 0 - ) - ), + nextStep: step, onNotNow: onNotNow, onNext: onNext, hasCloudTeamAccess: CloudWelcomeNextStep.hasSharedTeamAccess( @@ -414,6 +455,7 @@ private struct CloudWelcomeAccountView: View { showsNewBadge: showsNewBadge, footerHasNotNow: footerHasNotNow ) + .onChange(of: step) { _, displayed in onStepDisplayed(displayed) } .task { // The plan decides between Upgrade and Enable; ask once on show. if accountFlow?.isAuthenticated == true { diff --git a/Sources/Cloud/MachineCreateCoordinator.swift b/Sources/Cloud/MachineCreateCoordinator.swift index 3b8ddabf2c36..9de35769221a 100644 --- a/Sources/Cloud/MachineCreateCoordinator.swift +++ b/Sources/Cloud/MachineCreateCoordinator.swift @@ -2,6 +2,7 @@ import CmuxCloud import Foundation import Observation import CmuxCloudMachines +import CmuxProductAnalytics /// Adapts the shared package lifecycle to CLI processes, notifications, and local workspaces. /// Every New Machine entrypoint uses this owner; views consume immutable projections. @@ -34,6 +35,7 @@ final class MachineCreateCoordinator { MachineCreateCoordinator.selectCreatedWorkspace(workspaceID, for: request) }, cancelCreatedMachine: { CloudVMActionLauncher.shared.destroyMachineBestEffort($0) }, + analytics: .app, cancelOperation: { operation in guard let workspaceID = operation.request.presentationWorkspaceID else { return } NewMachineSheetPresenter.closeReservedWorkspace( @@ -57,6 +59,11 @@ final class MachineCreateCoordinator { @ObservationIgnored private let cancelOperation: @MainActor (MachineCreateOperation) -> Void @ObservationIgnored private let notificationCenter: NotificationCenter @ObservationIgnored private var accessDidEndObserver: NSObjectProtocol? + @ObservationIgnored private let analytics: ProductAnalytics + @ObservationIgnored private let now: () -> Date + /// Operations whose machine creation was already reported; a retry of + /// one only reopens the machine and is not another create. + @ObservationIgnored private var reportedCreatedOperationIDs: Set = [] init( notifier: @escaping @MainActor (MachineCreateNotice) -> Void, @@ -64,9 +71,12 @@ final class MachineCreateCoordinator { now: @escaping () -> Date = Date.init, notificationCenter: NotificationCenter = .default, cancelCreatedMachine: @escaping @MainActor (String) -> Void = { _ in }, + analytics: ProductAnalytics = .disabled, cancelOperation: @escaping @MainActor (MachineCreateOperation) -> Void = { _ in } ) { self.lifecycle = CloudMachineCreateCoordinator(output: Self.outputParser, now: now) + self.analytics = analytics + self.now = now self.notifier = notifier self.selectWorkspace = selectWorkspace self.cancelCreatedMachine = cancelCreatedMachine @@ -248,6 +258,7 @@ final class MachineCreateCoordinator { lastFinished = finished let id = finished.operation.id handles[id] = nil + reportCreateFinished(finished) #if DEBUG let presentationWorkspace = finished.operation.request.presentationWorkspaceID?.uuidString ?? "none" cmuxDebugLog( @@ -288,6 +299,22 @@ final class MachineCreateCoordinator { if transition.changed { postDidChange(finished: finished) } } + /// One product analytics outcome per finished create, from any launcher. + private func reportCreateFinished(_ finished: Finished) { + let seconds = now().timeIntervalSince(finished.operation.startedAt) + switch finished.outcome { + case .created, .createdButOpenFailed: + guard reportedCreatedOperationIDs.insert(finished.operation.id).inserted else { return } + analytics.noteMachineCreateFinished(succeeded: true, errorCode: nil, seconds: seconds) + case .failed: + analytics.noteMachineCreateFinished( + succeeded: false, + errorCode: finished.operation.refusalCode, + seconds: seconds + ) + } + } + /// Releases only app resources; lifecycle state and callback fences live in the package. private func discardRetiredAdapters() { let liveIDs = Set(lifecycle.projection.operations.map(\.id)) diff --git a/Sources/Cloud/MachinesControlBarComponents.swift b/Sources/Cloud/MachinesControlBarComponents.swift index 85f5fe276c68..a27f36cf8afa 100644 --- a/Sources/Cloud/MachinesControlBarComponents.swift +++ b/Sources/Cloud/MachinesControlBarComponents.swift @@ -48,6 +48,7 @@ struct MachinesFreeAccessBanner: View { .background(Color(nsColor: backgroundColor)) .help(helpText) .accessibilityIdentifier("CloudMachinesFreeAccessBanner") + .upgradePromptImpression(.machinesPanelTrialBanner) } private var helpText: String { diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index e88eb38bd31f..d654cae2ad14 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -742,6 +742,7 @@ struct MachinesPanelView: View { .underline() } .buttonStyle(.plain) + .upgradePromptImpression(.machinesPanelUpgradeNudge) } else if let plan = viewModel.plan { Text(planIncludesLabel(plan)) .cmuxFont(size: 11) diff --git a/Sources/Cloud/NewMachineModel.swift b/Sources/Cloud/NewMachineModel.swift index a87ffc00b832..bba9f814c42d 100644 --- a/Sources/Cloud/NewMachineModel.swift +++ b/Sources/Cloud/NewMachineModel.swift @@ -1,5 +1,6 @@ import CmuxCloud import CmuxCloudResizeCore +import CmuxProductAnalytics import CmuxSurfaceCatalogModel import Foundation import Observation @@ -673,6 +674,9 @@ final class NewMachineModel { return } if supportsAgentUpdates { defaults.set(keepsAgentsUpdated, forKey: Self.keepsAgentsUpdatedDefaultsKey) } + // Base and forks send no size (the backend or the source decides it). + let sentSize = mode == .newMachine && !isFork && supportsSize + ProductAnalytics.app.track(.cloudMachineCreateStarted(memoryGB: sentSize ? memoryMb / 1_024 : nil)) finish(.submitted) } diff --git a/Sources/Cloud/SidebarCloudButtonAction.swift b/Sources/Cloud/SidebarCloudButtonAction.swift index d692bae66358..96908d97298a 100644 --- a/Sources/Cloud/SidebarCloudButtonAction.swift +++ b/Sources/Cloud/SidebarCloudButtonAction.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxProductAnalytics /// Reveals Cloud on every press, introducing it explicitly while activation is off. @MainActor @@ -7,12 +8,17 @@ struct SidebarCloudButtonAction { let isEnabled: () -> Bool let revealMachines: () -> Void let presentIntroduction: () -> Void + var analytics: ProductAnalytics = .disabled func perform() { guard isAvailable() else { return } revealMachines() if !isEnabled() { + // Before the card opens, so the click precedes its welcome events. + analytics.track(.cloudButtonClicked(action: .introduceCloud)) presentIntroduction() + } else { + analytics.track(.cloudButtonClicked(action: .openCloud)) } } } diff --git a/Sources/Cloud/VMClientTelemetry+Live.swift b/Sources/Cloud/VMClientTelemetry+Live.swift index db70a749fc8a..33fe27f415ee 100644 --- a/Sources/Cloud/VMClientTelemetry+Live.swift +++ b/Sources/Cloud/VMClientTelemetry+Live.swift @@ -1,9 +1,11 @@ import CmuxCloud +import CmuxProductAnalytics import Foundation extension VMClientTelemetry { /// The production telemetry: PostHog for captured requests, Sentry for - /// failures and breadcrumbs. Built once at the composition root. + /// failures and breadcrumbs, and product analytics for the Cloud funnel. + /// Built once at the composition root. static func live() -> VMClientTelemetry { VMClientTelemetry( capturePostHog: { event, properties in @@ -19,6 +21,15 @@ extension VMClientTelemetry { }, addBreadcrumb: { message, data in sentryBreadcrumb(message, category: VMClientTelemetry.sentryCategory, data: data) + }, + observeRecord: { record in + ProductAnalytics.app.noteCloudRequest(CloudRequestObservation( + method: record.method, + route: record.route, + success: record.outcome.isSuccess, + errorCode: record.outcome.errorCode, + durationMs: record.durationMs + )) } ) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0d6028bc6cd6..88152c08c3d1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -27,6 +27,7 @@ import CmuxUpdater import CmuxUpdaterUI import ImageIO import Observation +import CmuxProductAnalytics import SwiftUI import ObjectiveC import UniformTypeIdentifiers @@ -10359,6 +10360,7 @@ struct ContentView: View { /// Presents the palette and resets per-presentation launcher availability before rebuilding results. private func presentCommandPalette(initialQuery: String) { + ProductAnalytics.app.featureUsed(.commandPalette) refreshCachedDefaultTerminalStatus(refreshSearchCorpusIfPresented: false) commandPaletteFocusRestoreCoordinator.clear() let browserTarget = AppDelegate.shared?.focusedBrowserActionTarget( diff --git a/Sources/Devices/DeviceWorkspaceCreationCoordinator.swift b/Sources/Devices/DeviceWorkspaceCreationCoordinator.swift index ca23995b0741..a197f13cada6 100644 --- a/Sources/Devices/DeviceWorkspaceCreationCoordinator.swift +++ b/Sources/Devices/DeviceWorkspaceCreationCoordinator.swift @@ -1,3 +1,4 @@ +import CmuxProductAnalytics import CmuxSurfaceCatalogModel import Foundation @@ -22,6 +23,7 @@ final class DeviceWorkspaceCreationCoordinator { guard let manager else { return } do { try await create(machine, manager) + ProductAnalytics.app.featureUsed(.devices) } catch is CancellationError { throw CancellationError() } catch { diff --git a/Sources/FileExplorerState.swift b/Sources/FileExplorerState.swift index aaf0ebd34ac4..c9197217a422 100644 --- a/Sources/FileExplorerState.swift +++ b/Sources/FileExplorerState.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxProductAnalytics import SwiftUI // MARK: - State (visibility toggle) @@ -55,9 +56,15 @@ final class FileExplorerState: ObservableObject { @MainActor lazy var cloudTeamPickerPresentation = CloudTeamPickerPresentation() /// Active mode for the right sidebar (file tree, search, sessions, or enabled beta modes). + /// Explicit sets come from people and their tools (tab bar, palette, CLI); + /// restore and preference changes go through `refreshModeAvailability`. var mode: RightSidebarMode { get { storedMode } - set { setMode(newValue) } + set { + let previous = storedMode + setMode(newValue) + if isVisible, storedMode != previous { ProductAnalytics.app.featureUsed(storedMode.productFeature) } + } } var customSidebarName: String? { @@ -107,8 +114,11 @@ final class FileExplorerState: ObservableObject { setVisible(!isVisible) } + /// The person's show/hide (toggle, shortcut, menu). Narrow-window + /// auto-collapse sets `isVisible` directly and is not counted as use. func setVisible(_ nextValue: Bool) { guard isVisible != nextValue else { return } + if nextValue { ProductAnalytics.app.featureUsed(storedMode.productFeature) } // Suppress both SwiftUI transactions and AppKit/Core Animation implicit layout changes. NSAnimationContext.beginGrouping() diff --git a/Sources/HostAccountFlow+Analytics.swift b/Sources/HostAccountFlow+Analytics.swift new file mode 100644 index 000000000000..61fa96eaa6c7 --- /dev/null +++ b/Sources/HostAccountFlow+Analytics.swift @@ -0,0 +1,38 @@ +import CmuxProductAnalytics + +extension HostAccountFlow { + /// Reports sign-in, identity, team and sign-out changes. Called on every + /// coordinator change and once at init; cheap when nothing changed. + func syncAnalytics() { + let actions = analyticsState.observe( + userID: coordinator.currentUser?.id, + isSettling: isWorkingOnAuth, + failure: AccountAnalyticsState.reason(lastSignInFailure) + ) + for action in actions { + switch action { + case let .identify(userID): + analytics.identify(userID: userID) + case .signInSucceeded: + analytics.track(.signInSucceeded) + case let .signInFailed(reason): + analytics.track(.signInFailed(reason: reason)) + case .signedOut: + analytics.signedOut() + } + } + } + + /// `cmux_signed_out` is captured before a sign-out runs so it joins the + /// account. If the account is still signed in afterwards (a failed or + /// no-op sign-out), later events get the signed-in context back. + func restoreAnalyticsIfStillSignedIn() { + guard let userID = coordinator.currentUser?.id else { return } + analytics.identify(userID: userID) + } + + func noteSignInStarted() { + analyticsState.signInStarted() + analytics.track(.signInStarted) + } +} diff --git a/Sources/Panels/MachineCreateLoadingContent.swift b/Sources/Panels/MachineCreateLoadingContent.swift index 8946bccb3521..da902e62ea2a 100644 --- a/Sources/Panels/MachineCreateLoadingContent.swift +++ b/Sources/Panels/MachineCreateLoadingContent.swift @@ -67,6 +67,7 @@ struct MachineCreateLoadingContent: View { ProUpgradePresenter.present(source: .createFailedAtLimit) } .accessibilityIdentifier("MachineCreateUpgradeButton") + .upgradePromptImpression(.createFailedAtLimit) } /// The create hit the plan's machine limit or its shared vCPU and memory diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 2b23ef40b063..a93129702b7d 100644 --- a/Sources/PostHogAnalytics.swift +++ b/Sources/PostHogAnalytics.swift @@ -34,12 +34,16 @@ final class PostHogAnalytics: @unchecked Sendable { private let now: @Sendable () -> Date private let capturePostHog: @Sendable (String, [String: Any]) -> Void private let flushPostHog: @Sendable () -> Void + private let contextCalls: PostHogContextCalls private let environment: [String: String] private let telemetryEnabled: @Sendable () -> Bool private let previousLaunchIdentity: [String: Any] private let launchStartedAt: Date private var didStart: Bool + /// The product context last registered as super properties. `reset()` + /// clears registered properties, so an identity reset restores these. + private var registeredContext: [String: Any] = [:] private var activeCheckTimer: Timer? init( @@ -51,6 +55,7 @@ final class PostHogAnalytics: @unchecked Sendable { PostHogSDK.shared.capture(event, properties: properties) }, flushPostHog: @escaping @Sendable () -> Void = { PostHogSDK.shared.flush() }, + contextCalls: PostHogContextCalls = .live, environment: [String: String] = ProcessInfo.processInfo.environment, telemetryEnabled: @escaping @Sendable () -> Bool = { TelemetrySettings.enabledForCurrentLaunch } ) { @@ -60,6 +65,7 @@ final class PostHogAnalytics: @unchecked Sendable { self.now = now self.capturePostHog = capturePostHog self.flushPostHog = flushPostHog + self.contextCalls = contextCalls self.environment = environment self.telemetryEnabled = telemetryEnabled self.previousLaunchIdentity = userDefaults.dictionary(forKey: "posthog.previousLaunchIdentity") ?? [:] @@ -122,6 +128,22 @@ final class PostHogAnalytics: @unchecked Sendable { } } + /// Whether this launch sends analytics at all: telemetry on, not under + /// XCTest, and (in debug builds) explicitly enabled. Product analytics + /// uses the same answer so it never queues work that would be dropped. + var isCaptureEnabled: Bool { isEnabled } + + /// Registers super properties sent with every later event. + func register(_ properties: [String: Any]) { + dispatchAsyncOnWorkQueue { [weak self] in + guard let self else { return } + self.startIfNeededOnWorkQueue() + guard self.didStart else { return } + self.registeredContext.merge(properties) { _, new in new } + self.contextCalls.register(properties) + } + } + /// Capture one product event with the app version properties attached. /// No-op when telemetry is disabled or the SDK never started. Used by the /// Cloud VM request telemetry (`VMClientTelemetry`). @@ -183,8 +205,12 @@ final class PostHogAnalytics: @unchecked Sendable { private func resetPostHogIdentityOnWorkQueue() { PostHogSDK.shared.reset() - // `reset()` also clears registered super properties. + // `reset()` also clears registered super properties: restore the + // desktop tags and the product context (plan, sign-in, Cloud state). PostHogSDK.shared.register(Self.superProperties(infoDictionary: Bundle.main.infoDictionary ?? [:])) + if !registeredContext.isEmpty { + contextCalls.register(registeredContext) + } } /// Mirror a previous-run crash into PostHog Error Tracking as one diff --git a/Sources/PostHogContextCalls.swift b/Sources/PostHogContextCalls.swift new file mode 100644 index 000000000000..fd7f871db593 --- /dev/null +++ b/Sources/PostHogContextCalls.swift @@ -0,0 +1,13 @@ +import Foundation +import PostHog + +/// The PostHog SDK call that sets context on later events, injectable so +/// tests observe it without the SDK. Person identity and groups (`identify`, +/// `group`, `reset`) are owned by the sign-in identity observer. +struct PostHogContextCalls: Sendable { + var register: @Sendable ([String: Any]) -> Void + + static let live = PostHogContextCalls( + register: { PostHogSDK.shared.register($0) } + ) +} diff --git a/Sources/PostHogProductAnalyticsSink.swift b/Sources/PostHogProductAnalyticsSink.swift new file mode 100644 index 000000000000..639317d99cc8 --- /dev/null +++ b/Sources/PostHogProductAnalyticsSink.swift @@ -0,0 +1,29 @@ +import CMUXMobileCore +import CmuxProductAnalytics +import Foundation + +/// Sends ``ProductAnalytics`` output through the app's PostHog SDK, which +/// owns batching, retries and the on-disk queue for offline launches. +struct PostHogProductAnalyticsSink: AnalyticsEmitting { + let analytics: PostHogAnalytics + + func capture(_ event: String, _ properties: [String: AnalyticsValue]) { + analytics.capture(event, properties: Self.plain(properties)) + } + + /// The PostHog person and its team group (identify, group, reset) have + /// one owner, the sign-in identity observer, which uses the same Stack + /// user id: a group lasts until `reset()`, so only that owner can clear it + /// on sign-out. Product analytics only adds events and context. + func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) {} + + func setSuperProperties(_ properties: [String: AnalyticsValue]) { + analytics.register(Self.plain(properties)) + } + + func flush() async {} + + static func plain(_ properties: [String: AnalyticsValue]) -> [String: Any] { + properties.mapValues { $0.jsonObject } + } +} diff --git a/Sources/PricingPlansScreen.swift b/Sources/PricingPlansScreen.swift index 1bac1a458c27..bd35c3e6b1ce 100644 --- a/Sources/PricingPlansScreen.swift +++ b/Sources/PricingPlansScreen.swift @@ -1,4 +1,5 @@ import CmuxCloud +import CmuxProductAnalytics import AppKit import Bonsplit import Foundation @@ -11,15 +12,24 @@ import SwiftUI /// workspace creation is unavailable. Every caller names its /// ``ProUpgradeSource`` so the resulting checkout is attributable. enum ProUpgradePresenter { - static let intentEvent = "cmux_upgrade_entrypoint_opened" - @MainActor private static var workspaceReuseState = ProUpgradeWorkspaceReuseState() + /// Records the click as `cmux_upgrade_entrypoint_opened` (the funnel's + /// first client step) and opens pricing. + /// + /// - Parameters: + /// - source: The surface; it also travels to checkout as `cmux_source`. + /// - analytics: The emitter. Tests pass their own. + /// - open: Opens pricing. Tests pass a no-op. @MainActor - static func present(source: ProUpgradeSource) { - PostHogAnalytics.shared.capture(intentEvent, properties: CheckoutAttribution.intentProperties(source: source)) - presentAppPricingWeb(source: source) + static func present( + source: ProUpgradeSource, + analytics: ProductAnalytics = .app, + open: @MainActor (ProUpgradeSource) -> Void = { presentAppPricingWeb(source: $0) } + ) { + analytics.track(.upgradeOpened(source, destination: .pricingPage)) + open(source) } /// Hover hook for upgrade entrypoints: loads the pricing page into a @@ -65,8 +75,12 @@ enum ProUpgradePresenter { } @MainActor - static func presentCheckout(source: ProUpgradeSource, plan: CheckoutPlan = .pro) { - PostHogAnalytics.shared.capture(intentEvent, properties: CheckoutAttribution.intentProperties(source: source, plan: plan)) + static func presentCheckout( + source: ProUpgradeSource, + plan: CheckoutPlan = .pro, + analytics: ProductAnalytics = .app + ) { + analytics.track(.upgradeOpened(source, plan: plan, destination: .checkout)) NSWorkspace.shared.open(checkoutURL(source: source, plan: plan)) } diff --git a/Sources/ProBadgeActions.swift b/Sources/ProBadgeActions.swift new file mode 100644 index 000000000000..482c71dd37db --- /dev/null +++ b/Sources/ProBadgeActions.swift @@ -0,0 +1,27 @@ +import CmuxCloud +import CmuxProductAnalytics + +/// What the sidebar footer's Upgrade badge does, with its analytics. The view +/// calls these; tests call them with a recording emitter and a no-op opener. +@MainActor +struct ProBadgeActions { + static let source = ProUpgradeSource.sidebarBadge + + var analytics: ProductAnalytics = .app + var openPricing: @MainActor (ProUpgradeSource) -> Void = { ProUpgradePresenter.presentAppPricingWeb(source: $0) } + + /// The badge is on screen. + func appeared() { + analytics.track(.upgradeShown(Self.source)) + } + + /// The badge was clicked: records the click with its source and opens pricing. + func upgrade() { + ProUpgradePresenter.present(source: Self.source, analytics: analytics, open: openPricing) + } + + /// The badge's X hid it. + func dismissed() { + analytics.track(.upgradeBadgeDismissed(source: Self.source.rawValue)) + } +} diff --git a/Sources/ProBadgeStyle.swift b/Sources/ProBadgeStyle.swift index 1c20a90de1ac..14f1ee659ccb 100644 --- a/Sources/ProBadgeStyle.swift +++ b/Sources/ProBadgeStyle.swift @@ -258,6 +258,7 @@ struct ProBadgeView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.sidebarReadabilityBackdrop) private var readabilityBackdrop @State private var isHovered = false + var actions = ProBadgeActions() private var helpTitle: String { String(localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…") @@ -276,7 +277,7 @@ struct ProBadgeView: View { ) HStack(spacing: 0) { Button { - ProUpgradePresenter.present(source: .sidebarBadge) + actions.upgrade() } label: { ProBadgeContent(style: style) .contentShape(Rectangle()) @@ -290,6 +291,7 @@ struct ProBadgeView: View { // so it's revealed by the widening capsule (clipped) rather // than fading or sliding on its own. Button { + actions.dismissed() withAnimation(.easeOut(duration: 0.15)) { ProBadgeStyleStore.shared.isDismissed = true } @@ -311,6 +313,7 @@ struct ProBadgeView: View { } .modifier(ProBadgeCapsule(style: style, isHovered: isHovered)) .frame(height: 22, alignment: .center) + .onAppear { actions.appeared() } .onHover { hovering in withAnimation(.easeOut(duration: 0.15)) { isHovered = hovering diff --git a/Sources/ProductAnalytics+App.swift b/Sources/ProductAnalytics+App.swift new file mode 100644 index 000000000000..5a11ccf39a6a --- /dev/null +++ b/Sources/ProductAnalytics+App.swift @@ -0,0 +1,37 @@ +import CmuxCloud +import CmuxProductAnalytics +import CmuxSettings +import Foundation + +extension ProductAnalytics { + /// The app's product analytics, sending through ``PostHogAnalytics``. + /// Disabled whenever PostHog capture is (telemetry opt-out in Settings, + /// managed `DisableTelemetry`, XCTest, debug builds without + /// `CMUX_POSTHOG_ENABLE=1`), so it enqueues nothing in those launches. + static let app: ProductAnalytics = { + let posthog = PostHogAnalytics.shared + let defaults = UserDefaults.standard + let info = Bundle.main.infoDictionary ?? [:] + let os = ProcessInfo.processInfo.operatingSystemVersion + return ProductAnalytics( + isEnabled: posthog.isCaptureEnabled, + sink: PostHogProductAnalyticsSink(analytics: posthog), + ledger: ProductAnalyticsLedger(defaults: defaults), + context: ProductAnalyticsContext( + appVersion: info["CFBundleShortVersionString"] as? String ?? "unknown", + appBuild: info["CFBundleVersion"] as? String ?? "unknown", + channel: BuildFlavor.current.rawValue, + osVersion: "\(os.majorVersion).\(os.minorVersion)", + cloudEnabled: CloudMachinesFeature.offMainIsEnabled() + ), + isPreexistingInstall: isPreexistingInstall(defaults: defaults) + ) + }() + + /// An install that ran before the event catalog shipped leaves earlier + /// analytics or campaign markers; its first launch is not reported now. + static func isPreexistingInstall(defaults: UserDefaults) -> Bool { + defaults.string(forKey: "posthog.lastActiveDayUTC") != nil + || defaults.string(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) != nil + } +} diff --git a/Sources/ProductAnalyticsEvent+Upgrade.swift b/Sources/ProductAnalyticsEvent+Upgrade.swift new file mode 100644 index 000000000000..56c691ae1971 --- /dev/null +++ b/Sources/ProductAnalyticsEvent+Upgrade.swift @@ -0,0 +1,19 @@ +import CmuxCloud +import CmuxProductAnalytics + +extension ProductAnalyticsEvent { + /// An upgrade click from `source`. The same token reaches checkout as + /// `cmux_source`, so this joins `cmux_billing_checkout_*` on `checkout_source`. + static func upgradeOpened( + _ source: ProUpgradeSource, + plan: CheckoutPlan = .pro, + destination: UpgradeDestination + ) -> Self { + .upgradeEntrypointOpened(source: source.rawValue, plan: plan.rawValue, destination: destination) + } + + /// An upgrade entrypoint from `source` became visible. + static func upgradeShown(_ source: ProUpgradeSource, plan: CheckoutPlan = .pro) -> Self { + .upgradePromptShown(source: source.rawValue, plan: plan.rawValue) + } +} diff --git a/Sources/RightSidebarMode+ProductFeature.swift b/Sources/RightSidebarMode+ProductFeature.swift new file mode 100644 index 000000000000..7c8cfac9d11f --- /dev/null +++ b/Sources/RightSidebarMode+ProductFeature.swift @@ -0,0 +1,16 @@ +import CmuxProductAnalytics + +extension RightSidebarMode { + /// The feature this right-sidebar tab counts as in product analytics. + var productFeature: ProductFeature { + switch self { + case .files: .filesExplorer + case .find: .find + case .sessions: .vault + case .feed: .feed + case .dock: .dock + case .machines: .cloudSidebar + case .customSidebar: .customSidebar + } + } +} diff --git a/Sources/TabManager+TerminalSplitAction.swift b/Sources/TabManager+TerminalSplitAction.swift index c230163f25ad..8fe58dc7a18d 100644 --- a/Sources/TabManager+TerminalSplitAction.swift +++ b/Sources/TabManager+TerminalSplitAction.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import CmuxPanes +import CmuxProductAnalytics import Foundation /// Preserves asynchronous acceptance through the shared terminal split action. @@ -35,6 +36,7 @@ extension TabManager { shellCommand: nil, workingDirectory: nil ) + if accepted { ProductAnalytics.app.featureUsed(.split) } return accepted ? .routedToRemote : .failed } guard workspace.panels[surfaceId] != nil else { return .failed } @@ -49,6 +51,9 @@ extension TabManager { ) if let panel = outcome.panel { workspace.equalizeSplitsAfterCreatingSplitIfEnabled(newPanelId: panel.id) + ProductAnalytics.app.featureUsed(.split) + } else if case .routedToRemote = outcome { + ProductAnalytics.app.featureUsed(.split) } else if case .noSpace = outcome { // Shortcut, menu, palette and Ghostty split actions all land here: // a split with no room for another pane beeps instead of doing diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cbcdcfdfc57f..80246376bd73 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1,3 +1,4 @@ +import CmuxProductAnalytics import AppKit import CmuxAgentChat import CmuxCloudMachines @@ -279,6 +280,16 @@ class TabManager: ObservableObject { func workspaceTabsWillChange(to newValue: [Workspace]) { objectWillChange.send() tabsPublisher.send(newValue) + if newValue.count != tabs.count, ProductAnalytics.app.isEnabled { + // A window that already had a workspace gaining one, outside + // restore, is a created workspace (whatever entrypoint created it). + // A new window's first workspace is not. + if newValue.count > tabs.count, !tabs.isEmpty, !isRestoringSessionSnapshot { + ProductAnalytics.app.reach(.firstWorkspace) + } + // After the commit, so the count reads the new list. Reorders skip this. + Task { @MainActor in AppDelegate.shared?.refreshProductAnalyticsActivityContext() } + } } /// Legacy `@Published workspaceGroups` willSet. diff --git a/Sources/TerminalController+ControlWorkspaceContext.swift b/Sources/TerminalController+ControlWorkspaceContext.swift index 0d1a375fa3c9..4873e02f57f4 100644 --- a/Sources/TerminalController+ControlWorkspaceContext.swift +++ b/Sources/TerminalController+ControlWorkspaceContext.swift @@ -3,6 +3,7 @@ import CmuxControlSocket import CmuxCore import CmuxFoundation import CmuxPanes +import CmuxProductAnalytics import CmuxRemoteWorkspace import CmuxRemoteSession import CmuxWorkspaces @@ -697,6 +698,11 @@ extension TerminalController: ControlWorkspaceContext { data: nil ) } + // `cmux ssh` configures a plain remote workspace; Cloud attaches carry + // a managed VM id and are counted by the Cloud events instead. + if config.managedCloudVMID == nil { + ProductAnalytics.app.featureUsed(.cmuxSSH) + } notifyRemotePTYControllerAvailabilityChanged() let windowId = AppDelegate.shared?.windowId(for: owner) diff --git a/Sources/UpgradePromptImpression.swift b/Sources/UpgradePromptImpression.swift new file mode 100644 index 000000000000..bbb29662b175 --- /dev/null +++ b/Sources/UpgradePromptImpression.swift @@ -0,0 +1,16 @@ +import CmuxCloud +import CmuxProductAnalytics +import SwiftUI + +extension View { + /// Records `cmux_upgrade_prompt_shown` for `source` when this view + /// appears. The pipeline keeps one per source per launch, so re-renders + /// and remounts are free. + func upgradePromptImpression( + _ source: ProUpgradeSource, + plan: CheckoutPlan = .pro, + analytics: ProductAnalytics = .app + ) -> some View { + onAppear { analytics.track(.upgradeShown(source, plan: plan)) } + } +} diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index 44d594c17532..ce8049e0f6e9 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -1,6 +1,7 @@ import AppKit import CmuxAppKitSupportUI import CmuxCloud +import CmuxProductAnalytics import CmuxFoundation import CmuxSettingsUI import CmuxSidebar @@ -361,6 +362,7 @@ struct SidebarCloudButton: View { .hintableAccessibilityLabel(title) .accessibilityIdentifier("SidebarCloudButton") .padding(.leading, SidebarFooterButtonMetrics.cloudLeadingInset) + .onAppear { ProductAnalytics.app.track(.cloudButtonShown) } } } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4f604246166e..8bb3611ad72c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -26,6 +26,7 @@ import CmuxPanes import CmuxSidebar import CmuxNotifications import Combine +import CmuxProductAnalytics import CryptoKit import Darwin import Network @@ -10252,6 +10253,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos installBrowserPanelSubscription(browserPanel) browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) applyCloudBrowserCreationPlan(cloudBrowserPlan, to: browserPanel) + if creationPolicy == .userInitiated { + ProductAnalytics.app.featureUsed(.browserPane) + } return browserPanel } @@ -10386,6 +10390,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos installBrowserPanelSubscription(browserPanel) browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) applyCloudBrowserCreationPlan(cloudBrowserPlan, to: browserPanel) + if creationPolicy == .userInitiated { + ProductAnalytics.app.featureUsed(.browserPane) + } return browserPanel } @@ -14914,7 +14921,9 @@ extension Workspace: BonsplitDelegate { } if let tabId = bonsplitController.selectedTab(inPane: pane)?.id, let panelId = panelIdFromSurfaceId(tabId) { - _ = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested(workspaceId: id, panelId: panelId, vertical: orientation == .vertical, focusIntent: .focusCreatedPane) + let accepted = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested(workspaceId: id, panelId: panelId, vertical: orientation == .vertical, focusIntent: .focusCreatedPane) ?? false + // tmux owns this split, so `didSplitPane` never runs for it. + if accepted { ProductAnalytics.app.featureUsed(.split) } } return false } @@ -15157,6 +15166,8 @@ extension Workspace: BonsplitDelegate { scheduleTerminalGeometryReconcile() return } + // A split from the tab bar UI (drag to an edge, split button) is a user split. + ProductAnalytics.app.featureUsed(.split) // If the new pane already has a tab, this split moved an existing tab (drag-to-split). // // In the "drag the only tab to split edge" case, bonsplit inserts a placeholder "Empty" diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 45085ff3a08c..2f8e160264c8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ D0B102040000000000000001 /* AboutPropertyRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B103040000000000000001 /* AboutPropertyRow.swift */; }; D0B102050000000000000001 /* AboutVisualEffectBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B103050000000000000001 /* AboutVisualEffectBackground.swift */; }; D0B102000000000000000001 /* AboutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B103000000000000000001 /* AboutWindowController.swift */; }; + EC5ED12CF24A42B4CAF095BC /* AccountAnalyticsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588E300F55061640D4CBE53A /* AccountAnalyticsState.swift */; }; 662000CBFF17033015D89968 /* AccountMacAdmissionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207C9B99F28EDA93DF17EDB6 /* AccountMacAdmissionPolicy.swift */; }; 7B4286950FBA469A8C23431B /* AccountMacDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7EB40497FBD54553C6598A /* AccountMacDirectory.swift */; }; 674B2758ED1A0B500EFFC9F6 /* AccountMacDirectoryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1278AF05232D11E39C32D4A /* AccountMacDirectoryClient.swift */; }; @@ -301,6 +302,7 @@ A5F100000000000000000003 /* AppDelegate+NotificationScroll.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F100000000000000000004 /* AppDelegate+NotificationScroll.swift */; }; 6313C7A00000000000000001 /* AppDelegate+PaneMemoryGuardrail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6313C7A00000000000000002 /* AppDelegate+PaneMemoryGuardrail.swift */; }; 71BC19E3211D86ED3333A772 /* AppDelegate+PaneNumberShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = B327EB76EA7CE66FFA2623B6 /* AppDelegate+PaneNumberShortcut.swift */; }; + EAC9592A7318ADC630BF1FF9 /* AppDelegate+ProductAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CFC6590048910718C3FAB7 /* AppDelegate+ProductAnalytics.swift */; }; 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */; }; 797800010000000000000001 /* AppDelegate+RemoteSessionPower.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797800010000000000000002 /* AppDelegate+RemoteSessionPower.swift */; }; C0DE74140000000000000001 /* AppDelegate+SavedLayoutMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE74140000000000000002 /* AppDelegate+SavedLayoutMenu.swift */; }; @@ -1125,6 +1127,7 @@ 5189C042A99401477E4DE49A /* CloudVPNSetupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3718948C68AF1E2610D73490 /* CloudVPNSetupTests.swift */; }; 59FECE3AAB392E8976A8D16A /* CloudVPNSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA45F7A29A3227884DC3929F /* CloudVPNSetupView.swift */; }; D17243000000000000000002 /* CloudWelcome in Resources */ = {isa = PBXBuildFile; fileRef = D17243000000000000000001 /* CloudWelcome */; }; + 43D9F9F0098DE6E4F85936B0 /* CloudWelcomeAnalyticsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335F00E37FFA52FF5624A936 /* CloudWelcomeAnalyticsSession.swift */; }; 3B7661AE47EE36CB4BEBEEED /* CloudWelcomeDebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883EC42C240445DBCE78E8A7 /* CloudWelcomeDebugMenu.swift */; }; 22D165EB062C603B0C3A0A82 /* CloudWelcomeHero.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA8C3407967FE40B15A41A3 /* CloudWelcomeHero.swift */; }; 6CDA457FF93E33F097EB4F7C /* CloudWelcomeMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66CDAC0E0F1B791CA9260310 /* CloudWelcomeMedia.swift */; }; @@ -1535,6 +1538,8 @@ F0C0A1000000000000000003 /* CmuxPhonePush in Frameworks */ = {isa = PBXBuildFile; productRef = F0C0A1000000000000000002 /* CmuxPhonePush */; }; F0C0A1000000000000000004 /* CmuxPhonePush in Frameworks */ = {isa = PBXBuildFile; productRef = F0C0A1000000000000000002 /* CmuxPhonePush */; }; CF6237863B42421E82388C27 /* CmuxPopoverGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96063440572D43F6AB2B499D /* CmuxPopoverGroupTests.swift */; }; + CA9A10000000000000000003 /* CmuxProductAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = CA9A10000000000000000002 /* CmuxProductAnalytics */; }; + CA9A10000000000000000004 /* CmuxProductAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = CA9A10000000000000000002 /* CmuxProductAnalytics */; }; A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */ = {isa = PBXBuildFile; productRef = A5C0DE0000000000000000A2 /* CMUXProjectModel */; }; CC0DE10000000000000000A3 /* CmuxRemoteDaemon in Frameworks */ = {isa = PBXBuildFile; productRef = CC0DE10000000000000000A2 /* CmuxRemoteDaemon */; }; CC0DE30000000000000000A3 /* CmuxRemoteSession in Frameworks */ = {isa = PBXBuildFile; productRef = CC0DE30000000000000000A2 /* CmuxRemoteSession */; }; @@ -2359,6 +2364,7 @@ 800100000000000000000101 /* HiveAccountTokenSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800100000000000000000102 /* HiveAccountTokenSource.swift */; }; 800100000000000000000103 /* HiveComputersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800100000000000000000104 /* HiveComputersService.swift */; }; 140240000000000000000002 /* HookPromptLengthUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 140240000000000000000001 /* HookPromptLengthUITests.swift */; }; + AC2BB0797CA8D9098F4D2F5A /* HostAccountFlow+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0A832803DD479693FA71A0 /* HostAccountFlow+Analytics.swift */; }; A4FF11E04A292AACEB803A41 /* HostAccountFlow+ReceivedInvitations.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2711070DB004542E6B9C0 /* HostAccountFlow+ReceivedInvitations.swift */; }; 40A965C25799A1B366F3F672 /* HostAccountFlow+TeamMembers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD17B8A4545F43B50EF504A /* HostAccountFlow+TeamMembers.swift */; }; 13019B000000000000000002 /* HostAccountFlow+TeamSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019B000000000000000001 /* HostAccountFlow+TeamSelection.swift */; }; @@ -2871,18 +2877,23 @@ A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; }; B8E9500A0000000000000001 /* PostHogAnalyticsPropertiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E9500A0000000000000002 /* PostHogAnalyticsPropertiesTests.swift */; }; + 72ED5F6AB93363EDB369E867 /* PostHogContextCalls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34F76A7191862A9BFADE3869 /* PostHogContextCalls.swift */; }; A7C0FF1D0000000000000001 /* PostHogIdentityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C0FF1D0000000000000002 /* PostHogIdentityObserver.swift */; }; + 6D7155E52FFDD7E47CF2E759 /* PostHogProductAnalyticsSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BF00B6EA5F80726C036DA53 /* PostHogProductAnalyticsSink.swift */; }; B7F100060000000000000001 /* PreparedNotificationSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F100060000000000000002 /* PreparedNotificationSound.swift */; }; D1F0A00100000000000000B1 /* PresenceHeartbeatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000B2 /* PresenceHeartbeatClient.swift */; }; DE71CE00000000000000000A /* PresenceHeartbeatClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000009 /* PresenceHeartbeatClientTests.swift */; }; D1F0A00100000000000000B3 /* PresenceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000B4 /* PresenceSettings.swift */; }; C0DEA771000000000000000D /* PricingPlansScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA771000000000000000E /* PricingPlansScreen.swift */; }; + 318A10362EFC47DB03BF1D5C /* ProBadgeActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89797AE49756191CC98B2C1 /* ProBadgeActions.swift */; }; C0DEA771000000000000000B /* ProBadgeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA771000000000000000C /* ProBadgeStyle.swift */; }; F0ACC0DE000000000000000D /* ProcessDetectedResumeIndexes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE000000000000000E /* ProcessDetectedResumeIndexes.swift */; }; AC0FCEFB6665CF134634D60B /* ProcessExitWait.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06E7588C05AC6A8659CFA51 /* ProcessExitWait.swift */; }; C11757000000000000000063 /* ProcessExitWait.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06E7588C05AC6A8659CFA51 /* ProcessExitWait.swift */; }; C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */; }; 0A327C88E531C7E407CEF58C /* ProcessSnapshotMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4587EDAC6B63EF4846FB24A6 /* ProcessSnapshotMeasurement.swift */; }; + BD54FBDB9B9175C3EC73C37E /* ProductAnalytics+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A46D27DF741643885DBBFA2 /* ProductAnalytics+App.swift */; }; + 0A9BC60ADCBC60AF3992E4CB /* ProductAnalyticsEvent+Upgrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0AFE40AA472BB58AEEBDB6E /* ProductAnalyticsEvent+Upgrade.swift */; }; D75280010000000000000001 /* ProfilingSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280010000000000000002 /* ProfilingSignposts.swift */; }; A5C0DE0000000000000000BA /* ProjectBuildSettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B9 /* ProjectBuildSettingsTabView.swift */; }; A5C0DE0000000000000000B6 /* ProjectFilesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B5 /* ProjectFilesTabView.swift */; }; @@ -3151,6 +3162,7 @@ B7F9A604B7F9A604B7F9A604 /* RightSidebarChromeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9A605B7F9A605B7F9A605 /* RightSidebarChromeStyle.swift */; }; C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */; }; B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000004 /* RightSidebarMode+Availability.swift */; }; + 6E78170519FB498D386528A2 /* RightSidebarMode+ProductFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899EDFB4688088904AEC67B4 /* RightSidebarMode+ProductFeature.swift */; }; 87272A2D0FCDDA4D798961D8 /* RightSidebarMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 272B367E39A68B0DCDE9E182 /* RightSidebarMode.swift */; }; 2472F1A7500F5B4FFF49740C /* RightSidebarModeBarDrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */; }; D3282C34128369182EC5CC9A /* RightSidebarModeBarTabsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */; }; @@ -4234,6 +4246,8 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; + 9E333A705D5EA232771DA203 /* UpgradeAnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA13D9C82962402EAA0FABC1 /* UpgradeAnalyticsTests.swift */; }; + 944FADDCC0FC5D78EB85E584 /* UpgradePromptImpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0312C285F7C7C82024BF62 /* UpgradePromptImpression.swift */; }; 0D5183AD0EC5E5C47A7E6E91 /* UserDefaults+CloudTreeExpansionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E7E938E768DFC9404CFA32 /* UserDefaults+CloudTreeExpansionPersistence.swift */; }; F6589022F0451794CFF2BEEF /* V2AccountMacAdmissionAuthority.swift in Sources */ = {isa = PBXBuildFile; fileRef = F35954A0F602A2EDCF60449F /* V2AccountMacAdmissionAuthority.swift */; }; F76550040000000000000001 /* VaultAgentProcessScanner+CodexSessionCwd.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76550040000000000000002 /* VaultAgentProcessScanner+CodexSessionCwd.swift */; }; @@ -4739,6 +4753,7 @@ D0B103040000000000000001 /* AboutPropertyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/About/AboutPropertyRow.swift; sourceTree = ""; }; D0B103050000000000000001 /* AboutVisualEffectBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/About/AboutVisualEffectBackground.swift; sourceTree = ""; }; D0B103000000000000000001 /* AboutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/About/AboutWindowController.swift; sourceTree = ""; }; + 588E300F55061640D4CBE53A /* AccountAnalyticsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAnalyticsState.swift; sourceTree = ""; }; 207C9B99F28EDA93DF17EDB6 /* AccountMacAdmissionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Devices/Account/AccountMacAdmissionPolicy.swift; sourceTree = ""; }; AE7EB40497FBD54553C6598A /* AccountMacDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Devices/Account/AccountMacDirectory.swift; sourceTree = ""; }; B1278AF05232D11E39C32D4A /* AccountMacDirectoryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Devices/Account/AccountMacDirectoryClient.swift; sourceTree = ""; }; @@ -5020,6 +5035,7 @@ A5F100000000000000000004 /* AppDelegate+NotificationScroll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+NotificationScroll.swift"; sourceTree = ""; }; 6313C7A00000000000000002 /* AppDelegate+PaneMemoryGuardrail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+PaneMemoryGuardrail.swift"; sourceTree = ""; }; B327EB76EA7CE66FFA2623B6 /* AppDelegate+PaneNumberShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+PaneNumberShortcut.swift"; sourceTree = ""; }; + 02CFC6590048910718C3FAB7 /* AppDelegate+ProductAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+ProductAnalytics.swift"; sourceTree = ""; }; 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RecoverableMainWindowRoutes.swift"; sourceTree = ""; }; 797800010000000000000002 /* AppDelegate+RemoteSessionPower.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RemoteSessionPower.swift"; sourceTree = ""; }; C0DE74140000000000000002 /* AppDelegate+SavedLayoutMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+SavedLayoutMenu.swift"; sourceTree = ""; }; @@ -5820,6 +5836,7 @@ 3718948C68AF1E2610D73490 /* CloudVPNSetupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudVPNSetupTests.swift"; sourceTree = ""; }; EA45F7A29A3227884DC3929F /* CloudVPNSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudVPNSetupView.swift; sourceTree = ""; }; D17243000000000000000001 /* CloudWelcome */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CloudWelcome; sourceTree = ""; }; + 335F00E37FFA52FF5624A936 /* CloudWelcomeAnalyticsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWelcomeAnalyticsSession.swift; sourceTree = ""; }; 883EC42C240445DBCE78E8A7 /* CloudWelcomeDebugMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug/CloudWelcomeDebugMenu.swift; sourceTree = ""; }; 3CA8C3407967FE40B15A41A3 /* CloudWelcomeHero.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWelcomeHero.swift; sourceTree = ""; }; 66CDAC0E0F1B791CA9260310 /* CloudWelcomeMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWelcomeMedia.swift; sourceTree = ""; }; @@ -6920,6 +6937,7 @@ 800100000000000000000102 /* HiveAccountTokenSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Hive/HiveAccountTokenSource.swift"; sourceTree = ""; }; 800100000000000000000104 /* HiveComputersService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Hive/HiveComputersService.swift"; sourceTree = ""; }; 140240000000000000000001 /* HookPromptLengthUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookPromptLengthUITests.swift; sourceTree = ""; }; + BE0A832803DD479693FA71A0 /* HostAccountFlow+Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HostAccountFlow+Analytics.swift"; sourceTree = ""; }; FDD2711070DB004542E6B9C0 /* HostAccountFlow+ReceivedInvitations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HostAccountFlow+ReceivedInvitations.swift"; sourceTree = ""; }; EFD17B8A4545F43B50EF504A /* HostAccountFlow+TeamMembers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HostAccountFlow+TeamMembers.swift"; sourceTree = ""; }; 13019B000000000000000001 /* HostAccountFlow+TeamSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HostAccountFlow+TeamSelection.swift"; sourceTree = ""; }; @@ -7422,17 +7440,22 @@ C037CA7A6B20B3F495BAB401 /* PortTerminalDeviceLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortTerminalDeviceLookup.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; B8E9500A0000000000000002 /* PostHogAnalyticsPropertiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsPropertiesTests.swift; sourceTree = ""; }; + 34F76A7191862A9BFADE3869 /* PostHogContextCalls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogContextCalls.swift; sourceTree = ""; }; A7C0FF1D0000000000000002 /* PostHogIdentityObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostHogIdentityObserver.swift; sourceTree = ""; }; + 7BF00B6EA5F80726C036DA53 /* PostHogProductAnalyticsSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogProductAnalyticsSink.swift; sourceTree = ""; }; B7F100060000000000000002 /* PreparedNotificationSound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedNotificationSound.swift; sourceTree = ""; }; D1F0A00100000000000000B2 /* PresenceHeartbeatClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PresenceHeartbeatClient.swift; sourceTree = ""; }; DE71CE000000000000000009 /* PresenceHeartbeatClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceHeartbeatClientTests.swift; sourceTree = ""; }; D1F0A00100000000000000B4 /* PresenceSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PresenceSettings.swift; sourceTree = ""; }; C0DEA771000000000000000E /* PricingPlansScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PricingPlansScreen.swift; sourceTree = ""; }; + F89797AE49756191CC98B2C1 /* ProBadgeActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProBadgeActions.swift; sourceTree = ""; }; C0DEA771000000000000000C /* ProBadgeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProBadgeStyle.swift; sourceTree = ""; }; F0ACC0DE000000000000000E /* ProcessDetectedResumeIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetectedResumeIndexes.swift; sourceTree = ""; }; E06E7588C05AC6A8659CFA51 /* ProcessExitWait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessExitWait.swift; sourceTree = ""; }; C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPipeReadCrashRegressionTests.swift; sourceTree = ""; }; 4587EDAC6B63EF4846FB24A6 /* ProcessSnapshotMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessSnapshotMeasurement.swift"; sourceTree = ""; }; + 7A46D27DF741643885DBBFA2 /* ProductAnalytics+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductAnalytics+App.swift"; sourceTree = ""; }; + E0AFE40AA472BB58AEEBDB6E /* ProductAnalyticsEvent+Upgrade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductAnalyticsEvent+Upgrade.swift"; sourceTree = ""; }; D75280010000000000000002 /* ProfilingSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilingSignposts.swift; sourceTree = ""; }; A5C0DE0000000000000000B9 /* ProjectBuildSettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectBuildSettingsTabView.swift; sourceTree = ""; }; A5C0DE0000000000000000B5 /* ProjectFilesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectFilesTabView.swift; sourceTree = ""; }; @@ -7694,6 +7717,7 @@ B7F9A605B7F9A605B7F9A605 /* RightSidebarChromeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeStyle.swift; sourceTree = ""; }; C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarCommandPaletteTests.swift; sourceTree = ""; }; B37A00000000000000000004 /* RightSidebarMode+Availability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarMode+Availability.swift"; sourceTree = ""; }; + 899EDFB4688088904AEC67B4 /* RightSidebarMode+ProductFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarMode+ProductFeature.swift"; sourceTree = ""; }; 272B367E39A68B0DCDE9E182 /* RightSidebarMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarMode.swift"; sourceTree = ""; }; DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarDrag.swift; sourceTree = ""; }; 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarTabsLayout.swift; sourceTree = ""; }; @@ -8769,6 +8793,8 @@ F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001218 /* UpdateTitlebarAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTitlebarAccessory.swift; sourceTree = ""; }; + BA13D9C82962402EAA0FABC1 /* UpgradeAnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UpgradeAnalyticsTests.swift"; sourceTree = ""; }; + 6E0312C285F7C7C82024BF62 /* UpgradePromptImpression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradePromptImpression.swift; sourceTree = ""; }; 82E7E938E768DFC9404CFA32 /* UserDefaults+CloudTreeExpansionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+CloudTreeExpansionPersistence.swift"; sourceTree = ""; }; F35954A0F602A2EDCF60449F /* V2AccountMacAdmissionAuthority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Devices/Account/V2AccountMacAdmissionAuthority.swift; sourceTree = ""; }; F76550040000000000000002 /* VaultAgentProcessScanner+CodexSessionCwd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultAgentProcessScanner+CodexSessionCwd.swift"; sourceTree = ""; }; @@ -9211,6 +9237,7 @@ E3B7A30000000000000000D3 /* CmuxNotifications in Frameworks */, E3B7A30000000000000000F3 /* CmuxPanes in Frameworks */, F0C0A1000000000000000003 /* CmuxPhonePush in Frameworks */, + CA9A10000000000000000003 /* CmuxProductAnalytics in Frameworks */, A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */, CC0DE10000000000000000A3 /* CmuxRemoteDaemon in Frameworks */, CC0DE30000000000000000A3 /* CmuxRemoteSession in Frameworks */, @@ -9326,6 +9353,7 @@ C1A072000000000000000003 /* CmuxMobileRPC in Frameworks */, 1A0B0C0D0E0F101112132004 /* CmuxMobileTransport in Frameworks */, F0C0A1000000000000000004 /* CmuxPhonePush in Frameworks */, + CA9A10000000000000000004 /* CmuxProductAnalytics in Frameworks */, C0DE4A000000000000000005 /* CmuxSidebarProviderKit in Frameworks */, D96970000000000000000003 /* CmuxSudoBroker in Frameworks */, D96970000000000000000005 /* CmuxSudoBrokerUI in Frameworks */, @@ -10021,6 +10049,7 @@ 5284AD113D5D3BC29608D4F4 /* InProcessMachineCreateLauncher.swift */, B4E85236BC4E03F5BA8B908C /* CoderouterAccountState.swift */, 5ECF12C0C620A0999268B8BA /* CloudTuiManualMirrorHandshake.swift */, + 335F00E37FFA52FF5624A936 /* CloudWelcomeAnalyticsSession.swift */, A986CE6A5E2D281B2CE3C7E1 /* SidebarCloudButtonAction.swift */, ); name = Cloud; @@ -12295,6 +12324,16 @@ 9E6A96CCBDE353757B31D2BB /* VaultAgentRegistry+Muse.swift */, 603138BB51C4ADFD98885570 /* SessionIndexStore+Muse.swift */, 352D4A0BA53BF9B5B3480BE8 /* MainWindowHitTestMutationMonitor.swift */, + 588E300F55061640D4CBE53A /* AccountAnalyticsState.swift */, + 02CFC6590048910718C3FAB7 /* AppDelegate+ProductAnalytics.swift */, + BE0A832803DD479693FA71A0 /* HostAccountFlow+Analytics.swift */, + 7BF00B6EA5F80726C036DA53 /* PostHogProductAnalyticsSink.swift */, + F89797AE49756191CC98B2C1 /* ProBadgeActions.swift */, + 7A46D27DF741643885DBBFA2 /* ProductAnalytics+App.swift */, + E0AFE40AA472BB58AEEBDB6E /* ProductAnalyticsEvent+Upgrade.swift */, + 6E0312C285F7C7C82024BF62 /* UpgradePromptImpression.swift */, + 899EDFB4688088904AEC67B4 /* RightSidebarMode+ProductFeature.swift */, + 34F76A7191862A9BFADE3869 /* PostHogContextCalls.swift */, ); path = Sources; sourceTree = ""; @@ -13869,6 +13908,7 @@ 220FD07E397B72CAE8025EC7 /* CloudWelcomePresentationTests.swift */, DA688992ADFE3696B36EEC3D /* CloudStaleInventoryMaterializationTests.swift */, BB0DDF5F330E26DA03118A56 /* ClaudeTranscriptlessRestoreTests.swift */, + BA13D9C82962402EAA0FABC1 /* UpgradeAnalyticsTests.swift */, 6F6A2754CDC4A872BCEFD3EA /* AgentAcknowledgedTurnNotificationTests.swift */, 2B8ACF56AB5811EEC9D0203F /* SidebarCloudButtonActionTests.swift */, 62038FEBE991E6F6022BD52F /* SSHTuiAttachRetryTests.swift */, @@ -14062,6 +14102,7 @@ C01E00AA00000000000000A2 /* CmuxSyntaxHighlighting */, D0F1A0000000000000000002 /* CmuxFilePreviewCore */, D0F2A0000000000000000002 /* CmuxCloudBannerCore */, + CA9A10000000000000000002 /* CmuxProductAnalytics */, CE57E10000000000000000B2 /* CmuxSentryReporting */, CE57E10000000000000000A2 /* CmuxSentryScrubbing */, CC0DE00000000000000000A2 /* CmuxCore */, @@ -14199,6 +14240,7 @@ C01E00AA00000000000000A2 /* CmuxSyntaxHighlighting */, D0F1A0000000000000000002 /* CmuxFilePreviewCore */, D0F2A0000000000000000002 /* CmuxCloudBannerCore */, + CA9A10000000000000000002 /* CmuxProductAnalytics */, D0F4A0000000000000000002 /* CmuxCloudTunnelCore */, A5001262 /* Bonsplit */, D96973000000000000000001 /* CmuxSudoBroker */, @@ -14335,6 +14377,7 @@ C01E00AA00000000000000A1 /* XCLocalSwiftPackageReference "CmuxSyntaxHighlighting" */, D0F1A0000000000000000001 /* XCLocalSwiftPackageReference "CmuxFilePreviewCore" */, D0F2A0000000000000000001 /* XCLocalSwiftPackageReference "CmuxCloudBannerCore" */, + CA9A10000000000000000001 /* XCLocalSwiftPackageReference "CmuxProductAnalytics" */, D0F4A0000000000000000001 /* XCLocalSwiftPackageReference "CmuxCloudTunnelCore" */, CE57E10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSentryTelemetry" */, CC0DE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCore" */, @@ -14693,6 +14736,7 @@ D0B102040000000000000001 /* AboutPropertyRow.swift in Sources */, D0B102050000000000000001 /* AboutVisualEffectBackground.swift in Sources */, D0B102000000000000000001 /* AboutWindowController.swift in Sources */, + EC5ED12CF24A42B4CAF095BC /* AccountAnalyticsState.swift in Sources */, 662000CBFF17033015D89968 /* AccountMacAdmissionPolicy.swift in Sources */, 7B4286950FBA469A8C23431B /* AccountMacDirectory.swift in Sources */, 674B2758ED1A0B500EFFC9F6 /* AccountMacDirectoryClient.swift in Sources */, @@ -14896,6 +14940,7 @@ A5F100000000000000000003 /* AppDelegate+NotificationScroll.swift in Sources */, 6313C7A00000000000000001 /* AppDelegate+PaneMemoryGuardrail.swift in Sources */, 71BC19E3211D86ED3333A772 /* AppDelegate+PaneNumberShortcut.swift in Sources */, + EAC9592A7318ADC630BF1FF9 /* AppDelegate+ProductAnalytics.swift in Sources */, 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */, 797800010000000000000001 /* AppDelegate+RemoteSessionPower.swift in Sources */, C0DE74140000000000000001 /* AppDelegate+SavedLayoutMenu.swift in Sources */, @@ -15278,6 +15323,7 @@ 549C9947A81C58B3F6402500 /* CloudVPNSetupPanel.swift in Sources */, C8783B458C963A34D5A93FF3 /* CloudVPNSetupPanelView.swift in Sources */, 59FECE3AAB392E8976A8D16A /* CloudVPNSetupView.swift in Sources */, + 43D9F9F0098DE6E4F85936B0 /* CloudWelcomeAnalyticsSession.swift in Sources */, 3B7661AE47EE36CB4BEBEEED /* CloudWelcomeDebugMenu.swift in Sources */, 22D165EB062C603B0C3A0A82 /* CloudWelcomeHero.swift in Sources */, 6CDA457FF93E33F097EB4F7C /* CloudWelcomeMedia.swift in Sources */, @@ -15862,6 +15908,7 @@ A10348020000000000000001 /* HistoryMenuState.swift in Sources */, 800100000000000000000101 /* HiveAccountTokenSource.swift in Sources */, 800100000000000000000103 /* HiveComputersService.swift in Sources */, + AC2BB0797CA8D9098F4D2F5A /* HostAccountFlow+Analytics.swift in Sources */, A4FF11E04A292AACEB803A41 /* HostAccountFlow+ReceivedInvitations.swift in Sources */, 40A965C25799A1B366F3F672 /* HostAccountFlow+TeamMembers.swift in Sources */, 13019B000000000000000002 /* HostAccountFlow+TeamSelection.swift in Sources */, @@ -16179,13 +16226,18 @@ B79500060000000000000001 /* PortScanPublicationState.swift in Sources */, 9262420B51295D47DC7065BD /* PortTerminalDeviceLookup.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, + 72ED5F6AB93363EDB369E867 /* PostHogContextCalls.swift in Sources */, A7C0FF1D0000000000000001 /* PostHogIdentityObserver.swift in Sources */, + 6D7155E52FFDD7E47CF2E759 /* PostHogProductAnalyticsSink.swift in Sources */, B7F100060000000000000001 /* PreparedNotificationSound.swift in Sources */, D1F0A00100000000000000B1 /* PresenceHeartbeatClient.swift in Sources */, D1F0A00100000000000000B3 /* PresenceSettings.swift in Sources */, C0DEA771000000000000000D /* PricingPlansScreen.swift in Sources */, + 318A10362EFC47DB03BF1D5C /* ProBadgeActions.swift in Sources */, C0DEA771000000000000000B /* ProBadgeStyle.swift in Sources */, F0ACC0DE000000000000000D /* ProcessDetectedResumeIndexes.swift in Sources */, + BD54FBDB9B9175C3EC73C37E /* ProductAnalytics+App.swift in Sources */, + 0A9BC60ADCBC60AF3992E4CB /* ProductAnalyticsEvent+Upgrade.swift in Sources */, D75280010000000000000001 /* ProfilingSignposts.swift in Sources */, A5C0DE0000000000000000BA /* ProjectBuildSettingsTabView.swift in Sources */, A5C0DE0000000000000000B6 /* ProjectFilesTabView.swift in Sources */, @@ -16338,6 +16390,7 @@ B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */, B7F9A604B7F9A604B7F9A604 /* RightSidebarChromeStyle.swift in Sources */, B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */, + 6E78170519FB498D386528A2 /* RightSidebarMode+ProductFeature.swift in Sources */, 87272A2D0FCDDA4D798961D8 /* RightSidebarMode.swift in Sources */, 2472F1A7500F5B4FFF49740C /* RightSidebarModeBarDrag.swift in Sources */, D3282C34128369182EC5CC9A /* RightSidebarModeBarTabsLayout.swift in Sources */, @@ -17126,6 +17179,7 @@ E7F100000000000000000001 /* UITestSocketSanityCoordinator.swift in Sources */, A500120D /* UpdateLogStore.swift in Sources */, A5001208 /* UpdateTitlebarAccessory.swift in Sources */, + 944FADDCC0FC5D78EB85E584 /* UpgradePromptImpression.swift in Sources */, 0D5183AD0EC5E5C47A7E6E91 /* UserDefaults+CloudTreeExpansionPersistence.swift in Sources */, F6589022F0451794CFF2BEEF /* V2AccountMacAdmissionAuthority.swift in Sources */, F76550040000000000000001 /* VaultAgentProcessScanner+CodexSessionCwd.swift in Sources */, @@ -18989,6 +19043,7 @@ 0C8A1FB23FA2673EDA2BE6A4 /* UITestFixtureTeamsAuthClientTests.swift in Sources */, CF67431916D254CC833A68CB /* UITestSocketCommandScriptTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, + 9E333A705D5EA232771DA203 /* UpgradeAnalyticsTests.swift in Sources */, A36C009DF2E44501AA774136 /* VaultCheckpointForkTests.swift in Sources */, C2A04B60729A4FA6B38C8BC1 /* VaultCheckpointHarnessTests.swift in Sources */, 469200054692000146920001 /* VaultNativeDragSourceTests.swift in Sources */, @@ -19901,6 +19956,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxCloudBannerCore; }; + CA9A10000000000000000001 /* XCLocalSwiftPackageReference "CmuxProductAnalytics" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxProductAnalytics; + }; CE57E10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSentryTelemetry" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CmuxSentryTelemetry; @@ -20294,6 +20353,11 @@ package = D0F2A0000000000000000001 /* XCLocalSwiftPackageReference "CmuxCloudBannerCore" */; productName = CmuxCloudBannerCore; }; + CA9A10000000000000000002 /* CmuxProductAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = CA9A10000000000000000001 /* XCLocalSwiftPackageReference "CmuxProductAnalytics" */; + productName = CmuxProductAnalytics; + }; CE57E10000000000000000A2 /* CmuxSentryScrubbing */ = { isa = XCSwiftPackageProductDependency; package = CE57E10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSentryTelemetry" */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 57cae4cf1a92..e2f17cbb8317 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -249,6 +249,9 @@ + + diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift index b6edcd7dfd31..ee55c218fb1a 100644 --- a/cmuxTests/AuthEnvironmentTests.swift +++ b/cmuxTests/AuthEnvironmentTests.swift @@ -790,12 +790,4 @@ struct CheckoutAttributionTests { #expect(pro.path == "/api/billing/checkout") #expect(max.path == "/api/billing/checkout") } - - @Test - func intentPropertiesNameSurfaceAndChannel() { - let properties = CheckoutAttribution.intentProperties(source: .helpMenu, flavor: .stable) - #expect(properties["source"] as? String == "mac_help_menu") - #expect(properties["client"] as? String == "mac") - #expect(properties["channel"] as? String == "stable") - } } diff --git a/cmuxTests/CloudWelcomePresentationTests.swift b/cmuxTests/CloudWelcomePresentationTests.swift index d2d836d94a89..c967f31e9d97 100644 --- a/cmuxTests/CloudWelcomePresentationTests.swift +++ b/cmuxTests/CloudWelcomePresentationTests.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxProductAnalytics import CmuxSettings import Testing @@ -29,13 +30,13 @@ struct CloudWelcomePresentationTests { ) defer { controller.dismiss() } - controller.present(over: nil, onEnabled: { resumed += 1 }) + controller.present(over: nil, trigger: .newCloudMachine, onEnabled: { resumed += 1 }) #expect(isVisible) #expect(defaults.string(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == "0.65.1") #expect(steps.isEmpty) #expect(resumed == 0) controller.dismiss() - controller.present(over: nil, onEnabled: { resumed += 1 }) + controller.present(over: nil, trigger: .newCloudMachine, onEnabled: { resumed += 1 }) #expect(!isVisible) #expect(steps == [.enable]) #expect(resumed == 1) @@ -64,7 +65,7 @@ struct CloudWelcomePresentationTests { ) defer { controller.dismiss() } for _ in 0..<2 { - controller.present(over: nil, onEnabled: { resumed += 1 }) + controller.present(over: nil, trigger: .newCloudMachine, onEnabled: { resumed += 1 }) #expect(!isVisible) } #expect(steps == [step, step]) @@ -89,11 +90,11 @@ struct CloudWelcomePresentationTests { controller.presentForDebug(over: nil) #expect(isVisible) controller.dismiss() - controller.present(over: nil) + controller.present(over: nil, trigger: .newCloudMachine) #expect(!isVisible) controller.resetForDebug() #expect(!CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) - controller.present(over: nil) + controller.present(over: nil, trigger: .newCloudMachine) #expect(isVisible) #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) controller.resetForDebug() diff --git a/cmuxTests/UpgradeAnalyticsTests.swift b/cmuxTests/UpgradeAnalyticsTests.swift new file mode 100644 index 000000000000..33bcaaa1418e --- /dev/null +++ b/cmuxTests/UpgradeAnalyticsTests.swift @@ -0,0 +1,320 @@ +import AppKit +import CMUXMobileCore +import CmuxCloud +import CmuxProductAnalytics +import Foundation +import os +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Records the events a ``ProductAnalytics`` sends, for app-level analytics tests. +private final class RecordingSink: AnalyticsEmitting { + // Written by the pipeline actor, read by the test after `drain()`. + private let state = OSAllocatedUnfairLock<[(String, [String: AnalyticsValue])]>(initialState: []) + var events: [(name: String, properties: [String: AnalyticsValue])] { state.withLock { $0.map { (name: $0.0, properties: $0.1) } } } + func named(_ name: String) -> [[String: AnalyticsValue]] { events.filter { $0.name == name }.map(\.properties) } + + func capture(_ event: String, _ properties: [String: AnalyticsValue]) { state.withLock { $0.append((event, properties)) } } + func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) {} + func setSuperProperties(_ properties: [String: AnalyticsValue]) {} + func flush() async {} +} + +private enum TestAnalytics { + static let fixedNow = Date(timeIntervalSince1970: 1_800_000_000) + + /// An emitter with its own ledger suite. Remove the suite with + /// ``remove(_:)`` once the test has drained the emitter. + static func make(enabled: Bool = true) -> (ProductAnalytics, RecordingSink, String) { + let sink = RecordingSink() + let suiteName = "UpgradeAnalyticsTests.\(UUID().uuidString)" + let analytics = ProductAnalytics( + isEnabled: enabled, + sink: sink, + ledger: ProductAnalyticsLedger(defaults: UserDefaults(suiteName: suiteName)!), + context: ProductAnalyticsContext(appVersion: "0.66.0", appBuild: "1", channel: "dev", osVersion: "15.5"), + isPreexistingInstall: true + ) + return (analytics, sink, suiteName) + } + + static func remove(_ suiteName: String) { + UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) + } +} + +@MainActor +@Suite("Upgrade analytics", .serialized, .exclusiveAppContext) +struct UpgradeAnalyticsTests { + @Test("the sidebar Upgrade badge records its impression, click and dismiss with its source") + func sidebarBadge() async { + let (analytics, sink, ledgerSuite) = TestAnalytics.make() + defer { TestAnalytics.remove(ledgerSuite) } + var opened: [ProUpgradeSource] = [] + let actions = ProBadgeActions(analytics: analytics, openPricing: { opened.append($0) }) + actions.appeared() + actions.appeared() + actions.upgrade() + actions.dismissed() + await analytics.drain() + + #expect(opened == [.sidebarBadge]) + #expect(sink.named("cmux_upgrade_prompt_shown").compactMap { $0["source"] } == [AnalyticsValue.string("mac_sidebar_badge")]) + let click = sink.named("cmux_upgrade_entrypoint_opened") + #expect(click.count == 1) + #expect(click.first?["source"] == .string("mac_sidebar_badge")) + #expect(click.first?["destination"] == .string("pricing_page")) + #expect(click.first?["plan"] == .string("pro")) + #expect(click.first?["client"] == .string("mac")) + #expect(click.first?["account_plan"] != nil) + #expect(sink.named("cmux_upgrade_badge_dismissed").compactMap { $0["source"] } == [AnalyticsValue.string("mac_sidebar_badge")]) + } + + @Test("the welcome card's Upgrade records shown, click, close and the checkout source", + arguments: [CloudWelcomeTrigger.launchAnnouncement, .newCloudWorkspace, .newCloudMachine]) + func welcomeUpgrade(trigger: CloudWelcomeTrigger) async throws { + let (analytics, sink, ledgerSuite) = TestAnalytics.make() + defer { TestAnalytics.remove(ledgerSuite) } + let suite = "UpgradeAnalyticsTests.welcome.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var performed: [CloudWelcomeNextStep] = [] + let controller = CloudWelcomeWindowController( + defaults: defaults, + nextStep: { .upgrade }, + performNextStep: { step, _ in + performed.append(step) + // What the real `.upgrade` step does, minus opening pricing. + ProUpgradePresenter.present(source: .cloudWelcome, analytics: analytics, open: { _ in }) + }, + analytics: analytics, + now: { TestAnalytics.fixedNow } + ) + defer { controller.dismiss() } + controller.present(over: nil, trigger: trigger) + controller.handleNext(.upgrade) + await analytics.drain() + + #expect(performed == [.upgrade]) + let shown = try #require(sink.named("cmux_cloud_welcome_shown").first) + #expect(shown["trigger"] == .string(trigger.rawValue)) + #expect(shown["next_step"] == .string("upgrade")) + #expect(sink.named("cmux_upgrade_prompt_shown").compactMap { $0["source"] } == [AnalyticsValue.string("mac_cloud_welcome")]) + let click = try #require(sink.named("cmux_cloud_welcome_button_clicked").first) + #expect(click["button"] == .string("upgrade")) + #expect(click["trigger"] == .string(trigger.rawValue)) + let closed = sink.named("cmux_cloud_welcome_closed") + #expect(closed.count == 1) + #expect(closed.first?["outcome"] == .string("upgrade")) + #expect(sink.named("cmux_upgrade_entrypoint_opened").compactMap { $0["source"] } == [AnalyticsValue.string("mac_cloud_welcome")]) + let order = sink.events.map(\.name).filter { $0.hasPrefix("cmux_cloud_welcome") || $0 == "cmux_upgrade_entrypoint_opened" } + #expect(order == [ + "cmux_cloud_welcome_shown", + "cmux_cloud_welcome_button_clicked", + "cmux_cloud_welcome_closed", + "cmux_upgrade_entrypoint_opened", + ]) + } + + @Test("Enable, Not Now and a plain window close each end the card once") + func welcomeOtherOutcomes() async throws { + for (step, act, button, outcome) in [ + (CloudWelcomeNextStep.enable, "next", "enable", "enable"), + (.signIn, "next", "sign_in", "sign_in"), + (.upgrade, "notNow", "not_now", "not_now"), + (.upgrade, "close", nil, "window_closed"), + ] as [(CloudWelcomeNextStep, String, String?, String)] { + let (analytics, sink, ledgerSuite) = TestAnalytics.make() + defer { TestAnalytics.remove(ledgerSuite) } + let suite = "UpgradeAnalyticsTests.outcome.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let controller = CloudWelcomeWindowController( + defaults: defaults, + nextStep: { step }, + performNextStep: { _, _ in }, + analytics: analytics, + now: { TestAnalytics.fixedNow } + ) + controller.present(over: nil, trigger: .newCloudMachine) + switch act { + case "next": controller.handleNext(step) + case "notNow": controller.handleNotNow() + default: controller.dismiss() + } + controller.dismiss() + await analytics.drain() + + let buttons = sink.named("cmux_cloud_welcome_button_clicked").compactMap { $0["button"] } + let expectedButtons: [AnalyticsValue] = button.map { [AnalyticsValue.string($0)] } ?? [] + #expect(buttons == expectedButtons) + let closed = sink.named("cmux_cloud_welcome_closed") + #expect(closed.count == 1, "\(outcome)") + #expect(closed.first?["outcome"] == .string(outcome)) + #expect(closed.first?["visible_for"] == .string("lt_1s")) + } + } + + @Test("the sidebar Cloud button records its route; while Cloud is off the welcome follows with its trigger") + func sidebarCloudButton() async throws { + let (analytics, sink, ledgerSuite) = TestAnalytics.make() + defer { TestAnalytics.remove(ledgerSuite) } + let suite = "UpgradeAnalyticsTests.cloudButton.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let welcome = CloudWelcomeWindowController( + defaults: defaults, + nextStep: { .upgrade }, + performNextStep: { _, _ in }, + analytics: analytics, + now: { TestAnalytics.fixedNow } + ) + defer { welcome.dismiss() } + + SidebarCloudButtonAction( + isAvailable: { true }, isEnabled: { true }, revealMachines: {}, presentIntroduction: {}, + analytics: analytics + ).perform() + SidebarCloudButtonAction( + isAvailable: { true }, isEnabled: { false }, revealMachines: {}, + presentIntroduction: { welcome.presentIntroduction(over: nil) }, + analytics: analytics + ).perform() + SidebarCloudButtonAction( + isAvailable: { false }, isEnabled: { false }, revealMachines: {}, presentIntroduction: {}, + analytics: analytics + ).perform() + await analytics.drain() + + #expect(sink.named("cmux_cloud_button_clicked").compactMap { $0["action"] } + == [AnalyticsValue.string("open_cloud"), .string("introduce_cloud")]) + let shown = try #require(sink.named("cmux_cloud_welcome_shown").first) + #expect(shown["trigger"] == .string("sidebar_cloud_button")) + #expect(shown["next_step"] == .string("upgrade")) + let order = sink.events.map(\.name).filter { $0 == "cmux_cloud_button_clicked" || $0 == "cmux_cloud_welcome_shown" } + #expect(order == ["cmux_cloud_button_clicked", "cmux_cloud_button_clicked", "cmux_cloud_welcome_shown"]) + } + + @Test("a card that turns into Upgrade after the plan loads records the Upgrade impression once") + func welcomeUpgradeImpressionAfterPlanLoads() async throws { + let (analytics, sink, ledgerSuite) = TestAnalytics.make() + defer { TestAnalytics.remove(ledgerSuite) } + let suite = "UpgradeAnalyticsTests.planLoads.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var step = CloudWelcomeNextStep.openCloud + let controller = CloudWelcomeWindowController( + defaults: defaults, + nextStep: { step }, + performNextStep: { _, _ in }, + analytics: analytics, + now: { TestAnalytics.fixedNow } + ) + defer { controller.dismiss() } + controller.present(over: nil, trigger: .newCloudWorkspace) + await analytics.drain() + #expect(sink.named("cmux_upgrade_prompt_shown").isEmpty) + + step = .upgrade + controller.analyticsSession?.stepDisplayed(.upgrade) + controller.analyticsSession?.stepDisplayed(.upgrade) + controller.handleNotNow() + await analytics.drain() + #expect(sink.named("cmux_upgrade_prompt_shown").compactMap { $0["source"] } == [AnalyticsValue.string("mac_cloud_welcome")]) + #expect(sink.named("cmux_cloud_welcome_shown").first?["next_step"] == .string("open_cloud")) + #expect(sink.named("cmux_cloud_welcome_closed").first?["next_step"] == .string("upgrade")) + } + + @Test("with telemetry off, no upgrade surface records anything") + func optOut() async throws { + let (analytics, sink, ledgerSuite) = TestAnalytics.make(enabled: false) + defer { TestAnalytics.remove(ledgerSuite) } + let actions = ProBadgeActions(analytics: analytics, openPricing: { _ in }) + actions.appeared() + actions.upgrade() + actions.dismissed() + let suite = "UpgradeAnalyticsTests.optout.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let controller = CloudWelcomeWindowController( + defaults: defaults, + nextStep: { .upgrade }, + performNextStep: { _, _ in }, + analytics: analytics, + now: { TestAnalytics.fixedNow } + ) + controller.present(over: nil, trigger: .launchAnnouncement) + controller.handleNotNow() + await analytics.drain() + #expect(sink.events.isEmpty) + } +} + +@Suite("Account analytics transitions") +struct AccountAnalyticsStateTests { + @Test("restore identifies without a sign-in success; a started sign-in succeeds once") + func signInLifecycle() { + var state = AccountAnalyticsState() + let restoring = state.observe(userID: nil, isSettling: true, failure: nil) + let restored = state.observe(userID: "u1", isSettling: false, failure: nil) + let signedOut = state.observe(userID: nil, isSettling: false, failure: nil) + state.signInStarted() + let waiting = state.observe(userID: nil, isSettling: true, failure: nil) + let signedIn = state.observe(userID: "u2", isSettling: false, failure: nil) + let unchanged = state.observe(userID: "u2", isSettling: false, failure: nil) + #expect(restoring.isEmpty) + #expect(restored == [.identify(userID: "u1")]) + #expect(signedOut == [.signedOut]) + #expect(waiting.isEmpty) + #expect(signedIn == [.identify(userID: "u2"), .signInSucceeded]) + #expect(unchanged.isEmpty) + } + + @Test("a failure is reported once per started sign-in") + func signInFailure() { + var state = AccountAnalyticsState() + let unstarted = state.observe(userID: nil, isSettling: false, failure: .timedOut) + state.signInStarted() + let failed = state.observe(userID: nil, isSettling: false, failure: .timedOut) + let repeated = state.observe(userID: nil, isSettling: false, failure: .timedOut) + #expect(unstarted.isEmpty) + #expect(failed == [.signInFailed(.timedOut)]) + #expect(repeated.isEmpty) + #expect(AccountAnalyticsState.reason(.unknown) == .network) + #expect(AccountAnalyticsState.reason(nil) == nil) + } +} + +@MainActor +@Suite("Machine create analytics", .serialized) +struct MachineCreateAnalyticsTests { + @Test("a refused create reports its plan limit, and the retry that succeeds reports one create") + func refusalThenRetry() async throws { + let (analytics, sink, ledgerSuite) = TestAnalytics.make() + defer { TestAnalytics.remove(ledgerSuite) } + let launches = MachineCreateCoordinatorTests.LaunchRecorder() + let coordinator = MachineCreateCoordinator( + notifier: { _ in }, + notificationCenter: NotificationCenter(), + analytics: analytics + ) + coordinator.start(MachineCreateCoordinatorTests.newMachineRequest(), launch: launches.launch) + let id = try #require(coordinator.operations.first?.id) + launches.complete(status: 1, output: "Error: limit", refusalCode: "vm_active_limit_exceeded") + #expect(coordinator.retry(id)) + launches.complete(status: 0, output: "OK machine=m1", machineID: "m1") + await analytics.drain() + + let failed = sink.named("cmux_cloud_machine_create_failed") + #expect(failed.count == 1) + #expect(failed.first?["error_code"] == .string("vm_active_limit_exceeded")) + #expect(failed.first?["error_class"] == .string("plan_limit")) + #expect(sink.named("cmux_cloud_plan_limit_hit").compactMap { $0["limit"] } == [AnalyticsValue.string("active_machines")]) + #expect(sink.named("cmux_cloud_machine_create_succeeded").count == 1) + } +} diff --git a/docs/posthog/billing-attribution.md b/docs/posthog/billing-attribution.md index 1f2b1cdff8e1..047351c94082 100644 --- a/docs/posthog/billing-attribution.md +++ b/docs/posthog/billing-attribution.md @@ -55,7 +55,7 @@ All billing events use the Stack user id as `distinct_id` (teams: `stack-team: Send anonymous telemetry** is off, when the + managed `DisableTelemetry` policy is set, under XCTest, or in debug builds + without `CMUX_POSTHOG_ENABLE=1`. In those launches the emitter is disabled + and enqueues nothing. +- Properties are enums, counts, booleans, version strings and duration + buckets. Free-text values collapse to `other`. Events never carry terminal + content, paths, commands, prompts, hostnames, repository names, emails or + error messages. Agent session ids are used only for dedupe, in memory. + +## Identity + +| Concept | Value | +| --- | --- | +| `distinct_id` | Signed out: the install's anonymous PostHog id. Signed in: the Stack user id, the same id the server's billing and `cloud_vm_*` events use. | +| Identity owner | `PostHogIdentityObserver` (#20565) identifies on sign-in or restore and resets on sign-out. Product analytics does not call `identify` itself. After a reset, `PostHogAnalytics` restores the product context super properties. | +| Anonymous to signed in | The first `identify` from an anonymous state sends `$anon_distinct_id`, which merges pre-sign-in activity (first launch, first split, upgrade clicks) into the account. | +| Sign-out | `cmux_signed_out` is captured when sign-out starts, before the reset, so it joins the account. | +| `$groups.stack_team` | Not set by the Mac yet. A PostHog group lasts until `reset()`, so it belongs with the identity observer that owns reset (suggested on #20565). Server billing events already carry it. | + +Feature flags evaluate against the same distinct id, so a signed-in user's +rollout bucket follows their account across devices. + +## Shared properties + +These are sent on every event below. They are also registered as super +properties, so `cmux_daily_active`, `cmux_hourly_active` and +`cmux_cloud_vm_request` carry them too. + +| Property | Values | +| --- | --- | +| `app_version`, `app_build` | bundle version and build | +| `channel` | `stable`, `nightly`, `rc`, `dev` | +| `os_version` | macOS `major.minor` | +| `account_plan` | `free`, `paid`, `unknown` (signed out or not loaded) | +| `is_signed_in` | bool | +| `cloud_enabled` | bool | +| `workspaces_open` | `0`, `1`, `2_3`, `4_7`, `8_15`, `16_plus` (refreshed on app activation and when a workspace opens or closes) | +| `platform` | `cmuxterm` (existing super property) | + +Duration buckets: `lt_1s`, `1s_3s`, `3s_10s`, `10s_30s`, `30s_60s`, `1m_3m`, +`3m_10m`, `10m_60m`, `1h_24h`, `1d_7d`, `gt_7d`. + +## Events + +### Upgrade funnel + +| Event | Fires when | Properties | Answers | +| --- | --- | --- | --- | +| `cmux_upgrade_prompt_shown` | An Upgrade entrypoint becomes visible. Covers the sidebar footer badge, the Cloud welcome card on the Upgrade step, the Cloud panel's nudge, the free-access banner and the create-at-limit screen. At most once per source per launch. | `source` (`ProUpgradeSource` token), `plan` (offered plan) | Click-through rate per surface | +| `cmux_upgrade_entrypoint_opened` | Any Upgrade click (every `ProUpgradeSource`). This is the existing event name; the keys are unchanged plus `destination`. | `source`, `client: mac`, `plan`, `destination` (`pricing_page`, `checkout`) | Upgrade clicks by surface; the first funnel step | +| `cmux_upgrade_badge_dismissed` | The sidebar badge's X | `source` | Badge fatigue | +| `cmux_billing_checkout_started` / `_completed` / `_expired` | Server, see [billing-attribution.md](billing-attribution.md) | `checkout_source` = the same `source` token | Checkout opened, paid, or abandoned | +| `cmux_plan_activated` | The app first sees the signed-in account as paid after it was free, or within 7 days of an upgrade click | `upgrade_source` (last click's source or `none`), `since_upgrade_click` (bucket or `none`) | Plan active in the app; time from click to activation | +| `cmux_plan_deactivated` | Same account went from paid to free | none | Downgrade (churn) | + +### Sidebar Cloud button + +The cloud icon in the sidebar footer, between Help and Upgrade. + +| Event | Fires when | Properties | Answers | +| --- | --- | --- | --- | +| `cmux_cloud_button_shown` | The button becomes visible (Cloud available and the sidebar icon setting on). At most once per launch. | none | Reach, so clicks become a rate | +| `cmux_cloud_button_clicked` | A press | `action` (`open_cloud` when Cloud is on, `introduce_cloud` when it is off or signed out) | How often the button is the way into Cloud | + +An `introduce_cloud` press is followed by `cmux_cloud_welcome_shown` with +`trigger = sidebar_cloud_button`, then the card's button and closed events. +That connects the button to Upgrade, checkout and enable. + +### Cloud welcome card + +| Event | Fires when | Properties | Answers | +| --- | --- | --- | --- | +| `cmux_cloud_welcome_shown` | The "Introducing cmux Cloud" card opens | `trigger` (`launch_announcement`, `new_cloud_workspace`, `new_cloud_machine`, `sidebar_cloud_button`, `debug_menu`), `next_step` (`upgrade`, `enable`, `sign_in`, `open_cloud`) | Reach of the announcement and of the Cloud-off/signed-out gate | +| `cmux_cloud_welcome_button_clicked` | Upgrade, Enable, Sign in, Open Cloud, or Not now (including the card's × and Escape) | `trigger`, `next_step`, `button` | Which choice each trigger leads to | +| `cmux_cloud_welcome_closed` | Exactly once per shown card | `trigger`, `next_step`, `outcome` (a button or `window_closed`), `visible_for` | Outcome and dwell time | + +Enable's result is `cmux_cloud_enable_completed`. Upgrade continues as +`cmux_upgrade_entrypoint_opened` with `source = mac_cloud_welcome`. + +### Account + +| Event | Fires when | Properties | +| --- | --- | --- | +| `cmux_sign_in_started` | The user starts a sign-in (Sign In, pane sign-in, Switch Account) | none | +| `cmux_sign_in_succeeded` | That sign-in completes. A session restore does not count. | none | +| `cmux_sign_in_failed` | That sign-in fails, once per attempt | `reason` (`offline`, `network`, `timed_out`, `server`, `invalid_link`, `browser_unavailable`, `unauthorized`, `rejected`, `cancelled`, `rate_limited`) | +| `cmux_signed_out` | The account signs out (captured before the identity reset) | none | + +### Cloud + +| Event | Fires when | Properties | +| --- | --- | --- | +| `cmux_cloud_enable_completed` | An Enable attempt settles | `result` (`success`, `requires_paid_plan`, `sign_in_required`, `service_unavailable`, `unavailable`, `cancelled`) | +| `cmux_cloud_disabled` | Cloud turned off | none | +| `cmux_cloud_machine_create_started` | The New Machine sheet submits | `memory_gb` (absent for Base setup and forks, which send no size) | +| `cmux_cloud_machine_create_succeeded` | A create finishes, from any launcher | `duration` | +| `cmux_cloud_machine_create_failed` | A create fails | `error_class` (`plan_limit`, `auth`, `network`, `server`, `client`, `cancelled`), `error_code` (server token or `unknown`), `consecutive_failures` | +| `cmux_cloud_machine_deleted` | A machine delete request succeeds | none | +| `cmux_cloud_machine_attached` | A machine's attach endpoint request finishes | `success`, `latency`, `error_class` on failure | +| `cmux_cloud_workspace_created` | New Cloud Workspace finishes | `machine_choice` (`resolved`, `explicit`), `duration` | +| `cmux_cloud_plan_limit_hit` | A Cloud request is refused by a plan limit | `limit` (`requires_paid_plan`, `active_machines`, `machine_memory`, `resource_pool`, `resize_size`, `credits`, `team_billing`), `operation` (`create`, `resize`, `restore`, `fork`, `machine_action`, `other`) | + +The server's `cloud_vm_created` stays the authoritative machine count. The +client events add what the user saw: failures, latency, and the path taken. + +### Activation and engagement + +| Event | Fires when | Properties | +| --- | --- | --- | +| `cmux_activation_milestone_reached` | First time per install | `milestone` (`first_launch`, `first_workspace`, `first_split`, `first_browser_pane`, `first_agent_session`, `first_sign_in`, `first_upgrade_click`, `first_cloud_enable`, `first_cloud_machine`, `first_cloud_workspace`, `first_paid_plan`), `is_new_install`, `days_since_install` | +| `cmux_agent_session_started` | An agent's SessionStart hook. A session that outlived a relaunch is not started again. | `agent` (`claude_code`, `codex`, `opencode`, `gemini`, `cursor`, `pi`, `other`) | +| `cmux_agent_session_ended` | The SessionEnd hook of a session counted as started | `agent` | +| `cmux_feature_used` | First use per UTC day | `feature` (`command_palette`, `files_explorer`, `find`, `vault`, `feed`, `dock`, `cloud_sidebar`, `custom_sidebar`, `devices`, `cmux_ssh`, `browser_pane`, `split`) | +| `cmux_app_updated` | A launch runs a different version than the previous one | `direction` (`upgrade`, `downgrade`), `from_version`, `to_version` | +| `cmux_daily_active` | Existing: once per UTC day | existing properties plus the shared properties above | + +Installs that ran before this catalog shipped get `is_new_install = false` and +never report `first_launch`. Filter activation funnels to +`is_new_install = true`. Every launch opens a terminal, so there is no "first +terminal" milestone; `first_launch` covers it. Uninstall is not observable. +Churn signals are `cmux_cloud_disabled`, `cmux_signed_out`, +`cmux_plan_deactivated`, `consecutive_failures` on +`cmux_cloud_machine_create_failed`, `cmux_app_updated` with `direction = +downgrade`, and absence from `cmux_daily_active`. + +Existing events that are unchanged: `cmux_hourly_active`, +`cmux_cloud_vm_request`, `cmux_vm_size_upgrade_prompted`, `$exception`. + +## Insights to build + +1. **Activation funnel (new installs).** Steps are `cmux_activation_milestone_reached` + filtered to `milestone` in this order: `first_launch`, then + `first_split` or `first_agent_session`, then `first_sign_in`, then + `first_cloud_enable`, then `first_cloud_machine`. Filter + `is_new_install = true`, use a 14-day window, and break down by + `channel`. +2. **Cloud upgrade funnel by source.** Steps: + - `cmux_upgrade_prompt_shown` + - `cmux_upgrade_entrypoint_opened` + - `cmux_billing_checkout_started` + - `cmux_billing_checkout_completed` + - `cmux_plan_activated` + + Break down by `source` on the client steps, `checkout_source` on the + server steps and `upgrade_source` on `cmux_plan_activated`; all three carry + the same `ProUpgradeSource` token. The client and server steps join on the + Stack user id once the app is signed in. +3. **Sidebar Cloud button.** `cmux_cloud_button_clicked` per `cmux_cloud_button_shown`, by + `action`; then the funnel `cmux_cloud_button_clicked` (`action = introduce_cloud`), + `cmux_cloud_welcome_closed` (`trigger = sidebar_cloud_button`), `cmux_upgrade_entrypoint_opened` + (`source = mac_cloud_welcome`), `cmux_billing_checkout_completed`. +4. **Welcome card outcomes.** Count `cmux_cloud_welcome_closed` broken down by + `trigger` × `outcome`, plus the funnel `cmux_cloud_welcome_shown`, then + `cmux_cloud_welcome_button_clicked` (`button = upgrade`), then + `cmux_billing_checkout_completed`. +5. **Week-1 / week-4 retention by activation event.** Retention insight. The + start event is `cmux_activation_milestone_reached` for each `milestone` + (one series per milestone); the return event is `cmux_daily_active`. Use + weekly periods with week 1 and week 4 highlighted, and filter + `is_new_install = true`. +6. **Retention by feature.** Use `cmux_feature_used` broken down by `feature` + as the start event and `cmux_daily_active` as the return event. +7. **Cloud reliability.** `cmux_cloud_machine_create_failed` by `error_class`, + `cmux_cloud_plan_limit_hit` by `limit`, and + `cmux_cloud_machine_attached` by `latency`. +8. **Churn signals.** Weekly counts of `cmux_cloud_disabled`, + `cmux_signed_out`, `cmux_plan_deactivated`, and `cmux_app_updated` + (`direction = downgrade`). + +Known gap: someone who checks out signed out gets an anonymous Stack user at +checkout, so that checkout joins the app's events only through `source` +until they sign in to the app with that account. diff --git a/tests/test_cloud_upgrade_rollout_retirement.py b/tests/test_cloud_upgrade_rollout_retirement.py index 38937cd85493..eb76998d968d 100644 --- a/tests/test_cloud_upgrade_rollout_retirement.py +++ b/tests/test_cloud_upgrade_rollout_retirement.py @@ -35,8 +35,11 @@ def test_upgrade_badge_keeps_local_dismissal_and_shared_presenter(self): badge = source.split("struct ProBadgeView: View {", 1)[1].split("final class ProBadgeDebugWindowController", 1)[0] self.assertIn("if !ProBadgeStyleStore.shared.isDismissed", badge) self.assertIn("ProBadgeStyleStore.shared.isDismissed = true", badge) - self.assertIn("ProUpgradePresenter.present(source: .sidebarBadge)", badge) + self.assertIn("actions.upgrade()", badge) self.assertNotIn("CmuxFeatureFlags", badge) + actions = (ROOT / "Sources/ProBadgeActions.swift").read_text() + self.assertIn("static let source = ProUpgradeSource.sidebarBadge", actions) + self.assertIn("ProUpgradePresenter.present(source: Self.source", actions) def test_cloud_entrypoints_use_policy_and_local_activation(self): for relative in ( From fc5a8630bbc5c79d7a9d5b7f40b23f63774551ac Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 05:29:41 -0700 Subject: [PATCH 050/183] Offer the next plan tier and stop asking Max subscribers to buy Pro (#20603) * test: Max and every higher tier include Pro, and upgrade offers name the next tier Failing first: CmuxPlanTier and BillingPlanDetails.tier do not exist yet. Co-Authored-By: Claude Opus 5.5 * test: upgrade entrypoints offer the next tier and hide for Max Failing first: the menus, palette and Settings card offer Pro to every account, and a Pro account at its machine limit is offered Max, which sells the same machine count. Co-Authored-By: Claude Opus 5.5 * Offer the next plan tier everywhere and never ask Max to buy Pro Add CmuxPlanTier, an ordered Free < Go < Pro < Max tier with includesPro, includesMax and nextUpgrade, and resolve it from the exact plan the billing endpoint already returns (subscriptionPlanId and teamPlanId). The sidebar account menu, sidebar help menu, Help menu, command palette, footer badge and Settings plan row now share one upgrade offer: Free and Go see Upgrade to cmux Pro, Pro sees Upgrade to cmux Max, and Max sees no upgrade. A signed-in account whose plan is still loading or failed to load is offered nothing rather than read as Free. The Settings row and sidebar subtitle name Max and Go instead of calling every paid plan Pro. A Pro account at its machine limit is no longer offered Max, which sells the same machine count; the service sends no upgrade there either. Co-Authored-By: Claude Opus 5.5 * test(web): Free/Go/Pro/Max entitlement matrix and an operator Go grant Failing first: there is no shared plan tier helper, and a Go grant is reported as Pro while its machine limits are Go's. Co-Authored-By: Claude Opus 5.5 * web: one plan tier ordering for every Pro and Max gate Add services/billing/planTier.ts (free < go < pro < max; Team and Founder's Edition count as Pro) and use it for the personal-plan, App Store and plan-change rankings, the VM size upgrade target and the shared-pool upgrade offer, replacing three hand-copied rank tables and the string comparisons. Behaviour is unchanged except that an operator Go grant now reports Go, matching its machine limits, instead of Pro. Co-Authored-By: Claude Opus 5.5 * Address review: keep Go billing, localize Max copy, keep plan across refresh - Go subscribers keep Manage billing beside the Pro offer in Settings. - Read the Max Settings strings from the package catalog that holds them. - A refresh of the same account and team keeps its plan, so one failed fetch no longer hides every upgrade entrypoint. - Keep an operator Go grant reported as Pro: checkout's already-active check would refuse the Pro upgrade it would now be offered. - planTier ignores inherited object keys. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../Sources/CMUXMobileCore/CmuxPlanTier.swift | 87 +++++ .../CmuxPlanTierTests.swift | 99 +++++ .../Sources/CmuxCloud/BillingPlan.swift | 34 +- .../Machines/MachinePlanSnapshot.swift | 13 +- .../BillingPlanClientTests.swift | 27 ++ .../Context/CommandPaletteContextKeys.swift | 3 + Packages/macOS/CmuxSettingsUI/Package.swift | 1 + .../CmuxSettingsUI/Account/AccountFlow.swift | 15 +- .../Account/ProUpgradeCard.swift | 125 ++++-- .../Resources/Localizable.xcstrings | 177 +++++++++ .../ProUpgradeCardModelTests.swift | 44 +++ Resources/Localizable.xcstrings | 366 ++++++++++++++---- Sources/App/CmuxHelpCommands.swift | 6 +- .../Auth/HostAccountFlow+TeamSelection.swift | 17 +- Sources/Auth/HostAccountFlow.swift | 26 +- Sources/ContentView+ProCommandPalette.swift | 22 +- Sources/ContentView.swift | 24 +- Sources/PricingPlansScreen.swift | 30 ++ Sources/SidebarAccountTeamPopover.swift | 5 +- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/AccountUpgradeOfferTests.swift | 68 ++++ cmuxTests/CloudSidebarPolishTests.swift | 7 +- scripts/localization-allowed-omissions.json | 159 ++++++++ web/services/billing/apple/entitlement.ts | 9 +- web/services/billing/planChange.ts | 5 +- web/services/billing/planTier.ts | 45 +++ web/services/billing/pro.ts | 5 +- web/services/vms/entitlements.ts | 11 +- web/services/vms/routeHelpers.ts | 3 +- web/tests/plan-tier-entitlements.test.ts | 143 +++++++ 30 files changed, 1428 insertions(+), 152 deletions(-) create mode 100644 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmuxPlanTier.swift create mode 100644 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmuxPlanTierTests.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ProUpgradeCardModelTests.swift create mode 100644 cmuxTests/AccountUpgradeOfferTests.swift create mode 100644 web/services/billing/planTier.ts create mode 100644 web/tests/plan-tier-entitlements.test.ts diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmuxPlanTier.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmuxPlanTier.swift new file mode 100644 index 000000000000..cef47ceaccfe --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmuxPlanTier.swift @@ -0,0 +1,87 @@ +import Foundation + +/// A cmux subscription tier, ordered so each tier includes every feature of +/// the tiers below it. +/// +/// Gate features on the ordering (``includesPro``, ``includesMax``) rather +/// than on a plan string, so Max and any later higher tier keep everything +/// Pro has. +public enum CmuxPlanTier: Int, Sendable, Comparable, CaseIterable { + /// No paid plan. + case free + /// The entry Cloud plan: a small machine allowance, no Pro features. + case go + /// Pro, and the plans that grant the Pro feature set (Team, Founder's Edition). + case pro + /// Max: everything in Pro plus the larger machine sizes and pool. + case max + + /// Maps a plan ID from the Cloud service, or `nil` for an unknown ID. + /// + /// - Parameter planID: A plan ID such as `free`, `go`, `pro`, `max`, + /// `team`, or `founders`. Case and surrounding whitespace are ignored. + public init?(planID: String?) { + switch planID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "free": self = .free + case "go": self = .go + case "pro", "team", "founders": self = .pro + case "max": self = .max + default: return nil + } + } + + public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } + + /// Whether the tier includes Cloud machines. + public var includesCloud: Bool { self >= .go } + + /// Whether the tier includes every Pro feature. + public var includesPro: Bool { self >= .pro } + + /// Whether the tier includes every Max feature. + public var includesMax: Bool { self >= .max } + + /// The tier an upgrade from this one should offer, or `nil` at the top. + public var nextUpgrade: CmuxPlanTier? { + switch self { + case .free, .go: .pro + case .pro: .max + case .max: nil + } + } + + /// The account's effective tier from a `/api/billing/plan` response: the + /// higher of the personal plan and the active team's plan. + /// + /// - Parameters: + /// - subscriptionPlanID: The exact personal plan (`free`, `go`, `pro`, + /// or `max`), or `nil` from servers that predate it. + /// - planID: The legacy personal plan family, `free` or `pro`. A Max + /// or Go subscriber is reported as `pro` here. + /// - teamPlanID: The active team's plan, if any. + /// - isPro: The legacy paid flag, used only when no plan ID is known. + public static func resolved( + subscriptionPlanID: String?, + planID: String?, + teamPlanID: String?, + isPro: Bool + ) -> CmuxPlanTier { + let personal = CmuxPlanTier(planID: subscriptionPlanID) + ?? CmuxPlanTier(planID: planID) + ?? (isPro ? .pro : .free) + let team = CmuxPlanTier(planID: teamPlanID) ?? .free + return Swift.max(personal, team) + } + + /// The tier an upgrade entrypoint should offer, or `nil` to hide it. + /// + /// - Parameters: + /// - isSignedIn: Whether an account is signed in. + /// - knownTier: The signed-in account's tier, or `nil` while it is + /// loading or after its fetch failed. An unknown tier is never read + /// as Free, so it offers nothing. + public static func upgradeOffer(isSignedIn: Bool, knownTier: CmuxPlanTier?) -> CmuxPlanTier? { + guard isSignedIn else { return .pro } + return knownTier?.nextUpgrade + } +} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmuxPlanTierTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmuxPlanTierTests.swift new file mode 100644 index 000000000000..8cb6e9cb85c0 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmuxPlanTierTests.swift @@ -0,0 +1,99 @@ +import Testing +@testable import CMUXMobileCore + +@Suite struct CmuxPlanTierTests { + @Test func everyHigherTierIncludesTheFeaturesBelowIt() { + #expect(CmuxPlanTier.free.includesCloud == false) + #expect(CmuxPlanTier.free.includesPro == false) + #expect(CmuxPlanTier.free.includesMax == false) + + #expect(CmuxPlanTier.go.includesCloud) + #expect(CmuxPlanTier.go.includesPro == false) + #expect(CmuxPlanTier.go.includesMax == false) + + #expect(CmuxPlanTier.pro.includesCloud) + #expect(CmuxPlanTier.pro.includesPro) + #expect(CmuxPlanTier.pro.includesMax == false) + + #expect(CmuxPlanTier.max.includesCloud) + #expect(CmuxPlanTier.max.includesPro) + #expect(CmuxPlanTier.max.includesMax) + } + + @Test func planIDsMapToTiers() { + #expect(CmuxPlanTier(planID: "free") == .free) + #expect(CmuxPlanTier(planID: "go") == .go) + #expect(CmuxPlanTier(planID: "pro") == .pro) + #expect(CmuxPlanTier(planID: " MAX ") == .max) + // Team and Founder's Edition grant the Pro feature set. + #expect(CmuxPlanTier(planID: "team") == .pro) + #expect(CmuxPlanTier(planID: "founders") == .pro) + #expect(CmuxPlanTier(planID: "enterprise") == nil) + #expect(CmuxPlanTier(planID: "") == nil) + #expect(CmuxPlanTier(planID: nil) == nil) + } + + @Test func nextUpgradeOffersTheTierAbove() { + #expect(CmuxPlanTier.free.nextUpgrade == .pro) + #expect(CmuxPlanTier.go.nextUpgrade == .pro) + #expect(CmuxPlanTier.pro.nextUpgrade == .max) + #expect(CmuxPlanTier.max.nextUpgrade == nil) + } + + @Test func maxSubscriberResolvesToMaxFromTheExactPlan() { + // `/api/billing/plan` reports a Max subscriber as planId "pro" for + // older clients and carries "max" in subscriptionPlanId. + let tier = CmuxPlanTier.resolved( + subscriptionPlanID: "max", + planID: "pro", + teamPlanID: "free", + isPro: true + ) + #expect(tier == .max) + #expect(tier.nextUpgrade == nil) + } + + @Test func goSubscriberIsNotPromotedToProByTheLegacyFlag() { + // The service sets isPro for every paid personal plan, Go included. + let tier = CmuxPlanTier.resolved( + subscriptionPlanID: "go", + planID: "pro", + teamPlanID: "free", + isPro: true + ) + #expect(tier == .go) + #expect(tier.nextUpgrade == .pro) + } + + @Test func paidTeamRaisesAFreePersonalPlan() { + let tier = CmuxPlanTier.resolved( + subscriptionPlanID: "free", + planID: "free", + teamPlanID: "team", + isPro: false + ) + #expect(tier == .pro) + } + + @Test func personalMaxIsKeptOnAFreeOrTeamWorkspace() { + #expect(CmuxPlanTier.resolved(subscriptionPlanID: "max", planID: "pro", teamPlanID: "team", isPro: true) == .max) + } + + @Test func olderServersFallBackToTheLegacyPlanFamily() { + #expect(CmuxPlanTier.resolved(subscriptionPlanID: nil, planID: "pro", teamPlanID: nil, isPro: true) == .pro) + #expect(CmuxPlanTier.resolved(subscriptionPlanID: nil, planID: nil, teamPlanID: nil, isPro: true) == .pro) + #expect(CmuxPlanTier.resolved(subscriptionPlanID: nil, planID: nil, teamPlanID: nil, isPro: false) == .free) + } + + @Test func upgradeOfferFollowsTheKnownTier() { + // Signed out: the entry plan is the useful offer. + #expect(CmuxPlanTier.upgradeOffer(isSignedIn: false, knownTier: nil) == .pro) + // Signed in but the plan is still loading or its fetch failed: never + // read that as Free, so offer nothing rather than the wrong plan. + #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: nil) == nil) + #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: .free) == .pro) + #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: .go) == .pro) + #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: .pro) == .max) + #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: .max) == nil) + } +} diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/BillingPlan.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/BillingPlan.swift index 2efe270035bb..c9e9f9e4e655 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/BillingPlan.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/BillingPlan.swift @@ -1,3 +1,4 @@ +import CMUXMobileCore import Foundation /// The account-scoped billing entitlement used by Cloud access controls. @@ -8,6 +9,8 @@ public struct BillingPlanState: Sendable, Equatable { public let isPro: Bool /// Whether the known account can manage billing through the hosted portal. public let canManageBilling: Bool + /// The known account's effective tier, or `nil` while unknown. + public let tier: CmuxPlanTier? /// Creates an entitlement snapshot. /// @@ -15,18 +18,26 @@ public struct BillingPlanState: Sendable, Equatable { /// - accountID: The account owning the snapshot, or `nil` when unknown. /// - isPro: Whether the account includes Cloud access. /// - canManageBilling: Whether billing can be managed in the hosted portal. - public init(accountID: String?, isPro: Bool, canManageBilling: Bool) { + /// - tier: The effective tier. Defaults to Pro or Free from `isPro` + /// for callers that only know the legacy flag. + public init(accountID: String?, isPro: Bool, canManageBilling: Bool, tier: CmuxPlanTier? = nil) { self.accountID = accountID self.isPro = isPro self.canManageBilling = canManageBilling + self.tier = accountID == nil ? nil : (tier ?? (isPro ? .pro : .free)) } /// An unknown entitlement that must not be treated as a free plan. public static var unknown: Self { Self(accountID: nil, isPro: false, canManageBilling: false) } /// Applies a successful response to the account that requested it. - public func applyingSuccess(for accountID: String, isPro: Bool, canManageBilling: Bool) -> Self { - Self(accountID: accountID, isPro: isPro, canManageBilling: canManageBilling) + public func applyingSuccess( + for accountID: String, + isPro: Bool, + canManageBilling: Bool, + tier: CmuxPlanTier? = nil + ) -> Self { + Self(accountID: accountID, isPro: isPro, canManageBilling: canManageBilling, tier: tier) } /// Retains an existing answer for the same account and clears other answers. @@ -41,11 +52,14 @@ public struct BillingPlanDetails: Sendable, Equatable { public let isPro: Bool /// Whether the account can manage billing through the hosted portal. public let canManageBilling: Bool + /// The effective tier: the higher of the personal and active-team plans. + public let tier: CmuxPlanTier /// Creates decoded billing details. - public init(isPro: Bool, canManageBilling: Bool) { + public init(isPro: Bool, canManageBilling: Bool, tier: CmuxPlanTier? = nil) { self.isPro = isPro self.canManageBilling = canManageBilling + self.tier = tier ?? (isPro ? .pro : .free) } } @@ -94,7 +108,16 @@ public struct BillingPlanClient: Sendable { || paidPlanIDs.contains(decoded.teamPlanId?.lowercased() ?? "") let canManageBilling = decoded.billingManagement == "stripe" || decoded.teamBillingManagement == "stripe" - return BillingPlanDetails(isPro: isPro, canManageBilling: canManageBilling) + // `planId` is "pro" for a Max or Go subscriber; the exact personal + // plan is in `subscriptionPlanId`. Gating on the tier ordering keeps + // Max from being offered the Pro upgrade it already includes. + let tier = CmuxPlanTier.resolved( + subscriptionPlanID: decoded.subscriptionPlanId, + planID: decoded.planId, + teamPlanID: decoded.teamPlanId, + isPro: decoded.isPro == true + ) + return BillingPlanDetails(isPro: isPro, canManageBilling: canManageBilling, tier: tier) } private let session: URLSession @@ -103,6 +126,7 @@ public struct BillingPlanClient: Sendable { let authenticated: Bool? let isPro: Bool? let planId: String? + let subscriptionPlanId: String? let billingManagement: String? let teamPlanId: String? let teamBillingManagement: String? diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift index 8d2af4905261..a224c15509f9 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift @@ -1,4 +1,5 @@ import CmuxCloudResizeCore +import CMUXMobileCore import Foundation /// The active plan: its machine ceiling, tier and free-access window. @@ -45,14 +46,12 @@ public struct MachinePlanSnapshot: Equatable, Sendable { /// upgrade affordance after the server returns `vm_requires_pro`. public var isPaidPlan: Bool { Self.isPaidPlanID(planId) } - /// Whether a plan with more machines exists to upgrade to. Max, Team and - /// Founders are the top of the ladder; everything else (free, Go, Pro, an - /// unknown id) can move up. + /// Whether a plan with more machines exists to upgrade to. Every tier + /// from Pro up (Pro, Team, Founders, Max) sells the same machine count, so + /// only Free, Go and an unknown id can move up. public var hasHigherPlan: Bool { - switch planId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "max", "team", "founders": return false - default: return true - } + guard let tier = CmuxPlanTier(planID: planId) else { return true } + return !tier.includesPro } /// Whether Max is a larger plan for shared vCPU and memory capacity. diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/BillingPlanClientTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/BillingPlanClientTests.swift index 0ca54911af08..6ff2fd564e6d 100644 --- a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/BillingPlanClientTests.swift +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/BillingPlanClientTests.swift @@ -1,3 +1,4 @@ +import CMUXMobileCore import CmuxCloud import Foundation import Testing @@ -31,6 +32,32 @@ struct BillingPlanClientTests { #expect(details.isPro) #expect(details.canManageBilling) } + + // A Max subscriber is reported as planId "pro" for older clients. The + // exact plan must survive decoding, or Max reads as Pro and the app + // offers an upgrade the account already has. + @Test("Max subscriber decodes as the Max tier", arguments: [ + ("free", "free", "free", false, CmuxPlanTier.free), + ("go", "pro", "free", true, CmuxPlanTier.go), + ("pro", "pro", "free", true, CmuxPlanTier.pro), + ("max", "pro", "free", true, CmuxPlanTier.max), + ("free", "free", "team", false, CmuxPlanTier.pro), + ]) + func exactTierDecodes( + subscriptionPlanID: String, + planID: String, + teamPlanID: String, + isPro: Bool, + expected: CmuxPlanTier + ) async throws { + let client = BillingPlanClient(session: BillingPlanStubURLProtocol.session(body: """ + {"authenticated":true,"planId":"\(planID)","subscriptionPlanId":"\(subscriptionPlanID)", + "isPro":\(isPro),"billingManagement":"stripe","teamPlanId":"\(teamPlanID)","teamBillingManagement":"none"} + """)) + + let details = try await client.fetch(from: BillingPlanStubURLProtocol.url, accessToken: "token") + #expect(details.tier == expected) + } } final class BillingPlanStubURLProtocol: URLProtocol, @unchecked Sendable { diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift index 72dd40e44b58..6278ca496993 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift @@ -115,6 +115,9 @@ public struct CommandPaletteContextKeys: Hashable, Sendable { public static let authSignedIn = CommandPaletteContextKeys(rawValue: "auth.signedIn") /// Whether an auth operation is in flight. public static let authWorking = CommandPaletteContextKeys(rawValue: "auth.working") + /// The plan tier the upgrade command offers (`pro` or `max`), absent + /// when the account is on the top tier or its plan is not yet known. + public static let accountUpgradeOffer = CommandPaletteContextKeys(rawValue: "account.upgradeOffer") /// Whether the Computer Use UX and its onboarding actions are available. public static let computerUseUXEnabled = CommandPaletteContextKeys(rawValue: "computerUse.uxEnabled") diff --git a/Packages/macOS/CmuxSettingsUI/Package.swift b/Packages/macOS/CmuxSettingsUI/Package.swift index ce8ab870ceff..59ba0b94a24d 100644 --- a/Packages/macOS/CmuxSettingsUI/Package.swift +++ b/Packages/macOS/CmuxSettingsUI/Package.swift @@ -36,6 +36,7 @@ let package = Package( .testTarget( name: "CmuxSettingsUITests", dependencies: [ + "CMUXMobileCore", "CmuxSettingsUI", "CmuxSettings", .product(name: "CmuxFoundation", package: "CmuxFoundation"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift index 858db468fed6..82aba0dbba21 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift @@ -1,3 +1,4 @@ +import CMUXMobileCore import Foundation /// Host-supplied dependency the package's ``AccountSection`` uses to @@ -74,14 +75,18 @@ public protocol AccountFlow: AccountTeamManagement { /// Opens the hosted Stripe customer portal in the user's default browser. func openBillingPortal() - /// Whether the Pro upgrade row should render. The host backs this with - /// a remotely toggleable feature flag; `true` when flags are - /// unavailable only in explicit dogfood builds whose flag default is on. + /// Whether the Settings plan row should render. The row chooses its + /// copy and actions from ``planTier``. var isProUpgradeAvailable: Bool { get } - /// Whether the current account has an active Pro entitlement. + /// Whether the current account has an active paid Cloud entitlement. var isProActive: Bool { get } + /// The signed-in account's plan tier, or `nil` while it is unknown. + /// Defaults to Pro or Free from ``isProActive`` for hosts that only + /// know the legacy flag. + var planTier: CmuxPlanTier? { get } + /// Whether the current Pro entitlement can be managed through the hosted /// Stripe billing portal. var canManageBilling: Bool { get } @@ -89,4 +94,6 @@ public protocol AccountFlow: AccountTeamManagement { extension AccountFlow { public func prefetchProUpgrade() {} + + public var planTier: CmuxPlanTier? { isProActive ? .pro : nil } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift index cb984b2e0740..d6cb4a93a743 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift @@ -1,3 +1,4 @@ +import CMUXMobileCore import CmuxFoundation import SwiftUI @@ -16,25 +17,32 @@ struct ProUpgradeCard: View { } var body: some View { + let model = ProUpgradeCardModel( + tier: flow?.planTier, + isProActive: flow?.isProActive == true, + canManageBilling: flow?.canManageBilling == true, + isSignedIn: flow?.currentIdentity != nil + ) HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 2) { - Text(String(localized: "settings.account.pro.title", defaultValue: "cmux Pro")) + Text(model.title) .cmuxFont(size: 13, weight: .medium) - Text(subtitleText) + Text(model.subtitle) .cmuxFont(size: 11) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 12) - if shouldShowAction { + ForEach(model.actions, id: \.self) { action in Button { - if flow?.canManageBilling == true { - flow?.openBillingPortal() - } else { + switch action { + case .upgradeToPro, .upgradeToMax: flow?.openProUpgrade() + case .manageBilling: + flow?.openBillingPortal() } } label: { - Text(buttonTitle) + Text(action.title) } .controlSize(.small) } @@ -43,10 +51,9 @@ struct ProUpgradeCard: View { .padding(.vertical, 10) .onHover { hovering in // Warm the pricing destination while the pointer is over the row - // so clicking "Upgrade…" opens an already-loaded page. Managed - // subscribers get the Stripe portal instead, which the host does - // not prewarm. - if hovering, flow?.canManageBilling != true { + // so clicking an upgrade opens an already-loaded page. The + // billing portal is not prewarmed. + if hovering, model.actions.contains(where: \.opensPricing) { flow?.prefetchProUpgrade() } } @@ -54,34 +61,86 @@ struct ProUpgradeCard: View { await flow?.refreshBillingPlan() } } +} + +/// What the Settings plan row says and offers for the account's tier. +/// +/// The upgrade always names the next tier: Free and Go are offered Pro, Pro +/// is offered Max, and Max is offered nothing. +struct ProUpgradeCardModel: Equatable { + enum Action: Hashable { + case upgradeToPro + case upgradeToMax + case manageBilling + + var opensPricing: Bool { self != .manageBilling } + + var title: String { + switch self { + case .upgradeToPro: + String(localized: "settings.account.pro.upgrade", defaultValue: "Upgrade…") + case .upgradeToMax: + String(localized: "settings.account.max.upgrade", defaultValue: "Upgrade to Max…", bundle: .module) + case .manageBilling: + String(localized: "settings.account.pro.manageBilling", defaultValue: "Manage billing") + } + } + } - private var subtitleText: String { - if flow?.isProActive == true { - if flow?.canManageBilling == true { - return String( + let tier: CmuxPlanTier? + let title: String + let subtitle: String + let actions: [Action] + + /// - Parameters: + /// - tier: The account's tier, or `nil` while unknown. + /// - isProActive: The legacy paid flag, used when `tier` is `nil`. + /// - canManageBilling: Whether the hosted billing portal applies. + /// - isSignedIn: Whether an account is signed in. A signed-in account + /// whose plan is still unknown is offered nothing, so a Max + /// subscriber never sees a Pro upgrade while the plan loads. + init(tier: CmuxPlanTier?, isProActive: Bool, canManageBilling: Bool, isSignedIn: Bool = false) { + // A host that only knows the legacy flag still reads as Pro. + let tier = tier ?? (isProActive ? .pro : nil) + self.tier = tier + switch tier { + case .max: + title = String(localized: "settings.account.max.title", defaultValue: "cmux Max", bundle: .module) + subtitle = String( + localized: "settings.account.max.activeSubtitle", + defaultValue: "Your Max subscription is active. It includes everything in Pro.", + bundle: .module + ) + actions = canManageBilling ? [.manageBilling] : [] + case .pro: + title = String(localized: "settings.account.pro.title", defaultValue: "cmux Pro") + if canManageBilling { + subtitle = String( localized: "settings.account.pro.activeSubtitle", defaultValue: "Your Pro subscription is active. Manage billing or cancel in Stripe." ) + actions = [.upgradeToMax, .manageBilling] + } else { + subtitle = String( + localized: "settings.account.pro.externalSubtitle", + defaultValue: "Your subscription is managed by our previous billing system. Contact support to make changes." + ) + actions = [.upgradeToMax] } - return String( - localized: "settings.account.pro.externalSubtitle", - defaultValue: "Your subscription is managed by our previous billing system. Contact support to make changes." + case .free, .go, nil: + title = String(localized: "settings.account.pro.title", defaultValue: "cmux Pro") + subtitle = String( + localized: "settings.account.pro.subtitle", + defaultValue: "Up to 5 Cloud VMs sharing 20 vCPUs and 40 GB RAM, plus the iOS app. $50/month." ) + if tier == nil && isSignedIn { + actions = [] + } else if tier == .go && canManageBilling { + // A Go subscriber still manages or cancels Go in the portal. + actions = [.upgradeToPro, .manageBilling] + } else { + actions = [.upgradeToPro] + } } - return String( - localized: "settings.account.pro.subtitle", - defaultValue: "Up to 5 Cloud VMs sharing 20 vCPUs and 40 GB RAM, plus the iOS app. $50/month." - ) - } - - private var buttonTitle: String { - if flow?.canManageBilling == true { - return String(localized: "settings.account.pro.manageBilling", defaultValue: "Manage billing") - } - return String(localized: "settings.account.pro.upgrade", defaultValue: "Upgrade…") - } - - private var shouldShowAction: Bool { - flow?.isProActive != true || flow?.canManageBilling == true } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings index 508d320a23d6..7eec89c0a676 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings @@ -15313,6 +15313,183 @@ } } } + }, + "settings.account.max.activeSubtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Max subscription is active. It includes everything in Pro." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اشتراكك في Max نشط. يتضمن كل ما في Pro." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Max-Abonnement ist aktiv. Es enthält alles aus Pro." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tu suscripción Max está activa. Incluye todo lo de Pro." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre abonnement Max est actif. Il inclut tout ce que contient Pro." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Maxサブスクリプションは有効です。Proのすべての機能が含まれます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Max 구독이 활성화되어 있습니다. Pro의 모든 기능이 포함됩니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的 Max 订阅已生效,包含 Pro 的全部功能。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的 Max 訂閱已啟用,包含 Pro 的所有功能。" + } + } + } + }, + "settings.account.max.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux Max" + } + } + } + }, + "settings.account.max.upgrade": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to Max…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الترقية إلى Max…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Max upgraden…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar a Max…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Passer à Max…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Maxにアップグレード…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Max로 업그레이드…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "升级到 Max…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "升級至 Max…" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ProUpgradeCardModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ProUpgradeCardModelTests.swift new file mode 100644 index 000000000000..2cbad0094492 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ProUpgradeCardModelTests.swift @@ -0,0 +1,44 @@ +import CMUXMobileCore +import Testing +@testable import CmuxSettingsUI + +/// The Settings plan row offers the next tier and never asks a Max +/// subscriber to upgrade to Pro. +@Suite struct ProUpgradeCardModelTests { + @Test func freeAndGoAreOfferedPro() { + for tier in [CmuxPlanTier.free, .go] { + let model = ProUpgradeCardModel(tier: tier, isProActive: tier == .go, canManageBilling: false, isSignedIn: true) + #expect(model.actions == [.upgradeToPro], "\(tier)") + } + } + + @Test func managedGoKeepsBillingBesideTheProOffer() { + let model = ProUpgradeCardModel(tier: .go, isProActive: true, canManageBilling: true, isSignedIn: true) + #expect(model.actions == [.upgradeToPro, .manageBilling]) + } + + @Test func proIsOfferedMaxAndBilling() { + let model = ProUpgradeCardModel(tier: .pro, isProActive: true, canManageBilling: true, isSignedIn: true) + #expect(model.actions == [.upgradeToMax, .manageBilling]) + #expect(!model.actions.contains(.upgradeToPro)) + } + + @Test func maxIsOfferedNoUpgrade() { + let managed = ProUpgradeCardModel(tier: .max, isProActive: true, canManageBilling: true, isSignedIn: true) + #expect(managed.actions == [.manageBilling]) + #expect(managed.title == String(localized: "settings.account.max.title", defaultValue: "cmux Max")) + + let external = ProUpgradeCardModel(tier: .max, isProActive: true, canManageBilling: false, isSignedIn: true) + #expect(external.actions.isEmpty) + } + + @Test func unknownPlanForASignedInAccountOffersNothing() { + let model = ProUpgradeCardModel(tier: nil, isProActive: false, canManageBilling: false, isSignedIn: true) + #expect(model.actions.isEmpty) + } + + @Test func signedOutIsOfferedPro() { + let model = ProUpgradeCardModel(tier: nil, isProActive: false, canManageBilling: false, isSignedIn: false) + #expect(model.actions == [.upgradeToPro]) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1b3b8c2c270f..ac0be8b7225f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -398140,71 +398140,6 @@ } } }, - "sidebar.cloud.button": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "السحابة" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nube" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Cloud" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "クラウド" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "클라우드" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Хмара" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "云" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "雲端" - } - } - } - }, "sidebar.help.button": { "extractionState": "manual", "localizations": { @@ -653355,6 +653290,307 @@ } } } + }, + "sidebar.account.teamWithGoPlan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Go" + } + } + } + }, + "sidebar.account.teamWithMaxPlan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Max" + } + } + } + }, + "command.pro.upgradeToMax.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to cmux Max" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الترقية إلى cmux Max" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Upgrade auf cmux Max" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar a cmux Max" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Passer à cmux Max" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux Maxにアップグレード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux Max로 업그레이드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "升级到 cmux Max" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "升級至 cmux Max" + } + } + } + }, + "menu.help.upgradeToMax": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to cmux Max…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الترقية إلى cmux Max…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf cmux Max upgraden…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar a cmux Max…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Passer à cmux Max…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux Maxにアップグレード…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux Max로 업그레이드…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "升级到 cmux Max…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "升級至 cmux Max…" + } + } + } + }, + "sidebar.cloud.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السحابة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nube" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cloud" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클라우드" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Хмара" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "云" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雲端" + } + } + } } }, "version": "1.0" diff --git a/Sources/App/CmuxHelpCommands.swift b/Sources/App/CmuxHelpCommands.swift index 1fbabd1aa644..c15392b4d43b 100644 --- a/Sources/App/CmuxHelpCommands.swift +++ b/Sources/App/CmuxHelpCommands.swift @@ -27,8 +27,10 @@ extension cmuxApp { helpResourceButton(.githubIssues) helpResourceButton(.discord) - Button(String(localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…")) { - ProUpgradePresenter.present(source: .helpMenu) + if let offer = ProUpgradePresenter.currentUpgradeOffer { + Button(ProUpgradePresenter.upgradeMenuTitle(for: offer)) { + ProUpgradePresenter.present(source: .helpMenu) + } } #if DEBUG Button(String(localized: "menu.help.previewNativePricing", defaultValue: "Preview Native Pro Pricing…")) { diff --git a/Sources/Auth/HostAccountFlow+TeamSelection.swift b/Sources/Auth/HostAccountFlow+TeamSelection.swift index f93b66e79e12..af9319757da7 100644 --- a/Sources/Auth/HostAccountFlow+TeamSelection.swift +++ b/Sources/Auth/HostAccountFlow+TeamSelection.swift @@ -1,4 +1,5 @@ import CMUXAuthCore +import CMUXMobileCore import CmuxAuthRuntime import CmuxSettingsUI import Foundation @@ -96,12 +97,24 @@ extension HostAccountFlow { _ = teamObservationRevision let team = activeTeamDisplayName ?? String(localized: "sidebar.account.noTeam", defaultValue: "No team") - if isProActive { + switch planTier { + case .max: + return String( + format: String(localized: "sidebar.account.teamWithMaxPlan", defaultValue: "%1$@ · Max"), + team + ) + case .pro: return String( format: String(localized: "sidebar.account.teamWithPlan", defaultValue: "%1$@ · Pro"), team ) + case .go: + return String( + format: String(localized: "sidebar.account.teamWithGoPlan", defaultValue: "%1$@ · Go"), + team + ) + case .free, nil: + return team } - return team } } diff --git a/Sources/Auth/HostAccountFlow.swift b/Sources/Auth/HostAccountFlow.swift index d41e91179256..13023db66cf7 100644 --- a/Sources/Auth/HostAccountFlow.swift +++ b/Sources/Auth/HostAccountFlow.swift @@ -2,6 +2,7 @@ import CmuxCloud import CmuxProductAnalytics import AppKit import CMUXAuthCore +import CMUXMobileCore import CmuxAuthRuntime import CmuxSettingsUI import Foundation @@ -19,15 +20,25 @@ import Observation final class HostAccountFlow: AccountFlow, AccountSignInFlow { let coordinator: AuthCoordinator private let browserSignIn: HostBrowserSignInFlow - var isProUpgradeAvailable: Bool { true } private(set) var billingPlanState = BillingPlanState.unknown var isProActive: Bool { billingPlanState.isPro } + /// The signed-in account's tier, or nil while it is unknown. + var planTier: CmuxPlanTier? { hasLoadedBillingPlan ? billingPlanState.tier : nil } + /// The tier every upgrade entrypoint offers, or nil to hide them. + var upgradeOffer: CmuxPlanTier? { + CmuxPlanTier.upgradeOffer(isSignedIn: currentIdentity != nil, knownTier: planTier) + } + /// The Settings plan row renders for every tier; it picks its own copy + /// and actions from ``planTier``. Menus gate on ``upgradeOffer`` instead. + var isProUpgradeAvailable: Bool { true } var canManageBilling: Bool { billingPlanState.canManageBilling } /// The account whose plan is known, or nil while the plan is unknown. var billingPlanIdentityID: String? { billingPlanState.accountID } /// The most recent plan request. Only it may write, so an older request /// that finishes late cannot overwrite a newer answer. @ObservationIgnored private var billingPlanRequestID: UUID? + /// The account and team the current plan answer belongs to. + @ObservationIgnored private var billingPlanScope: String? /// Whether `isProActive` is a real answer for the signed-in account. var hasLoadedBillingPlan: Bool { guard let billingPlanIdentityID else { return false } @@ -276,8 +287,14 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { billingPlanRequestID = requestID // Do not project the previous team/account's entitlement while this // request is in flight. Unknown keeps Cloud enabled until a verified - // response arrives and avoids a false Free/Upgrade state. - billingPlanState = .unknown + // response arrives and avoids a false Free/Upgrade state. A refresh + // of the same account and team keeps its answer, so a failed refresh + // does not hide every upgrade entrypoint until the next change. + let scope = "\(identityID):\(confirmedTeamID ?? "personal")" + if billingPlanScope != scope { + billingPlanState = .unknown + billingPlanScope = scope + } let tokens = try? await coordinator.currentTokens() do { @@ -290,7 +307,8 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { billingPlanState = billingPlanState.applyingSuccess( for: identityID, isPro: details.isPro, - canManageBilling: details.canManageBilling + canManageBilling: details.canManageBilling, + tier: details.tier ) analytics.notePlan(accountID: identityID, isPaid: details.isPro) return true diff --git a/Sources/ContentView+ProCommandPalette.swift b/Sources/ContentView+ProCommandPalette.swift index 2beb451894b2..2aa335919e3a 100644 --- a/Sources/ContentView+ProCommandPalette.swift +++ b/Sources/ContentView+ProCommandPalette.swift @@ -1,11 +1,26 @@ import CmuxCommandPalette import AppKit +import CMUXMobileCore import Foundation extension ContentView { static let commandPaletteProUpgradeCommandId = "palette.pro.upgrade" static let commandPaletteProWelcomeChecklistCommandId = "palette.pro.welcomeChecklist" + /// Encodes an upgrade offer for ``CommandPaletteContextKeys/accountUpgradeOffer``. + nonisolated static func commandPaletteUpgradeOfferValue(_ tier: CmuxPlanTier) -> String { + tier == .max ? "max" : "pro" + } + + /// Decodes ``CommandPaletteContextKeys/accountUpgradeOffer``; nil hides the command. + nonisolated static func commandPaletteUpgradeOffer(_ context: CommandPaletteContextSnapshot) -> CmuxPlanTier? { + switch context.string(CommandPaletteContextKeys.accountUpgradeOffer) { + case "max": .max + case "pro": .pro + default: nil + } + } + static func commandPaletteProCommandContributions() -> [CommandPaletteCommandContribution] { func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { { _ in value } @@ -20,9 +35,12 @@ extension ContentView { ), CommandPaletteCommandContribution( commandId: commandPaletteProUpgradeCommandId, - title: constant(String(localized: "command.pro.upgrade.title", defaultValue: "Upgrade to cmux Pro")), + title: { context in + ProUpgradePresenter.upgradeCommandTitle(for: commandPaletteUpgradeOffer(context) ?? .pro) + }, subtitle: constant(String(localized: "command.auth.subtitle", defaultValue: "Account")), - keywords: ["pro", "upgrade", "subscription", "billing", "plan", "pricing", "cloud", "purchase", "buy"] + keywords: ["pro", "max", "upgrade", "subscription", "billing", "plan", "pricing", "cloud", "purchase", "buy"], + when: { context in commandPaletteUpgradeOffer(context) != nil } ), ] } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 88152c08c3d1..bb4da4b8627d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7334,6 +7334,10 @@ struct ContentView: View { snapshot.setBool(CommandPaletteContextKeys.authSignedIn, auth.accountFlow.isAuthenticated) snapshot.setBool(CommandPaletteContextKeys.authWorking, auth.accountFlow.isWorkingOnAuth) } + snapshot.setString( + CommandPaletteContextKeys.accountUpgradeOffer, + ProUpgradePresenter.currentUpgradeOffer.map(Self.commandPaletteUpgradeOfferValue) + ) let focusedDock = AppDelegate.shared?.focusedDockStoreForShortcut( preferredWindow: observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow @@ -15727,7 +15731,7 @@ struct SidebarFooterButtons: View { private var billingPlanRefreshID: String? { guard let flow = accountFlow, let accountID = flow.currentIdentity?.id else { return nil } - return "\(accountID):\(flow.confirmedTeamID ?? "personal"):\(flow.isProUpgradeAvailable):\(flow.isAuthenticated)" + return "\(accountID):\(flow.confirmedTeamID ?? "personal"):\(flow.isAuthenticated)" } private var isProStatusKnownForUpgrade: Bool { @@ -15766,7 +15770,7 @@ struct SidebarFooterButtons: View { } if shows(.upgrade), SidebarFooterPresentationPolicy.isUpgradeVisible( - isProActive: accountFlow?.isProActive == true, + isProActive: accountFlow?.planTier?.includesPro == true, isProStatusKnown: isProStatusKnownForUpgrade ) { SidebarProBadge() @@ -15895,13 +15899,15 @@ private struct SidebarHelpMenuButton: View { accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", isExternalLink: false ) - helpOptionButton( - title: String(localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…"), - action: .upgrade, - accessibilityIdentifier: "SidebarHelpMenuOptionUpgrade", - isExternalLink: false, - trailingSystemImage: "sparkles" - ) + if let offer = ProUpgradePresenter.currentUpgradeOffer { + helpOptionButton( + title: ProUpgradePresenter.upgradeMenuTitle(for: offer), + action: .upgrade, + accessibilityIdentifier: "SidebarHelpMenuOptionUpgrade", + isExternalLink: false, + trailingSystemImage: "sparkles" + ) + } helpOptionButton( title: String(localized: "menu.app.settings", defaultValue: "Settings…"), action: .settings, diff --git a/Sources/PricingPlansScreen.swift b/Sources/PricingPlansScreen.swift index bd35c3e6b1ce..499ea1e9a892 100644 --- a/Sources/PricingPlansScreen.swift +++ b/Sources/PricingPlansScreen.swift @@ -1,6 +1,7 @@ import CmuxCloud import CmuxProductAnalytics import AppKit +import CMUXMobileCore import Bonsplit import Foundation import SwiftUI @@ -15,6 +16,35 @@ enum ProUpgradePresenter { @MainActor private static var workspaceReuseState = ProUpgradeWorkspaceReuseState() + /// Menu title for an upgrade entrypoint that offers `tier`, the next + /// tier above the account's plan (``CmuxPlanTier/upgradeOffer(isSignedIn:knownTier:)``). + nonisolated static func upgradeMenuTitle(for tier: CmuxPlanTier) -> String { + switch tier { + case .max: + String(localized: "menu.help.upgradeToMax", defaultValue: "Upgrade to cmux Max…") + case .free, .go, .pro: + String(localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…") + } + } + + /// Command palette title for the same offer, without the ellipsis. + nonisolated static func upgradeCommandTitle(for tier: CmuxPlanTier) -> String { + switch tier { + case .max: + String(localized: "command.pro.upgradeToMax.title", defaultValue: "Upgrade to cmux Max") + case .free, .go, .pro: + String(localized: "command.pro.upgrade.title", defaultValue: "Upgrade to cmux Pro") + } + } + + /// The offer every upgrade entrypoint shows, or nil when the account is + /// already on the top tier or its plan is not yet known. + @MainActor + static var currentUpgradeOffer: CmuxPlanTier? { + guard let flow = AppDelegate.shared?.auth?.accountFlow else { return .pro } + return flow.upgradeOffer + } + /// Records the click as `cmux_upgrade_entrypoint_opened` (the funnel's /// first client step) and opens pricing. /// diff --git a/Sources/SidebarAccountTeamPopover.swift b/Sources/SidebarAccountTeamPopover.swift index 97642b06b92a..476a6a301f8b 100644 --- a/Sources/SidebarAccountTeamPopover.swift +++ b/Sources/SidebarAccountTeamPopover.swift @@ -1,4 +1,5 @@ import AppKit +import CMUXMobileCore import CmuxAppKitSupportUI import CmuxSettingsUI import SwiftUI @@ -152,13 +153,13 @@ private struct SidebarAccountPopover: View { } .accessibilityIdentifier("SidebarAccountSignInButton") } - if accountFlow?.isProUpgradeAvailable == true { + if let offer = accountFlow?.upgradeOffer { if accountFlow?.currentIdentity == nil { Divider() .padding(.vertical, 4) } accountMenuRow( - title: String(localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…"), + title: ProUpgradePresenter.upgradeMenuTitle(for: offer), systemImage: "sparkles" ) { dismiss() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2f8e160264c8..343dfd888392 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ A5C017000000000000000009 /* AccountSignInPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C01700000000000000000A /* AccountSignInPanel.swift */; }; A5C01700000000000000000B /* AccountSignInPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C01700000000000000000C /* AccountSignInPanelView.swift */; }; A5C017000000000000000007 /* AccountSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C017000000000000000008 /* AccountSignInView.swift */; }; + 73554A9CA0643B8F0DDCA371 /* AccountUpgradeOfferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA69A8DB9EBB531DD11DFE5 /* AccountUpgradeOfferTests.swift */; }; D0B102020000000000000001 /* AcknowledgmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B103020000000000000001 /* AcknowledgmentsView.swift */; }; D0B102010000000000000001 /* AcknowledgmentsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B103010000000000000001 /* AcknowledgmentsWindowController.swift */; }; A9E020000000000000000006 /* agent-session-react in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000006 /* agent-session-react */; }; @@ -4766,6 +4767,7 @@ A5C01700000000000000000A /* AccountSignInPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSignInPanel.swift; sourceTree = ""; }; A5C01700000000000000000C /* AccountSignInPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSignInPanelView.swift; sourceTree = ""; }; A5C017000000000000000008 /* AccountSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSignInView.swift; sourceTree = ""; }; + FBA69A8DB9EBB531DD11DFE5 /* AccountUpgradeOfferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountUpgradeOfferTests.swift"; sourceTree = ""; }; D0B103020000000000000001 /* AcknowledgmentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/About/AcknowledgmentsView.swift; sourceTree = ""; }; D0B103010000000000000001 /* AcknowledgmentsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/About/AcknowledgmentsWindowController.swift; sourceTree = ""; }; A9E010000000000000000006 /* agent-session-react */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-react"; sourceTree = ""; }; @@ -13912,6 +13914,7 @@ 6F6A2754CDC4A872BCEFD3EA /* AgentAcknowledgedTurnNotificationTests.swift */, 2B8ACF56AB5811EEC9D0203F /* SidebarCloudButtonActionTests.swift */, 62038FEBE991E6F6022BD52F /* SSHTuiAttachRetryTests.swift */, + FBA69A8DB9EBB531DD11DFE5 /* AccountUpgradeOfferTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -17824,6 +17827,7 @@ 49A07E13506067E94820A5CE /* AccountMacDirectoryClientTests.swift in Sources */, 9707E1A30E96E4DDD19BE85C /* AccountMacDirectoryTests.swift in Sources */, A5C01700000000000000000F /* AccountSignInModelTests.swift in Sources */, + 73554A9CA0643B8F0DDCA371 /* AccountUpgradeOfferTests.swift in Sources */, 2BA53AD928B8026FE8A0D3A9 /* AgentAcknowledgedTurnNotificationTests.swift in Sources */, 8EA083841777B442E3FA7E72 /* AgentAutoResumeScreenTests.swift in Sources */, 5FAC71D5AC71D5AC71D50001 /* AgentChatChildRunTests.swift in Sources */, diff --git a/cmuxTests/AccountUpgradeOfferTests.swift b/cmuxTests/AccountUpgradeOfferTests.swift new file mode 100644 index 000000000000..404bbdf34d9e --- /dev/null +++ b/cmuxTests/AccountUpgradeOfferTests.swift @@ -0,0 +1,68 @@ +import CMUXMobileCore +import CmuxCommandPalette +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Every upgrade entrypoint (account menu, sidebar help menu, Help menu, +/// command palette) offers the tier above the account's plan: Free and Go +/// see Pro, Pro sees Max, and Max sees nothing. +@MainActor +struct AccountUpgradeOfferTests { + @Test func menuTitlesNameTheOfferedTier() { + #expect(ProUpgradePresenter.upgradeMenuTitle(for: .pro) == String( + localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…" + )) + #expect(ProUpgradePresenter.upgradeMenuTitle(for: .max) == String( + localized: "menu.help.upgradeToMax", defaultValue: "Upgrade to cmux Max…" + )) + } + + @Test(arguments: [ + (CmuxPlanTier.free, Optional(CmuxPlanTier.pro)), + (CmuxPlanTier.go, Optional(CmuxPlanTier.pro)), + (CmuxPlanTier.pro, Optional(CmuxPlanTier.max)), + (CmuxPlanTier.max, nil), + ]) + func paletteCommandFollowsTheTier(tier: CmuxPlanTier, expected: CmuxPlanTier?) throws { + let command = try #require(ContentView.commandPaletteProCommandContributions().first { + $0.commandId == ContentView.commandPaletteProUpgradeCommandId + }) + var context = CommandPaletteContextSnapshot() + let offer = CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: tier) + context.setString( + CommandPaletteContextKeys.accountUpgradeOffer, + offer.map(ContentView.commandPaletteUpgradeOfferValue) + ) + #expect(command.when(context) == (expected != nil)) + if let expected { + #expect(command.title(context) == ProUpgradePresenter.upgradeCommandTitle(for: expected)) + } + } + + @Test func paletteHidesTheUpgradeWhileThePlanIsUnknown() throws { + let command = try #require(ContentView.commandPaletteProCommandContributions().first { + $0.commandId == ContentView.commandPaletteProUpgradeCommandId + }) + var context = CommandPaletteContextSnapshot() + context.setString( + CommandPaletteContextKeys.accountUpgradeOffer, + CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: nil).map(ContentView.commandPaletteUpgradeOfferValue) + ) + #expect(!command.when(context)) + } + + @Test func footerBadgeIsHiddenForProAndMax() { + for tier in CmuxPlanTier.allCases { + #expect( + SidebarFooterPresentationPolicy.isUpgradeVisible(isProActive: tier.includesPro) == !tier.includesPro, + "\(tier)" + ) + } + } +} diff --git a/cmuxTests/CloudSidebarPolishTests.swift b/cmuxTests/CloudSidebarPolishTests.swift index 6148b92e5caa..69a5aa5ee7f0 100644 --- a/cmuxTests/CloudSidebarPolishTests.swift +++ b/cmuxTests/CloudSidebarPolishTests.swift @@ -25,10 +25,13 @@ struct CloudSidebarPolishTests { // Actions follow the carried code, never the display text. operation.refusalCode = nil #expect(!operation.hitMachineLimit) - for plan in ["free", "go", "pro", "unknown"] { + for plan in ["free", "go", "unknown"] { #expect(MachinePlanSnapshot(activeCount: 5, planId: plan).hasHigherPlan, "\(plan)") } - for plan in ["max", "Team", " founders "] { + // Pro already has the most machines any plan sells: Max raises the + // machine sizes and shared pool, not the machine count, and the + // service sends no upgrade for a paid plan at its machine limit. + for plan in ["pro", "max", "Team", " founders "] { #expect(!MachinePlanSnapshot(activeCount: 5, planId: plan).hasHigherPlan, "\(plan)") } } diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index 52fc4e4d16bf..7615d9381236 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -4981,5 +4981,164 @@ "globalSearch.agent.claude": { "class": "brand", "source": "Claude" + }, + "settings.account.max.title": { + "source": "cmux Max", + "identityLocales": { + "de": { + "reason": "Product and plan name cmux Max is a brand name and is not translated.", + "values": [ + "cmux Max" + ] + }, + "fr": { + "reason": "Product and plan name cmux Max is a brand name and is not translated.", + "values": [ + "cmux Max" + ] + }, + "ar": { + "reason": "Product and plan name cmux Max is a brand name and is not translated.", + "values": [ + "cmux Max" + ] + }, + "es": { + "reason": "Product and plan name cmux Max is a brand name and is not translated.", + "values": [ + "cmux Max" + ] + }, + "zh-Hant": { + "reason": "Product and plan name cmux Max is a brand name and is not translated.", + "values": [ + "cmux Max" + ] + }, + "zh-Hans": { + "reason": "Product and plan name cmux Max is a brand name and is not translated.", + "values": [ + "cmux Max" + ] + }, + "ko": { + "reason": "Product and plan name cmux Max is a brand name and is not translated.", + "values": [ + "cmux Max" + ] + }, + "ja": { + "reason": "Product and plan name cmux Max is a brand name and is not translated.", + "values": [ + "cmux Max" + ] + } + } + }, + "sidebar.account.teamWithMaxPlan": { + "source": "%1$@ · Max", + "identityLocales": { + "de": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Max" + ] + }, + "fr": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Max" + ] + }, + "ar": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Max" + ] + }, + "es": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Max" + ] + }, + "zh-Hant": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Max" + ] + }, + "zh-Hans": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Max" + ] + }, + "ko": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Max" + ] + }, + "ja": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Max" + ] + } + } + }, + "sidebar.account.teamWithGoPlan": { + "source": "%1$@ · Go", + "identityLocales": { + "de": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Go" + ] + }, + "fr": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Go" + ] + }, + "ar": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Go" + ] + }, + "es": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Go" + ] + }, + "zh-Hant": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Go" + ] + }, + "zh-Hans": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Go" + ] + }, + "ko": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Go" + ] + }, + "ja": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Go" + ] + } + } } } diff --git a/web/services/billing/apple/entitlement.ts b/web/services/billing/apple/entitlement.ts index e5626a569c87..10b636902145 100644 --- a/web/services/billing/apple/entitlement.ts +++ b/web/services/billing/apple/entitlement.ts @@ -6,8 +6,12 @@ import { eq } from "drizzle-orm"; import { cloudDb } from "../../../db/client"; import { appleSubscriptions, type AppleSubscriptionStatus } from "../../../db/schema"; import type { PersonalPlanId } from "../pro"; +import { planTierRank } from "../planTier"; import { appleEnvironmentGrantsEntitlement } from "./config"; +/** Personal plans sold in the App Store; the best grant wins by tier. */ +const APPLE_PLAN_IDS: ReadonlySet = new Set(["go", "pro", "max"]); + type Env = Record; export type AppleEntitlementRow = { @@ -21,7 +25,6 @@ export type AppleEntitlementRow = { readonly autoRenewEnabled?: boolean | null; }; -const PLAN_RANK: Readonly> = { go: 1, pro: 2, max: 3 }; function isAfter(value: Date | null, now: Date): boolean { return value !== null && value.getTime() > now.getTime(); @@ -38,7 +41,7 @@ export function appleSubscriptionGrantsAccess( now: Date = new Date(), env: Env = process.env, ): boolean { - if (!(row.planId in PLAN_RANK)) return false; + if (!APPLE_PLAN_IDS.has(row.planId)) return false; if (!appleEnvironmentGrantsEntitlement(row, env)) return false; switch (row.status) { case "active": @@ -61,7 +64,7 @@ export function bestAppleEntitlement( let best: Row | null = null; for (const row of rows) { if (!appleSubscriptionGrantsAccess(row, now, env)) continue; - if (!best || PLAN_RANK[row.planId]! > PLAN_RANK[best.planId]!) best = row; + if (!best || planTierRank(row.planId) > planTierRank(best.planId)) best = row; } return best; } diff --git a/web/services/billing/planChange.ts b/web/services/billing/planChange.ts index 922dd25c2846..eb6211d7c83e 100644 --- a/web/services/billing/planChange.ts +++ b/web/services/billing/planChange.ts @@ -6,6 +6,7 @@ import type Stripe from "stripe"; import { MAX_PRICING_USD, PRO_PRICING_USD } from "./plans"; +import { planTierRank } from "./planTier"; import { stripeBillingStatusForUser, type StripeBillingStatus } from "./pro"; import { applySubscriptionUpdate } from "./purchase"; import { resolveMaxPrice, resolveProPrice, stripe } from "./stripe"; @@ -13,7 +14,6 @@ import { personalPlanIdForSubscription } from "./subscriptionPlan"; import { planItem } from "./subscriptionItems"; export type SwitchTargetPlan = "pro" | "max"; -type PaidPersonalPlan = "go" | "pro" | "max"; export type PlanChangeRefusal = | "no_subscription" @@ -63,7 +63,6 @@ const defaultDependencies: PlanChangeDependencies = { syncSubscription: (subscription) => applySubscriptionUpdate(subscription), }; -const PLAN_RANK: Readonly> = { go: 1, pro: 2, max: 3 }; const MONTHLY_AMOUNT: Readonly> = { pro: PRO_PRICING_USD.month.billedAmount * 100, max: MAX_PRICING_USD.month.billedAmount * 100, @@ -106,7 +105,7 @@ async function resolveSwitch( subscription, itemId: item.id, price: await deps.priceFor(target), - direction: PLAN_RANK[target] > PLAN_RANK[current] ? "upgrade" : "downgrade", + direction: planTierRank(target) > planTierRank(current) ? "upgrade" : "downgrade", periodEnd: typeof item.current_period_end === "number" ? item.current_period_end : null, }; } diff --git a/web/services/billing/planTier.ts b/web/services/billing/planTier.ts new file mode 100644 index 000000000000..24820bf8e946 --- /dev/null +++ b/web/services/billing/planTier.ts @@ -0,0 +1,45 @@ +/** + * The ordered cmux plan tiers. Each tier includes every feature of the tiers + * below it, so gate on the ordering (`planIncludes(planId, "pro")`) rather + * than on a plan string: Max, and any later higher tier, then keeps + * everything Pro has. + * + * Team and Founder's Edition grant the Pro feature set. Their own machine + * allowance (Team seats) is applied by the VM entitlements, not here. + */ +export type PlanTier = "free" | "go" | "pro" | "max"; + +const PLAN_TIER_RANK: Readonly> = { free: 0, go: 1, pro: 2, max: 3 }; + +const PLAN_TIER_BY_ID: Readonly> = { + free: "free", + go: "go", + pro: "pro", + team: "pro", + founders: "pro", + max: "max", +}; + +/** The tier a plan ID grants, or null for an unknown ID. */ +export function planTier(planId: string | null | undefined): PlanTier | null { + if (typeof planId !== "string") return null; + const key = planId.trim().toLowerCase(); + return Object.hasOwn(PLAN_TIER_BY_ID, key) ? PLAN_TIER_BY_ID[key]! : null; +} + +/** Sort key for a tier or plan ID; an unknown ID ranks with Free. */ +export function planTierRank(planId: string | null | undefined): number { + return PLAN_TIER_RANK[planTier(planId) ?? "free"]; +} + +/** Whether a plan includes every feature of `required`. Unknown IDs include nothing. */ +export function planIncludes(planId: string | null | undefined, required: PlanTier): boolean { + const tier = planTier(planId); + return tier !== null && PLAN_TIER_RANK[tier] >= PLAN_TIER_RANK[required]; +} + +/** The plan an upgrade from `planId` offers, or null at the top tier. */ +export function nextUpgradePlanId(planId: string | null | undefined): "pro" | "max" | null { + if (planIncludes(planId, "max")) return null; + return planIncludes(planId, "pro") ? "max" : "pro"; +} diff --git a/web/services/billing/pro.ts b/web/services/billing/pro.ts index a8d24f538eac..024589b5c704 100644 --- a/web/services/billing/pro.ts +++ b/web/services/billing/pro.ts @@ -26,6 +26,7 @@ import { } from "../account/metadataMutation"; import { activeApplePlanForUser } from "./apple/entitlement"; +import { planTierRank } from "./planTier"; import { APPLE_MANAGE_SUBSCRIPTIONS_URL } from "./apple/config"; export const PRO_PLAN_ID = "pro"; @@ -78,8 +79,6 @@ export const PAID_PLAN_IDS = [GO_PLAN_ID, PRO_PLAN_ID, MAX_PLAN_ID, TEAM_PLAN_ID */ export const PERSONAL_PLAN_IDS = [GO_PLAN_ID, PRO_PLAN_ID, MAX_PLAN_ID] as const; export type PersonalPlanId = (typeof PERSONAL_PLAN_IDS)[number]; -/** Higher index wins when an account has more than one active personal row. */ -const PERSONAL_PLAN_RANK: Record = { go: 1, pro: 2, max: 3 }; export function isPersonalPlanId(planId: string | null | undefined): planId is PersonalPlanId { return typeof planId === "string" && @@ -94,7 +93,7 @@ export function highestPersonalPlanId( for (const candidate of planIds) { if (!isPersonalPlanId(candidate)) continue; const normalized = candidate.trim().toLowerCase() as PersonalPlanId; - if (!best || PERSONAL_PLAN_RANK[normalized] > PERSONAL_PLAN_RANK[best]) best = normalized; + if (!best || planTierRank(normalized) > planTierRank(best)) best = normalized; } return best; } diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index 818925e1b481..72df475df9a0 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -8,6 +8,7 @@ import { TEAM_PLAN_ID, isPaidPlanId, } from "../billing/pro"; +import { planIncludes } from "../billing/planTier"; import { MAX_PLAN_RESOURCE_POOL, PAID_MAX_ACTIVE_VMS_DEFAULT, @@ -208,8 +209,8 @@ export const GO_MEMORY_UPGRADE_PLAN_ID = PRO_PLAN_ID; /** Return the first paid plan that can create the requested ladder size. */ export function upgradePlanForMemory(memoryMb: number, currentPlanId: string, env: Record = process.env): string | null { const current = normalizedPlanId(currentPlanId); - if (current === MAX_PLAN_ID) return null; - if ((current === GO_PLAN_ID || current === "free" || !isPaidPlanId(current)) && memoryMb <= maxMemoryMbForPlan(PRO_PLAN_ID, env)) return PRO_PLAN_ID; + if (planIncludes(current, "max")) return null; + if (!planIncludes(current, "pro") && memoryMb <= maxMemoryMbForPlan(PRO_PLAN_ID, env)) return PRO_PLAN_ID; return memoryMb <= maxMemoryMbForPlan(MAX_PLAN_ID, env) ? MAX_PLAN_ID : null; } @@ -284,9 +285,9 @@ export function lockedMemoryOptionsMbForPlan( const max = maxMemoryMbForPlan(planId, env); const locked = VM_MEMORY_OPTIONS_MB.filter((mb) => mb > max); const normalized = normalizedPlanId(planId ?? ""); - const candidateUpgradePlanId = normalized === GO_PLAN_ID || normalized === "free" || !isPaidPlanId(normalized) - ? GO_MEMORY_UPGRADE_PLAN_ID - : MEMORY_UPGRADE_PLAN_ID; + const candidateUpgradePlanId = planIncludes(normalized, "pro") + ? MEMORY_UPGRADE_PLAN_ID + : GO_MEMORY_UPGRADE_PLAN_ID; const upgradePlanId = locked.length > 0 && normalized !== candidateUpgradePlanId && maxMemoryMbForPlan(candidateUpgradePlanId, env) >= locked[0] ? candidateUpgradePlanId diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index 6f6498073c43..ee46b15ab764 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -45,6 +45,7 @@ import { type VmWorkflowError, } from "./errors"; import { recordSpanTiming } from "./timings"; +import { planIncludes } from "../billing/planTier"; import { authProviderErrorResponse } from "./authErrors"; import { goCapacityConstraint } from "./goUsage"; import { isSyntheticAccountEmail, rememberSyntheticUser } from "../analytics/syntheticAccounts"; @@ -707,7 +708,7 @@ export async function vmResourcePoolExceededResponse( error: VmResourcePoolExceededError, locale: Locale, ): Promise { - const upgradePlanId = error.planId === "max" ? null : "max"; + const upgradePlanId = planIncludes(error.planId, "max") ? null : "max"; const memory = error.resource === "memoryMb"; const copy = await vmResourcePoolCopy(locale, { resource: error.resource, diff --git a/web/tests/plan-tier-entitlements.test.ts b/web/tests/plan-tier-entitlements.test.ts new file mode 100644 index 000000000000..8232dc6b6e36 --- /dev/null +++ b/web/tests/plan-tier-entitlements.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; + +import { + nextUpgradePlanId, + planIncludes, + planTier, + planTierRank, +} from "../services/billing/planTier"; +import { + isPaidVmPlan, + lockedMemoryOptionsMbForPlan, + maxActiveVmsForPlan, + maxDiskMbForPlan, + maxMemoryMbForPlan, + maxVcpusForPlan, + memoryOptionsMbForPlan, + resourcePoolForPlan, + upgradePlanForMemory, +} from "../services/vms/entitlements"; + +// Defaults only: operator env overrides are covered by the entitlement suites. +const env: Record = {}; +const GB = 1024; + +describe("plan tier ordering", () => { + test("every plan ID maps to a tier, with Team and Founder's Edition counted as Pro", () => { + expect(planTier("free")).toBe("free"); + expect(planTier("go")).toBe("go"); + expect(planTier(" PRO ")).toBe("pro"); + expect(planTier("team")).toBe("pro"); + expect(planTier("founders")).toBe("pro"); + expect(planTier("max")).toBe("max"); + expect(planTier("enterprise")).toBeNull(); + expect(planTier("constructor")).toBeNull(); + expect(planTier(null)).toBeNull(); + expect(planTierRank("free")).toBeLessThan(planTierRank("go")); + expect(planTierRank("go")).toBeLessThan(planTierRank("pro")); + expect(planTierRank("pro")).toBeLessThan(planTierRank("max")); + }); + + test("Max includes everything Pro includes, and Pro does not include Max", () => { + expect(planIncludes("max", "pro")).toBe(true); + expect(planIncludes("max", "max")).toBe(true); + expect(planIncludes("pro", "pro")).toBe(true); + expect(planIncludes("pro", "max")).toBe(false); + expect(planIncludes("team", "pro")).toBe(true); + expect(planIncludes("go", "pro")).toBe(false); + expect(planIncludes("free", "go")).toBe(false); + expect(planIncludes("unknown", "go")).toBe(false); + }); + + test("the next upgrade is the tier above, and nothing above Max", () => { + expect(nextUpgradePlanId("free")).toBe("pro"); + expect(nextUpgradePlanId("go")).toBe("pro"); + expect(nextUpgradePlanId("pro")).toBe("max"); + expect(nextUpgradePlanId("team")).toBe("max"); + expect(nextUpgradePlanId("max")).toBeNull(); + }); +}); + +/** + * Free / Pro / Max across every server-side Cloud gate. Max must get every + * Pro gate plus the Max-only sizes and pool; Pro must be refused the Max-only + * rows with Max as the upgrade; Free gets neither. + */ +describe("Cloud entitlement matrix", () => { + const rows = [ + { + plan: "free", + paid: false, + maxMemoryMb: 8 * GB, + maxVcpus: 4, + maxDiskMb: 128 * GB, + maxActiveVms: 0, + pool: null, + lockedUpgrade: "pro", + }, + { + plan: "go", + paid: true, + maxMemoryMb: 4 * GB, + maxVcpus: 2, + maxDiskMb: 16 * GB, + maxActiveVms: 1, + pool: null, + lockedUpgrade: "pro", + }, + { + plan: "pro", + paid: true, + maxMemoryMb: 16 * GB, + maxVcpus: 8, + maxDiskMb: 128 * GB, + maxActiveVms: 5, + pool: { vcpus: 20, memoryMb: 40 * GB }, + lockedUpgrade: "max", + }, + { + plan: "max", + paid: true, + maxMemoryMb: 64 * GB, + maxVcpus: 32, + maxDiskMb: 256 * GB, + maxActiveVms: 5, + pool: { vcpus: 80, memoryMb: 160 * GB }, + lockedUpgrade: null, + }, + ] as const; + + for (const row of rows) { + test(`${row.plan}: provisioning, size ceilings, machine count and pool`, () => { + expect(isPaidVmPlan(row.plan)).toBe(row.paid); + expect(maxMemoryMbForPlan(row.plan, env)).toBe(row.maxMemoryMb); + expect(maxVcpusForPlan(row.plan, env)).toBe(row.maxVcpus); + expect(maxDiskMbForPlan(row.plan, env)).toBe(row.maxDiskMb); + expect(maxActiveVmsForPlan(row.plan, env)).toBe(row.maxActiveVms); + const pool = resourcePoolForPlan(row.plan, maxActiveVmsForPlan(row.plan, env)); + expect(pool).toEqual(row.pool); + expect(lockedMemoryOptionsMbForPlan(row.plan, env).upgradePlanId).toBe(row.lockedUpgrade); + }); + } + + test("Max can start every Max-only size, and Pro is refused each with Max as the upgrade", () => { + for (const memoryMb of [24 * GB, 32 * GB, 64 * GB]) { + expect(memoryOptionsMbForPlan("max", env)).toContain(memoryMb); + expect(memoryOptionsMbForPlan("pro", env)).not.toContain(memoryMb); + expect(upgradePlanForMemory(memoryMb, "pro", env)).toBe("max"); + expect(upgradePlanForMemory(memoryMb, "max", env)).toBeNull(); + } + }); + + test("Max keeps every size Pro can start", () => { + for (const memoryMb of memoryOptionsMbForPlan("pro", env)) { + expect(memoryOptionsMbForPlan("max", env)).toContain(memoryMb); + } + expect(maxActiveVmsForPlan("max", env)!).toBeGreaterThanOrEqual(maxActiveVmsForPlan("pro", env)!); + }); + + test("Free is offered Pro for a Pro size, not Max", () => { + expect(upgradePlanForMemory(16 * GB, "free", env)).toBe("pro"); + expect(upgradePlanForMemory(16 * GB, "go", env)).toBe("pro"); + }); +}); From 30eb6889656d3bb18798adb464bac01bd2299faa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:43:43 -0700 Subject: [PATCH 051/183] Regenerate settings schema, MDM manifests, settings page strings and action exports on feat-cmux-next for the motion settings Fleet steps at 4b5cf673e75d: scripts/measure/export-settings-artifacts.sh (cmux-ci d9e1de8769d954bd550b06c0) and scripts/measure/export-action-surfaces.sh (ec57e1adf03c24775a4d0c71); defaults_guard.py snapshot (unchanged) and webviews/scripts/pages/gen-strings.mjs. --- docs/mdm/com.manaflow.cmux.json | 22 +++ docs/mdm/com.manaflow.cmux.plist | 41 +++++ docs/mdm/managed-preferences.md | 3 + plans/cmux-next/action-surfaces.json | 108 ++++++++++++ plans/cmux-next/actions.md | 10 +- schemas/settings/settings-schema.json | 165 +++++++++++++++++- .../src/pages/settings/generated/strings.json | 126 +++++++++++++ 7 files changed, 468 insertions(+), 7 deletions(-) diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index cfa4f00ac6a5..af58fb819651 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -2841,6 +2841,28 @@ "title" : "Font Size", "type" : "number" }, + "ui.animations.bounce" : { + "default" : "none", + "description" : "None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce.", + "enum" : [ + "none", + "subtle" + ], + "title" : "Animation Bounce", + "type" : "string" + }, + "ui.animations.newTab" : { + "default" : false, + "description" : "A new tab grows into its place in the tab bar. Off shows it there at once.", + "title" : "Animate New Tabs", + "type" : "boolean" + }, + "ui.animations.newWorkspace" : { + "default" : false, + "description" : "A new workspace row opens in the sidebar. Off shows it there at once.", + "title" : "Animate New Workspaces", + "type" : "boolean" + }, "ui.animationSpeed" : { "default" : "fast", "description" : "ui.animationSpeed", diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index 39cb02a23fcb..6b86b4474d45 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -1853,6 +1853,47 @@ pfm_type boolean + + pfm_default + + pfm_description + A new tab grows into its place in the tab bar. Off shows it there at once. + pfm_name + ui.animations.newTab + pfm_title + Animate New Tabs + pfm_type + boolean + + + pfm_default + + pfm_description + A new workspace row opens in the sidebar. Off shows it there at once. + pfm_name + ui.animations.newWorkspace + pfm_title + Animate New Workspaces + pfm_type + boolean + + + pfm_default + none + pfm_description + None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce. + pfm_name + ui.animations.bounce + pfm_range_list + + none + subtle + + pfm_title + Animation Bounce + pfm_type + string + pfm_description How panes are told apart. None draws no border or divider at all; dragging between panes still resizes them. Default: dividers inside the curved main pane, borders otherwise. diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index 63017852ecaa..056fb998eb54 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -105,6 +105,9 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `ui.animationSpeed` | string | `"fast"` | `fast`, `normal`, `off` | Animations | | `appearance.chromeStyle` | string | `"default"` | `default`, `card`, `card-outlined`, `flat`, `flat-sidebar-line`, `bare`, `panes`, `two-tone` | Window Style. The look of the window: whether panes sit in one rounded card, and where lines are drawn. Separation, Sidebar Background and the sidebar border still win when you set them. | | `ui.paneAnimations` | boolean | `false` | | Animate Panes. Splits, closes, moves, zooms, column scrolls and screen switches slide into place. Off: they change at once. | +| `ui.animations.newTab` | boolean | `false` | | Animate New Tabs. A new tab grows into its place in the tab bar. Off shows it there at once. | +| `ui.animations.newWorkspace` | boolean | `false` | | Animate New Workspaces. A new workspace row opens in the sidebar. Off shows it there at once. | +| `ui.animations.bounce` | string | `"none"` | `none`, `subtle` | Animation Bounce. None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce. | | `layout.paneSeparation` | string | | `none`, `dividers`, `borders`, `cards` | Separation. How panes are told apart. None draws no border or divider at all; dragging between panes still resizes them. Default: dividers inside the curved main pane, borders otherwise. | | `layout.panePadding` | real | | 0 to 16 | Padding | | `layout.paneCornerRadius` | real | | 0 to 20 | Corner Radius | diff --git a/plans/cmux-next/action-surfaces.json b/plans/cmux-next/action-surfaces.json index 1ff73a4b6ec9..f2c493bbe7dc 100644 --- a/plans/cmux-next/action-surfaces.json +++ b/plans/cmux-next/action-surfaces.json @@ -21892,6 +21892,114 @@ "title_key" : "action.appearance.animationSpeed.off", "title_table" : "ActionCatalog" }, + { + "cli" : "offered", + "cli_name" : "settings toggle-new-tab-animations", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.animations.newTab.toggle", + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Toggle New Tab Animations", + "title_key" : "action.appearance.animations.newTab.toggle", + "title_table" : "ActionCatalog" + }, + { + "cli" : "offered", + "cli_name" : "settings toggle-new-workspace-animations", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.animations.newWorkspace.toggle", + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Toggle New Workspace Animations", + "title_key" : "action.appearance.animations.newWorkspace.toggle", + "title_table" : "ActionCatalog" + }, + { + "cli" : "offered", + "cli_name" : "settings use-bouncy-animations", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.animations.bounce.subtle", + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Use Bouncy Animations", + "title_key" : "action.appearance.animations.bounce.subtle", + "title_table" : "ActionCatalog" + }, + { + "cli" : "offered", + "cli_name" : "settings use-animations-without-bounce", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.animations.bounce.none", + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Use Animations Without Bounce", + "title_key" : "action.appearance.animations.bounce.none", + "title_table" : "ActionCatalog" + }, { "cli" : "offered", "cli_name" : "settings use-chromium-by-default", diff --git a/plans/cmux-next/actions.md b/plans/cmux-next/actions.md index 528e91cae630..b470ec22c5f7 100644 --- a/plans/cmux-next/actions.md +++ b/plans/cmux-next/actions.md @@ -47,11 +47,11 @@ Declare its placements (menu, group, rank) and whether the CLI offers it, or nam - `agents`: 58 - `cloud`: 52 - `remote`: 10 -- `settings`: 79 +- `settings`: 83 -## Counts (955 actions) +## Counts (959 actions) -Palette 919, CLI verbs 518, right-click 496, MCP tools 449. +Palette 923, CLI verbs 522, right-click 496, MCP tools 449. ## Menus @@ -138,7 +138,7 @@ Palette 919, CLI verbs 518, right-click 496, MCP tools 449. **guiOnly** (20): `browser.media.show`, `browser.downloads.show`, `browser.profile.choose`, `browser.overflow.menu`, `browser.download.openBlockedSiteSettings`, `agentPane.continueIn`, `agentPane.switchModel`, `agentPane.createCheckpoint`, `agentPane.searchFiles`, `agentPane.copyLastCodeBlock`, `agentPane.archiveChat`, `agentPane.previousTurn`, `agentPane.nextTurn`, `agentPane.permission.allowOnce`, `agentPane.permission.allowChat`, `agentPane.permission.deny`, `agentPane.permission.expand`, `agentPane.permission.retry`, `agentPane.permission.revoke`, `agentPane.permission.refresh` -**noObject** (187): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `reopenClosedBrowserPanel`, `home.show`, `home.tab`, `home.channels`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.saveFlightRecording`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `agentActivity.open`, `computerUseStop`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.customize`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `bookmark.add`, `bookmark.import`, `bookmark.importFromBrowser`, `bookmark.export`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth` +**noObject** (191): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `reopenClosedBrowserPanel`, `home.show`, `home.tab`, `home.channels`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.saveFlightRecording`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `agentActivity.open`, `computerUseStop`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.customize`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `bookmark.add`, `bookmark.import`, `bookmark.importFromBrowser`, `bookmark.export`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth` **noTargetSurface** (81): `taskManager.killProcess`, `toggleChecklistItemComplete`, `canvasOverview`, `canvasTidy`, `canvasAlignLeft`, `canvasAlignRight`, `canvasAlignTop`, `canvasAlignBottom`, `canvasEqualizeWidths`, `canvasEqualizeHeights`, `canvasDistributeHorizontally`, `canvasDistributeVertically`, `simulatorHome`, `simulatorRotateLeft`, `simulatorRotateRight`, `simulatorToggleAppearance`, `simulatorToggleSoftwareKeyboard`, `file.open`, `home.archiveChief`, `tabGroup.deleteSaved`, `tabGroup.reopenSaved`, `screenGroup.reopenSaved`, `screenGroup.deleteSaved`, `resumeCommandSet`, `resumeCommandEdit`, `resumeCommandClear`, `saveFilePreview`, `toggleFileEditorWordWrap`, `filePreviewOpenWith`, `filePreviewOpenExternally`, `filePreviewRevealInFinder`, `diffViewerNextLine`, `diffViewerPreviousLine`, `diffViewerHalfPageDown`, `diffViewerHalfPageUp`, `diffViewerNextHunk`, `diffViewerPreviousHunk`, `diffViewerGoToBottom`, `diffViewerGoToTop`, `diffViewerSearch`, `diffViewerNextFile`, `diffViewerPreviousFile`, `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart`, `browser.openOnMachine`, `openDiffViewer`, `palette.openDirectoryDiffViewer`, `openMarkdownFile`, `fileEditorGotoLine`, `fileEditorReplace`, `fileEditorAction`, `browser.certificateWarning.proceed`, `browser.certificateWarning.goBack`, `browserProfile.openLink`, `fileExplorerOpenSelection`, `fileExplorerOpenSelectionFinderAlias`, `fileExplorerOpenInCmux`, `fileExplorerReveal`, `fileExplorerCopyPath`, `fileExplorerCopyRelativePath`, `fileExplorerOpenWith`, `vaultOpenSession`, `vaultResumeInNewWorkspace`, `vaultCopyResumeCommand`, `vaultOpenPullRequest`, `checklistEditItem`, `checklistMarkInProgress`, `checklistCompleteItem`, `checklistRemoveItem`, `checklistOpenAsPane`, `checklistAttachImages`, `agentPane.git.commit`, `agentPane.git.push`, `agentPane.searchChats`, `cloudExec`, `cloudDomainList`, `cloudPublicationList`, `markdownSave`, `markdownLink`, `markdownBack`, `markdownForward` @@ -160,7 +160,7 @@ Palette 919, CLI verbs 518, right-click 496, MCP tools 449. **endsApp** (4): `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything` -**systemChange** (58): `keepMacAwake`, `workspace.importClassic`, `palette.toggleMatchTerminalBackground`, `palette.enableMinimalMode`, `palette.disableMinimalMode`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `agentPaneZoomIn`, `agentPaneZoomOut`, `agentPaneZoomReset`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `help.showCrashLogs`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar` +**systemChange** (62): `keepMacAwake`, `workspace.importClassic`, `palette.toggleMatchTerminalBackground`, `palette.enableMinimalMode`, `palette.disableMinimalMode`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `agentPaneZoomIn`, `agentPaneZoomOut`, `agentPaneZoomReset`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `help.showCrashLogs`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar` Every action without a CLI verb is also no MCP tool, for the CLI's reason. diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index 86e1c278e6c7..1fc5c73c1a4a 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -247,7 +247,10 @@ "appearance.focusIndicator", "focus.inactiveTabStyle", "ui.animationSpeed", - "ui.paneAnimations" + "ui.paneAnimations", + "ui.animations.newTab", + "ui.animations.newWorkspace", + "ui.animations.bounce" ], "title" : { "key" : "settings.group.densityMotion", @@ -10126,6 +10129,164 @@ }, "validation" : "portable" }, + { + "accepts" : [ + true, + false + ], + "agent_refusal" : null, + "agent_settable" : true, + "consumers" : [ + "cmux-next" + ], + "default" : false, + "default_label" : null, + "group" : { + "key" : "settings.group.densityMotion", + "text" : "Density and Motion" + }, + "help" : { + "key" : "settings.ui.animations.newTab.help", + "text" : "A new tab grows into its place in the tab bar. Off shows it there at once." + }, + "kept_on_reset_all" : false, + "key" : "ui.animations.newTab", + "keywords" : [ + "motion", + "animation", + "tab" + ], + "kind" : "toggle", + "page_hidden" : false, + "path" : [ + "ui", + "animations", + "newTab" + ], + "refuses" : [ + "true", + 1, + null + ], + "section" : "appearance", + "summary" : null, + "title" : { + "key" : "settings.ui.animations.newTab", + "text" : "Animate New Tabs" + }, + "validation" : "portable" + }, + { + "accepts" : [ + true, + false + ], + "agent_refusal" : null, + "agent_settable" : true, + "consumers" : [ + "cmux-next" + ], + "default" : false, + "default_label" : null, + "group" : { + "key" : "settings.group.densityMotion", + "text" : "Density and Motion" + }, + "help" : { + "key" : "settings.ui.animations.newWorkspace.help", + "text" : "A new workspace row opens in the sidebar. Off shows it there at once." + }, + "kept_on_reset_all" : false, + "key" : "ui.animations.newWorkspace", + "keywords" : [ + "motion", + "animation", + "workspace", + "sidebar" + ], + "kind" : "toggle", + "page_hidden" : false, + "path" : [ + "ui", + "animations", + "newWorkspace" + ], + "refuses" : [ + "true", + 1, + null + ], + "section" : "appearance", + "summary" : null, + "title" : { + "key" : "settings.ui.animations.newWorkspace", + "text" : "Animate New Workspaces" + }, + "validation" : "portable" + }, + { + "accepts" : [ + "none", + "subtle" + ], + "agent_refusal" : null, + "agent_settable" : true, + "choices" : [ + { + "title" : { + "key" : "settings.choice.none", + "text" : "None" + }, + "value" : "none" + }, + { + "title" : { + "key" : "settings.choice.subtle", + "text" : "Subtle" + }, + "value" : "subtle" + } + ], + "consumers" : [ + "cmux-next" + ], + "default" : "none", + "default_label" : null, + "group" : { + "key" : "settings.group.densityMotion", + "text" : "Density and Motion" + }, + "help" : { + "key" : "settings.ui.animations.bounce.help", + "text" : "None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce." + }, + "kept_on_reset_all" : false, + "key" : "ui.animations.bounce", + "keywords" : [ + "motion", + "spring", + "bounce" + ], + "kind" : "choice", + "page_hidden" : false, + "path" : [ + "ui", + "animations", + "bounce" + ], + "refuses" : [ + "__not_a_choice__", + 1, + true + ], + "section" : "appearance", + "summary" : null, + "title" : { + "key" : "settings.ui.animations.bounce", + "text" : "Animation Bounce" + }, + "validation" : "portable" + }, { "accepts" : [ "none", @@ -23532,7 +23693,7 @@ "validation" : "portable" } ], - "schema_hash" : "79e569d3cdd546084d1ee8033b78707b55524c03716992d0a53c7b6a4b473314", + "schema_hash" : "1a4739f5cb5bd56d387252123bd73d5db4b6067ea56683f2310c89e280b47c53", "sections" : [ { "id" : "general", diff --git a/webviews/src/pages/settings/generated/strings.json b/webviews/src/pages/settings/generated/strings.json index 224e846bca33..b79eb30efa36 100644 --- a/webviews/src/pages/settings/generated/strings.json +++ b/webviews/src/pages/settings/generated/strings.json @@ -672,6 +672,12 @@ "settings.terminal.fontFamily.help": "A monospaced font installed on this Mac.", "settings.terminal.fontSize": "Font Size", "settings.ui.animationSpeed": "Animations", + "settings.ui.animations.bounce": "Animation Bounce", + "settings.ui.animations.bounce.help": "None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce.", + "settings.ui.animations.newTab": "Animate New Tabs", + "settings.ui.animations.newTab.help": "A new tab grows into its place in the tab bar. Off shows it there at once.", + "settings.ui.animations.newWorkspace": "Animate New Workspaces", + "settings.ui.animations.newWorkspace.help": "A new workspace row opens in the sidebar. Off shows it there at once.", "settings.ui.paneAnimations": "Animate Panes", "settings.ui.paneAnimations.help": "Splits, closes, moves, zooms, column scrolls and screen switches slide into place. Off: they change at once.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -1683,6 +1689,12 @@ "settings.terminal.fontFamily.help": "خط أحادي المسافة مثبّت على جهاز Mac هذا.", "settings.terminal.fontSize": "حجم الخط", "settings.ui.animationSpeed": "الرسوم المتحركة", + "settings.ui.animations.bounce": "ارتداد الحركة", + "settings.ui.animations.bounce.help": "لا شيء يوقف علامات التبويب والصفوف والأجزاء في مكانها تمامًا. خفيف يتركها تستقر بارتداد صغير.", + "settings.ui.animations.newTab": "تحريك علامات التبويب الجديدة", + "settings.ui.animations.newTab.help": "تنمو علامة التبويب الجديدة في مكانها في شريط علامات التبويب. عند الإيقاف تظهر فورًا.", + "settings.ui.animations.newWorkspace": "تحريك مساحات العمل الجديدة", + "settings.ui.animations.newWorkspace.help": "ينفتح صف مساحة العمل الجديدة في الشريط الجانبي. عند الإيقاف يظهر فورًا.", "settings.ui.paneAnimations": "تحريك الأجزاء", "settings.ui.paneAnimations.help": "تنزلق عمليات التقسيم والإغلاق والنقل والتكبير وتمرير الأعمدة وتبديل الشاشات إلى أماكنها. عند الإيقاف: تتغير فورًا.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -2694,6 +2706,12 @@ "settings.terminal.fontFamily.help": "Font fiksne širine instaliran na ovom Macu.", "settings.terminal.fontSize": "Veličina fonta", "settings.ui.animationSpeed": "Animacije", + "settings.ui.animations.bounce": "Odskok animacije", + "settings.ui.animations.bounce.help": "Ništa zaustavlja kartice, redove i okna tačno na mjestu. Suptilno ih pušta da se smire uz mali odskok.", + "settings.ui.animations.newTab": "Animiraj nove kartice", + "settings.ui.animations.newTab.help": "Nova kartica raste na svom mjestu u traci kartica. Isključeno je odmah prikazuje.", + "settings.ui.animations.newWorkspace": "Animiraj nove radne prostore", + "settings.ui.animations.newWorkspace.help": "Novi red radnog prostora otvara se u bočnoj traci. Isključeno ga odmah prikazuje.", "settings.ui.paneAnimations": "Animiraj panele", "settings.ui.paneAnimations.help": "Dijeljenja, zatvaranja, premještanja, zumiranja, pomicanja kolona i promjene ekrana klize na mjesto. Isključeno: mijenjaju se odmah.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -3705,6 +3723,12 @@ "settings.terminal.fontFamily.help": "En skrift med fast bredde, der er installeret på denne Mac.", "settings.terminal.fontSize": "Skriftstørrelse", "settings.ui.animationSpeed": "Animationer", + "settings.ui.animations.bounce": "Animationsfjedring", + "settings.ui.animations.bounce.help": "Ingen stopper faner, rækker og ruder præcis på deres plads. Diskret lader dem falde på plads med et lille fjed.", + "settings.ui.animations.newTab": "Animer nye faner", + "settings.ui.animations.newTab.help": "En ny fane vokser frem på sin plads i fanelinjen. Fra viser den der med det samme.", + "settings.ui.animations.newWorkspace": "Animer nye arbejdsområder", + "settings.ui.animations.newWorkspace.help": "En ny række for arbejdsområdet åbner i sidepanelet. Fra viser den der med det samme.", "settings.ui.paneAnimations": "Animér paneler", "settings.ui.paneAnimations.help": "Opdelinger, lukninger, flytninger, zoom, kolonnerulning og skærmskift glider på plads. Fra: de skifter med det samme.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -4716,6 +4740,12 @@ "settings.terminal.fontFamily.help": "Eine auf diesem Mac installierte Schrift mit fester Breite.", "settings.terminal.fontSize": "Schriftgröße", "settings.ui.animationSpeed": "Animationen", + "settings.ui.animations.bounce": "Animationsfederung", + "settings.ui.animations.bounce.help": "Keine hält Tabs, Zeilen und Bereiche genau an ihrem Platz an. Dezent lässt sie mit einem kleinen Federn einrasten.", + "settings.ui.animations.newTab": "Neue Tabs animieren", + "settings.ui.animations.newTab.help": "Ein neuer Tab wächst an seinem Platz in der Tableiste. Aus zeigt ihn dort sofort.", + "settings.ui.animations.newWorkspace": "Neue Arbeitsbereiche animieren", + "settings.ui.animations.newWorkspace.help": "Eine neue Arbeitsbereichszeile öffnet sich in der Seitenleiste. Aus zeigt sie dort sofort.", "settings.ui.paneAnimations": "Bereiche animieren", "settings.ui.paneAnimations.help": "Teilen, Schließen, Verschieben, Zoomen, Spaltenscrollen und Bildschirmwechsel gleiten an ihren Platz. Aus: Sie wechseln sofort.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -5727,6 +5757,12 @@ "settings.terminal.fontFamily.help": "Una fuente monoespaciada instalada en este Mac.", "settings.terminal.fontSize": "Tamaño de fuente", "settings.ui.animationSpeed": "Animaciones", + "settings.ui.animations.bounce": "Rebote de animación", + "settings.ui.animations.bounce.help": "Ninguno detiene pestañas, filas y paneles justo en su sitio. Sutil los deja asentarse con un pequeño rebote.", + "settings.ui.animations.newTab": "Animar pestañas nuevas", + "settings.ui.animations.newTab.help": "Una pestaña nueva crece en su lugar de la barra de pestañas. Desactivado la muestra al instante.", + "settings.ui.animations.newWorkspace": "Animar espacios de trabajo nuevos", + "settings.ui.animations.newWorkspace.help": "Una fila de espacio de trabajo nueva se abre en la barra lateral. Desactivado la muestra al instante.", "settings.ui.paneAnimations": "Animar paneles", "settings.ui.paneAnimations.help": "Las divisiones, cierres, movimientos, zooms, desplazamientos de columnas y cambios de pantalla se deslizan a su sitio. Desactivado: cambian al instante.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -6738,6 +6774,12 @@ "settings.terminal.fontFamily.help": "Une police à chasse fixe installée sur ce Mac.", "settings.terminal.fontSize": "Taille de police", "settings.ui.animationSpeed": "Animations", + "settings.ui.animations.bounce": "Rebond des animations", + "settings.ui.animations.bounce.help": "Aucun arrête onglets, lignes et sous-fenêtres exactement à leur place. Discret les laisse se poser avec un petit rebond.", + "settings.ui.animations.newTab": "Animer les nouveaux onglets", + "settings.ui.animations.newTab.help": "Un nouvel onglet s’ouvre à sa place dans la barre d’onglets. Désactivé l’affiche aussitôt.", + "settings.ui.animations.newWorkspace": "Animer les nouveaux espaces de travail", + "settings.ui.animations.newWorkspace.help": "Une nouvelle ligne d’espace de travail s’ouvre dans la barre latérale. Désactivé l’affiche aussitôt.", "settings.ui.paneAnimations": "Animer les volets", "settings.ui.paneAnimations.help": "Les divisions, fermetures, déplacements, zooms, défilements de colonnes et changements d'écran glissent en place. Désactivé : ils changent immédiatement.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -7749,6 +7791,12 @@ "settings.terminal.fontFamily.help": "Un font a spaziatura fissa installato su questo Mac.", "settings.terminal.fontSize": "Dimensione del font", "settings.ui.animationSpeed": "Animazioni", + "settings.ui.animations.bounce": "Rimbalzo delle animazioni", + "settings.ui.animations.bounce.help": "Nessuno ferma schede, righe e riquadri esattamente al loro posto. Leggero li fa assestare con un piccolo rimbalzo.", + "settings.ui.animations.newTab": "Anima le nuove schede", + "settings.ui.animations.newTab.help": "Una nuova scheda cresce al suo posto nella barra delle schede. Disattivato la mostra subito.", + "settings.ui.animations.newWorkspace": "Anima i nuovi spazi di lavoro", + "settings.ui.animations.newWorkspace.help": "Una nuova riga dello spazio di lavoro si apre nella barra laterale. Disattivato la mostra subito.", "settings.ui.paneAnimations": "Anima i riquadri", "settings.ui.paneAnimations.help": "Divisioni, chiusure, spostamenti, zoom, scorrimenti delle colonne e cambi di schermata scorrono in posizione. Disattivato: cambiano subito.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -8760,6 +8808,12 @@ "settings.terminal.fontFamily.help": "この Mac にインストールされている等幅フォントです。", "settings.terminal.fontSize": "フォントサイズ", "settings.ui.animationSpeed": "アニメーション", + "settings.ui.animations.bounce": "アニメーションの弾み", + "settings.ui.animations.bounce.help": "なしではタブ、行、ペインがその位置でぴたりと止まります。控えめでは小さく弾んで落ち着きます。", + "settings.ui.animations.newTab": "新しいタブをアニメーション", + "settings.ui.animations.newTab.help": "新しいタブはタブバーの位置で広がります。オフにするとすぐに表示されます。", + "settings.ui.animations.newWorkspace": "新しいワークスペースをアニメーション", + "settings.ui.animations.newWorkspace.help": "新しいワークスペースの行がサイドバーで開きます。オフにするとすぐに表示されます。", "settings.ui.paneAnimations": "ペインをアニメーション", "settings.ui.paneAnimations.help": "分割、閉じる、移動、ズーム、列のスクロール、画面の切り替えがスライドして収まります。オフにすると即座に切り替わります。", "settings.updates.checkAutomatically": "アップデートを自動的に確認", @@ -9771,6 +9825,12 @@ "settings.terminal.fontFamily.help": "ពុម្ពអក្សរទទឹងថេរដែលបានដំឡើងនៅលើ Mac នេះ។", "settings.terminal.fontSize": "ទំហំពុម្ពអក្សរ", "settings.ui.animationSpeed": "ចលនា", + "settings.ui.animations.bounce": "ការលោតនៃចលនា", + "settings.ui.animations.bounce.help": "គ្មាន បញ្ឈប់ផ្ទាំង ជួរ និងផ្ទាំងបន្ទះនៅកន្លែងរបស់វាត្រឹមត្រូវ។ ស្រាល ឱ្យវាស្ងប់ដោយលោតបន្តិច។", + "settings.ui.animations.newTab": "ធ្វើចលនាផ្ទាំងថ្មី", + "settings.ui.animations.newTab.help": "ផ្ទាំងថ្មីលូតលាស់នៅកន្លែងរបស់វាក្នុងរបារផ្ទាំង។ បិទ បង្ហាញវាភ្លាមៗ។", + "settings.ui.animations.newWorkspace": "ធ្វើចលនាកន្លែងធ្វើការថ្មី", + "settings.ui.animations.newWorkspace.help": "ជួរកន្លែងធ្វើការថ្មីបើកនៅក្នុងរបារចំហៀង។ បិទ បង្ហាញវាភ្លាមៗ។", "settings.ui.paneAnimations": "ចលនាផ្ទាំង", "settings.ui.paneAnimations.help": "ការបំបែក ការបិទ ការផ្លាស់ទី ការពង្រីក ការរំកិលជួរឈរ និងការប្តូរអេក្រង់ រអិលចូលកន្លែង។ បិទ៖ ពួកវាប្តូរភ្លាមៗ។", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -10782,6 +10842,12 @@ "settings.terminal.fontFamily.help": "이 Mac에 설치된 고정폭 서체입니다.", "settings.terminal.fontSize": "서체 크기", "settings.ui.animationSpeed": "애니메이션", + "settings.ui.animations.bounce": "애니메이션 바운스", + "settings.ui.animations.bounce.help": "없음은 탭, 행, 창을 정확히 제자리에 멈춥니다. 은은하게는 작게 튕기며 자리 잡습니다.", + "settings.ui.animations.newTab": "새 탭 애니메이션", + "settings.ui.animations.newTab.help": "새 탭은 탭 막대의 자기 자리에서 자라납니다. 끄면 즉시 표시됩니다.", + "settings.ui.animations.newWorkspace": "새 작업 공간 애니메이션", + "settings.ui.animations.newWorkspace.help": "새 작업 공간 행이 사이드바에서 열립니다. 끄면 즉시 표시됩니다.", "settings.ui.paneAnimations": "창 애니메이션", "settings.ui.paneAnimations.help": "분할, 닫기, 이동, 확대/축소, 열 스크롤, 화면 전환이 미끄러지듯 자리를 잡습니다. 끄면 즉시 바뀝니다.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -11793,6 +11859,12 @@ "settings.terminal.fontFamily.help": "En skrift med fast bredde som er installert på denne Macen.", "settings.terminal.fontSize": "Skriftstørrelse", "settings.ui.animationSpeed": "Animasjoner", + "settings.ui.animations.bounce": "Animasjonsfjæring", + "settings.ui.animations.bounce.help": "Ingen stopper faner, rader og ruter nøyaktig på plassen sin. Diskret lar dem falle på plass med en liten fjæring.", + "settings.ui.animations.newTab": "Animer nye faner", + "settings.ui.animations.newTab.help": "En ny fane vokser inn på plassen sin i fanelinjen. Av viser den der med en gang.", + "settings.ui.animations.newWorkspace": "Animer nye arbeidsområder", + "settings.ui.animations.newWorkspace.help": "En ny rad for arbeidsområdet åpner seg i sidepanelet. Av viser den der med en gang.", "settings.ui.paneAnimations": "Animer ruter", "settings.ui.paneAnimations.help": "Delinger, lukkinger, flyttinger, zoom, kolonnerulling og skjermbytter glir på plass. Av: de endres med en gang.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -12804,6 +12876,12 @@ "settings.terminal.fontFamily.help": "Czcionka o stałej szerokości zainstalowana na tym Macu.", "settings.terminal.fontSize": "Rozmiar czcionki", "settings.ui.animationSpeed": "Animacje", + "settings.ui.animations.bounce": "Odbicie animacji", + "settings.ui.animations.bounce.help": "Brak zatrzymuje karty, wiersze i panele dokładnie na miejscu. Subtelne pozwala im osiąść z lekkim odbiciem.", + "settings.ui.animations.newTab": "Animuj nowe karty", + "settings.ui.animations.newTab.help": "Nowa karta wyrasta na swoim miejscu na pasku kart. Wyłączone pokazuje ją od razu.", + "settings.ui.animations.newWorkspace": "Animuj nowe obszary robocze", + "settings.ui.animations.newWorkspace.help": "Nowy wiersz obszaru roboczego otwiera się na pasku bocznym. Wyłączone pokazuje go od razu.", "settings.ui.paneAnimations": "Animuj panele", "settings.ui.paneAnimations.help": "Podziały, zamknięcia, przeniesienia, powiększenia, przewijanie kolumn i zmiany ekranów wsuwają się na miejsce. Wyłączone: zmieniają się od razu.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -13815,6 +13893,12 @@ "settings.terminal.fontFamily.help": "Uma fonte monoespaçada instalada neste Mac.", "settings.terminal.fontSize": "Tamanho da fonte", "settings.ui.animationSpeed": "Animações", + "settings.ui.animations.bounce": "Quique das animações", + "settings.ui.animations.bounce.help": "Nenhum para abas, linhas e painéis exatamente no lugar. Sutil os deixa assentar com um pequeno quique.", + "settings.ui.animations.newTab": "Animar novas abas", + "settings.ui.animations.newTab.help": "Uma nova aba cresce no seu lugar na barra de abas. Desativado a mostra na hora.", + "settings.ui.animations.newWorkspace": "Animar novos espaços de trabalho", + "settings.ui.animations.newWorkspace.help": "Uma nova linha de espaço de trabalho se abre na barra lateral. Desativado a mostra na hora.", "settings.ui.paneAnimations": "Animar painéis", "settings.ui.paneAnimations.help": "Divisões, fechamentos, movimentos, zooms, rolagens de colunas e trocas de tela deslizam até o lugar. Desativado: mudam na hora.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -14826,6 +14910,12 @@ "settings.terminal.fontFamily.help": "Моноширинный шрифт, установленный на этом Mac.", "settings.terminal.fontSize": "Размер шрифта", "settings.ui.animationSpeed": "Анимации", + "settings.ui.animations.bounce": "Пружинистость анимации", + "settings.ui.animations.bounce.help": "Нет останавливает вкладки, строки и панели точно на месте. Слегка даёт им встать на место с небольшим отскоком.", + "settings.ui.animations.newTab": "Анимировать новые вкладки", + "settings.ui.animations.newTab.help": "Новая вкладка вырастает на своём месте в панели вкладок. Выключено показывает её сразу.", + "settings.ui.animations.newWorkspace": "Анимировать новые рабочие пространства", + "settings.ui.animations.newWorkspace.help": "Новая строка рабочего пространства раскрывается на боковой панели. Выключено показывает её сразу.", "settings.ui.paneAnimations": "Анимировать панели", "settings.ui.paneAnimations.help": "Разделение, закрытие, перемещение, масштабирование, прокрутка колонок и смена экранов плавно встают на место. Выкл.: меняются сразу.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -15837,6 +15927,12 @@ "settings.terminal.fontFamily.help": "แบบอักษรความกว้างคงที่ที่ติดตั้งใน Mac เครื่องนี้", "settings.terminal.fontSize": "ขนาดแบบอักษร", "settings.ui.animationSpeed": "ภาพเคลื่อนไหว", + "settings.ui.animations.bounce": "การเด้งของภาพเคลื่อนไหว", + "settings.ui.animations.bounce.help": "ไม่มี จะหยุดแท็บ แถว และบานหน้าต่างตรงตำแหน่งพอดี เล็กน้อย จะให้เด้งเล็กน้อยก่อนเข้าที่", + "settings.ui.animations.newTab": "เคลื่อนไหวแท็บใหม่", + "settings.ui.animations.newTab.help": "แท็บใหม่จะขยายในตำแหน่งของมันบนแถบแท็บ ปิดจะแสดงทันที", + "settings.ui.animations.newWorkspace": "เคลื่อนไหวพื้นที่ทำงานใหม่", + "settings.ui.animations.newWorkspace.help": "แถวพื้นที่ทำงานใหม่จะเปิดในแถบด้านข้าง ปิดจะแสดงทันที", "settings.ui.paneAnimations": "เคลื่อนไหวบานหน้าต่าง", "settings.ui.paneAnimations.help": "การแบ่ง การปิด การย้าย การซูม การเลื่อนคอลัมน์ และการสลับหน้าจอจะเลื่อนเข้าที่ ปิด: เปลี่ยนทันที", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -16848,6 +16944,12 @@ "settings.terminal.fontFamily.help": "Bu Mac’te yüklü eş aralıklı bir yazı tipi.", "settings.terminal.fontSize": "Yazı tipi boyutu", "settings.ui.animationSpeed": "Animasyonlar", + "settings.ui.animations.bounce": "Animasyon sekmesi", + "settings.ui.animations.bounce.help": "Yok sekmeleri, satırları ve bölmeleri tam yerinde durdurur. Hafif, küçük bir sekmeyle yerleşmelerini sağlar.", + "settings.ui.animations.newTab": "Yeni sekmeleri canlandır", + "settings.ui.animations.newTab.help": "Yeni sekme sekme çubuğundaki yerinde büyür. Kapalı olduğunda hemen gösterilir.", + "settings.ui.animations.newWorkspace": "Yeni çalışma alanlarını canlandır", + "settings.ui.animations.newWorkspace.help": "Yeni çalışma alanı satırı kenar çubuğunda açılır. Kapalı olduğunda hemen gösterilir.", "settings.ui.paneAnimations": "Bölmeleri canlandır", "settings.ui.paneAnimations.help": "Bölme, kapatma, taşıma, yakınlaştırma, sütun kaydırma ve ekran geçişleri kayarak yerine oturur. Kapalı: hemen değişir.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -17859,6 +17961,12 @@ "settings.terminal.fontFamily.help": "Моноширинний шрифт, установлений на цьому Mac.", "settings.terminal.fontSize": "Розмір шрифту", "settings.ui.animationSpeed": "Анімації", + "settings.ui.animations.bounce": "Пружність анімації", + "settings.ui.animations.bounce.help": "Немає зупиняє вкладки, рядки й панелі точно на місці. Легко дає їм стати на місце з невеликим відскоком.", + "settings.ui.animations.newTab": "Анімувати нові вкладки", + "settings.ui.animations.newTab.help": "Нова вкладка виростає на своєму місці на панелі вкладок. Вимкнено показує її одразу.", + "settings.ui.animations.newWorkspace": "Анімувати нові робочі простори", + "settings.ui.animations.newWorkspace.help": "Новий рядок робочого простору розкривається на бічній панелі. Вимкнено показує його одразу.", "settings.ui.paneAnimations": "Анімувати панелі", "settings.ui.paneAnimations.help": "Розділення, закриття, переміщення, масштабування, прокручування колонок і зміна екранів плавно стають на місце. Вимк.: змінюються одразу.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -18870,6 +18978,12 @@ "settings.terminal.fontFamily.help": "Một phông chữ đơn cách được cài trên máy Mac này.", "settings.terminal.fontSize": "Cỡ phông chữ", "settings.ui.animationSpeed": "Hoạt ảnh", + "settings.ui.animations.bounce": "Độ nảy của hiệu ứng", + "settings.ui.animations.bounce.help": "Không dừng tab, hàng và khung đúng vị trí. Nhẹ cho chúng nảy nhẹ trước khi dừng.", + "settings.ui.animations.newTab": "Tạo hiệu ứng tab mới", + "settings.ui.animations.newTab.help": "Tab mới mở ra tại vị trí của nó trên thanh tab. Tắt sẽ hiện ngay.", + "settings.ui.animations.newWorkspace": "Tạo hiệu ứng không gian làm việc mới", + "settings.ui.animations.newWorkspace.help": "Hàng không gian làm việc mới mở ra trong thanh bên. Tắt sẽ hiện ngay.", "settings.ui.paneAnimations": "Hiệu ứng ngăn", "settings.ui.paneAnimations.help": "Chia, đóng, di chuyển, phóng to, cuộn cột và chuyển màn hình trượt vào chỗ. Tắt: thay đổi ngay lập tức.", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -19881,6 +19995,12 @@ "settings.terminal.fontFamily.help": "此 Mac 上安装的等宽字体。", "settings.terminal.fontSize": "字体大小", "settings.ui.animationSpeed": "动画", + "settings.ui.animations.bounce": "动画弹跳", + "settings.ui.animations.bounce.help": "无:标签页、行和窗格精确停在原位。轻微:以轻微弹跳落定。", + "settings.ui.animations.newTab": "动画显示新标签页", + "settings.ui.animations.newTab.help": "新标签页在标签栏中的位置展开。关闭后立即显示。", + "settings.ui.animations.newWorkspace": "动画显示新工作区", + "settings.ui.animations.newWorkspace.help": "新工作区行在侧边栏中展开。关闭后立即显示。", "settings.ui.paneAnimations": "窗格动画", "settings.ui.paneAnimations.help": "拆分、关闭、移动、缩放、列滚动和屏幕切换会滑动到位。关闭后立即切换。", "settings.updates.checkAutomatically": "Check for Updates Automatically", @@ -20892,6 +21012,12 @@ "settings.terminal.fontFamily.help": "這部 Mac 上安裝的等寬字體。", "settings.terminal.fontSize": "字體大小", "settings.ui.animationSpeed": "動畫", + "settings.ui.animations.bounce": "動畫彈跳", + "settings.ui.animations.bounce.help": "無:分頁、列與窗格精確停在原位。輕微:以輕微彈跳落定。", + "settings.ui.animations.newTab": "動畫顯示新分頁", + "settings.ui.animations.newTab.help": "新分頁在分頁列中的位置展開。關閉後立即顯示。", + "settings.ui.animations.newWorkspace": "動畫顯示新工作區", + "settings.ui.animations.newWorkspace.help": "新工作區列在側邊欄中展開。關閉後立即顯示。", "settings.ui.paneAnimations": "窗格動畫", "settings.ui.paneAnimations.help": "分割、關閉、移動、縮放、欄捲動和螢幕切換會滑動到位。關閉後立即切換。", "settings.updates.checkAutomatically": "Check for Updates Automatically", From a8b5a4b00de74633d7cbd3143ccb7078707413fb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:55:17 -0700 Subject: [PATCH 052/183] billing: App Store Go hours, Pro/Max compute allowances, Sandbox allowlist (#20587) * billing: DB behavior test for App Store Go hours and Pro/Max allowances Red on main: the Go capacity trigger refuses every App Store Go subscriber (cmux_go_period_unavailable), and the Pro/Max allowance does not exist. * billing: App Store Go hours, Pro/Max allowances, Sandbox allowlist, grant analytics - Go allowance reads the web or App Store subscription period; migration 20261014160000 lets the Go capacity guard read apple_subscriptions. - Pro (375 compute-hours, 160 GB disk) and Max (1,500 compute-hours, 640 GB disk) monthly allowances, enforced at create/resume/resize and by the minute job, with a localized reset-date refusal. - Production Sandbox purchases grant only for APPLE_IAP_SANDBOX_ACCOUNT_EMAILS. - subscription_entitlement_granted from the app post and notifications. - No vendor names in billing, guest CLI, and Cloud security copy. * billing: review fixes for plan allowances and the Sandbox allowlist - Native forks check the Pro/Max allowance before allocating. - Legacy disk placeholders count at the plan's per-machine disk maximum. - A same-plan web subscription without a usable period falls back to the App Store period, like the database guard. - Paused-resume reports an allowance refusal instead of the original error. - The Sandbox allowlist matches only a verified primary email, and only App Store bundle Sandbox rows trigger the account email read. * billing: allowances off by default, App Store yearly Pro and Max products - CMUX_VM_PLAN_BUDGETS=on opts in to the Pro/Max allowances; by default no Pro or Max user gets a compute-hour or disk limit. Go hours stay enforced. - com.cmux.app.{pro,max}.yearly map to their plans; account-token lists them with billingPeriod when the app sends x-cmux-billing-periods: monthly,yearly. - billing_period on subscription_entitlement_granted and subscription_* events. - A yearly subscription's allowance (when on) resets monthly. * billing: review fixes for yearly windows and yearly analytics prices - Allowance windows: a billed period up to 35 days is one window; yearly periods split by clamped calendar months, a short remainder joins the previous window, and a grace period extends the last billed window. - Admin analytics uses the yearly list-price estimate for yearly charges. - Route test sends x-cmux-billing-periods. --- docs/billing/ios-in-app-purchases.md | 17 +- .../api/billing/apple/account-token/route.ts | 2 + web/app/api/vm/route.ts | 17 + .../migration.sql | 58 ++++ web/messages/ar.json | 10 + web/messages/bs.json | 10 + web/messages/da.json | 10 + web/messages/de.json | 10 + web/messages/en.json | 18 +- web/messages/es.json | 10 + web/messages/fr.json | 10 + web/messages/it.json | 10 + web/messages/ja.json | 16 +- web/messages/km.json | 10 + web/messages/ko.json | 10 + web/messages/no.json | 10 + web/messages/pl.json | 10 + web/messages/pt-BR.json | 10 + web/messages/ru.json | 10 + web/messages/th.json | 10 + web/messages/tr.json | 10 + web/messages/uk.json | 10 + web/messages/zh-CN.json | 10 + web/messages/zh-TW.json | 10 + web/services/billing/analytics/query.ts | 2 +- web/services/billing/analytics/records.ts | 13 +- web/services/billing/apple/accountToken.ts | 44 ++- web/services/billing/apple/config.ts | 89 ++++- web/services/billing/apple/entitlement.ts | 42 ++- web/services/billing/apple/service.ts | 60 +++- web/services/billing/apple/state.ts | 7 +- web/services/vms/errors.ts | 18 + web/services/vms/goPause.ts | 38 ++- web/services/vms/goRuntimeLimits.ts | 47 ++- web/services/vms/goUsage.ts | 47 +-- web/services/vms/planUsage.ts | 323 ++++++++++++++++++ web/services/vms/routeHelpers.ts | 25 ++ web/services/vms/vmErrorMessages.ts | 31 ++ web/services/vms/workflows.ts | 98 +++++- web/tests/apple-iap-account-token.test.ts | 32 +- web/tests/apple-iap-routes.test.ts | 3 +- web/tests/apple-iap-service.test.ts | 17 +- web/tests/dashboard-billing-screen.test.tsx | 2 +- web/tests/fixtures/go-runtime-limits.sql | 46 +++ web/tests/plan-allowance-db-behavior.test.ts | 179 ++++++++++ 45 files changed, 1362 insertions(+), 109 deletions(-) create mode 100644 web/db/migrations/20261014160000_go_runtime_limits_apple/migration.sql create mode 100644 web/services/vms/planUsage.ts create mode 100644 web/tests/plan-allowance-db-behavior.test.ts diff --git a/docs/billing/ios-in-app-purchases.md b/docs/billing/ios-in-app-purchases.md index 7ee3da0f4eb4..7d908e5deb7d 100644 --- a/docs/billing/ios-in-app-purchases.md +++ b/docs/billing/ios-in-app-purchases.md @@ -19,6 +19,7 @@ All products are auto-renewable, monthly, in one subscription group so upgrades Product IDs are prefixed with the bundle ID, so the App Store app and the TestFlight beta app each own their products: - `.go.monthly`, `.pro.monthly`, `.max.monthly` +- Yearly, like the web prices (Pro $480/yr, Max $1,920/yr; Go is monthly only): `.pro.yearly`, `.max.yearly`. The account-token route lists them only to an app that sends `x-cmux-billing-periods: monthly,yearly`; every product entry carries `billingPeriod` (`monthly` | `yearly`). Older apps get monthly products only. Transactions and notifications map either ID to its plan; the grant runs until `expires_at`. Until the App Store yearly prices are set, a non-USD yearly charge is estimated at the monthly list price x 9.6. - App Store app: bundle `com.cmux.app` (Apple ID 6783338052). Beta: `dev.cmux.app.beta`. Dev builds use a local StoreKit configuration file. Go is listed only when the server reports it enabled (the Go PostHog flag), same as web. @@ -59,19 +60,29 @@ An Apple subscription in `active`, `grace_period`, or `billing_retry` (Apple kee Mechanism: the `cmuxPlan` mirror in Stack `clientReadOnlyMetadata` is the single entitlement. Every write of it (`syncProPlanMetadata` in Stripe fulfillment, read-time reconciliation, and the Apple flows) computes the highest of the active Stripe plan and the granting Apple plan, so neither source can clear or downgrade the other. Grants are time-checked on read (`expires_at`, `grace_period_expires_at`), so a missed notification cannot extend access. An operator `cmuxVmPlan` override still wins and is never rewritten. -Sandbox purchases are free, so in production deployments a Sandbox subscription grants a plan only for the App Store bundle `com.cmux.app` (App Review buys in the Sandbox against production). TestFlight purchases of the beta bundle are recorded but grant nothing in production, so the account-token route answers `purchases_unavailable` and lists no products to those builds. Other deployments grant every Sandbox purchase. `APPLE_IAP_SANDBOX_ENTITLEMENTS` (`all`, `none`, or a bundle list) overrides both. +Sandbox purchases are free, so in production deployments a Sandbox subscription grants a plan only for the App Store bundle `com.cmux.app` and only when the signed-in cmux account's primary email is verified and in `APPLE_IAP_SANDBOX_ACCOUNT_EMAILS` (comma-separated, case-insensitive). Every other TestFlight tester, and every TestFlight purchase of the beta bundle, is recorded but grants nothing, so the account-token route answers `purchases_unavailable` and lists no products to those builds. The grant check reads the email from the account when a Sandbox row would otherwise grant, so removing an email from the list ends that account's Sandbox grant on the next plan read. Other deployments grant every Sandbox purchase. `APPLE_IAP_SANDBOX_ENTITLEMENTS` (`all`, `none`, or a bundle list) overrides both, and then the email list is not read. + +The production list must contain the App Review demo account `cmux-rev-0e6ba145@mailinator.com` (App Review buys in the Sandbox against production; without it App Review sees no products and rejects the submission) and `lawrence@manaflow.ai`. Production value: `APPLE_IAP_SANDBOX_ACCOUNT_EMAILS=cmux-rev-0e6ba145@mailinator.com,lawrence@manaflow.ai`. Both accounts must exist in production with a verified email; verify the demo account through its public Mailinator inbox. ## Analytics (RevenueCat parity) -Server-side PostHog events, one per App Store Server Notification that changes subscriber-visible state (deduplicated by `notificationUUID`, timestamped at the notification's `signedDate`), keyed by the cmux user id: `subscription_started`, `subscription_renewed`, `subscription_plan_changed`, `subscription_cancel_scheduled` (auto-renew off), `subscription_resubscribed`, `subscription_billing_issue`, `subscription_grace_period_entered`, `subscription_expired`, `subscription_refunded`, `subscription_revoked`. Properties: `source` (`apple` | `stripe`), `plan_id`, `product_id`, `storefront`, `currency`, `price` (gross, local currency), `price_usd_estimate`, `net_usd_estimate` (commission 30%, or 15% after one year of paid service), `environment`, `original_transaction_id`. Apple events also carry `notification_type`, `notification_subtype`, and `auto_renew_product_id`. Mapping: `SUBSCRIBED` (`INITIAL_BUY` started, `RESUBSCRIBE` resubscribed), `DID_RENEW` renewed, `DID_CHANGE_RENEWAL_PREF` plan_changed (`UPGRADE` takes effect now, `DOWNGRADE` at renewal), `DID_CHANGE_RENEWAL_STATUS` `AUTO_RENEW_DISABLED` cancel_scheduled, `DID_FAIL_TO_RENEW` (`GRACE_PERIOD` grace_period_entered, otherwise billing_issue), `EXPIRED` expired, `REFUND` refunded, `REVOKE` revoked. `GRACE_PERIOD_EXPIRED`, `REFUND_REVERSED`, `PRICE_INCREASE`, `RENEWAL_EXTENDED`, auto-renew re-enabled, `CONSUMPTION_REQUEST`, and `TEST` update state (or are only recorded) without an event. The `price_usd_estimate` is exact for USD storefronts and the plan's US list price elsewhere. Stripe does not emit these events yet; it keeps its existing `cmux_billing_*` events. +Server-side PostHog events, one per App Store Server Notification that changes subscriber-visible state (deduplicated by `notificationUUID`, timestamped at the notification's `signedDate`), keyed by the cmux user id: `subscription_started`, `subscription_renewed`, `subscription_plan_changed`, `subscription_cancel_scheduled` (auto-renew off), `subscription_resubscribed`, `subscription_billing_issue`, `subscription_grace_period_entered`, `subscription_expired`, `subscription_refunded`, `subscription_revoked`. Properties: `source` (`apple` | `stripe`), `plan_id`, `product_id`, `billing_period` (`monthly` | `yearly`, Apple events), `storefront`, `currency`, `price` (gross, local currency), `price_usd_estimate`, `net_usd_estimate` (commission 30%, or 15% after one year of paid service), `environment`, `original_transaction_id`. Apple events also carry `notification_type`, `notification_subtype`, and `auto_renew_product_id`. Mapping: `SUBSCRIBED` (`INITIAL_BUY` started, `RESUBSCRIBE` resubscribed), `DID_RENEW` renewed, `DID_CHANGE_RENEWAL_PREF` plan_changed (`UPGRADE` takes effect now, `DOWNGRADE` at renewal), `DID_CHANGE_RENEWAL_STATUS` `AUTO_RENEW_DISABLED` cancel_scheduled, `DID_FAIL_TO_RENEW` (`GRACE_PERIOD` grace_period_entered, otherwise billing_issue), `EXPIRED` expired, `REFUND` refunded, `REVOKE` revoked. `GRACE_PERIOD_EXPIRED`, `REFUND_REVERSED`, `PRICE_INCREASE`, `RENEWAL_EXTENDED`, auto-renew re-enabled, `CONSUMPTION_REQUEST`, and `TEST` update state (or are only recorded) without an event. + +`subscription_entitlement_granted` is sent after a transaction is recorded and applied, from both the app-posted route and the notification, when that subscription is the plan the user now holds through Apple (so a Sandbox purchase that grants nothing sends none). Properties: `source` (`apple`), `plan_id`, `product_id`, `billing_period`, `environment`, `original_transaction_id`, `transaction_id`, `status`, `expires_at`; `distinct_id` is the cmux user id. The insert ID is `apple_entitlement:`, so the app post and the notification for one transaction count once, and each renewal counts again. The lifecycle events above come only from notifications, so a deployment whose App Store Server Notifications URL is missing records purchases with no lifecycle events; the grant event does not depend on notifications. The `price_usd_estimate` is exact for USD storefronts and the plan's US list price elsewhere. Stripe does not emit these events yet; it keeps its existing `cmux_billing_*` events. The cmux-admin app page `/subscriptions` (data from `GET /api/admin/subscriptions/analytics`) shows, filterable by source and plan: active subscriptions, MRR gross and net, new subscriptions, churn, refunds, billing-issue and grace counts, revenue by storefront, and monthly cohort retention. Sandbox data is excluded unless toggled. The iOS app sends client events (`ios_paywall_viewed`, `ios_purchase_started`, `ios_purchase_cancelled`, `ios_purchase_failed`, `ios_purchase_pending`, `ios_restore_started`, `ios_restore_completed`) through its existing analytics path (`/api/analytics/events`, which accepts only allowlisted `ios_`-prefixed names in `web/services/analytics/iosEventPolicy.ts`) so the paywall funnel joins the server lifecycle events. Properties: `entry_point` (`settings` | `cloud_upgrade`), `product_id`, `plan_id`, and `reason` on failures; `success` and `restored_count` on `ios_restore_completed`. +## Plan allowances + +App Store and web subscribers get the same allowances, each reset by its own subscription period (App Store: newest transaction's purchase date to expiry, extended by a grace period; a yearly subscription resets monthly from its start date). Go: 40 VM-hours, one running machine, two saved; always enforced. + +The Pro and Max allowances below are **off by default**: https://cmux.com/pricing promises no hour limits, so without `CMUX_VM_PLAN_BUDGETS=on` no Pro or Max user gets a compute-hour or disk limit and the 402 refusals never fire. With the switch on: Pro: 375 compute-hours and 160 GB of disk across saved machines. Max: 1,500 compute-hours and 640 GB. A compute-hour is 1 vCPU with 2 GB of memory running for one hour (a machine draws `max(vCPUs, memory GB / 2)` units). Create, resume, fork, and restore refuse with 402 `vm_compute_budget_reached` (with the reset date) when the compute-hours are used up, and create or disk growth refuses with 402 `vm_storage_budget_reached`; the minute job pauses running Pro and Max machines at the limit. Founder's Edition, operator plan grants, and Team have no allowance (an operator grant to a user who also holds a Pro or Max subscription is metered by that subscription). A machine is metered at its current recorded shape, so resizing a machine also re-rates its earlier runtime in the period. `CMUX_VM_PLAN_BUDGETS=on` turns them on. + ## Configuration -Server env: `APPLE_IAP_KEY_ID`, `APPLE_IAP_ISSUER_ID`, `APPLE_IAP_PRIVATE_KEY` (In-App Purchase `.p8` contents, for the App Store Server API), `APPLE_IAP_BUNDLE_IDS` (`com.cmux.app,dev.cmux.app.beta`), `APPLE_IAP_APP_APPLE_ID` (`6783338052`, required to verify Production payloads). The bundle IDs and app Apple ID default to these values. Optional: `APPLE_IAP_SANDBOX_ENTITLEMENTS` (see Entitlement), `APPLE_IAP_ONLINE_CHECKS` (`1`/`0`; OCSP revocation checks of Apple's certificates, on by default only when `VERCEL_ENV=production`). Apple's root certificates (G3 and G2) are bundled in `web/services/billing/apple/certs` and inlined in `rootCertificates.ts`; a test checks both against Apple's published SHA-256 fingerprints. +Server env: `APPLE_IAP_KEY_ID`, `APPLE_IAP_ISSUER_ID`, `APPLE_IAP_PRIVATE_KEY` (In-App Purchase `.p8` contents, for the App Store Server API), `APPLE_IAP_BUNDLE_IDS` (`com.cmux.app,dev.cmux.app.beta`), `APPLE_IAP_APP_APPLE_ID` (`6783338052`, required to verify Production payloads). The bundle IDs and app Apple ID default to these values. Optional: `APPLE_IAP_SANDBOX_ACCOUNT_EMAILS` and `APPLE_IAP_SANDBOX_ENTITLEMENTS` (see Entitlement), `APPLE_IAP_ONLINE_CHECKS` (`1`/`0`; OCSP revocation checks of Apple's certificates, on by default only when `VERCEL_ENV=production`). Apple's root certificates (G3 and G2) are bundled in `web/services/billing/apple/certs` and inlined in `rootCertificates.ts`; a test checks both against Apple's published SHA-256 fingerprints. Server env values: `APPLE_IAP_KEY_ID=976787J9XZ` (In-App Purchase key "cmux server IAP"), `APPLE_IAP_ISSUER_ID=94d76068-119d-45cd-8700-86eb7a1a6235`. The `.p8` is kept outside the repo; never commit it. diff --git a/web/app/api/billing/apple/account-token/route.ts b/web/app/api/billing/apple/account-token/route.ts index 7136423fcf08..8b91405a328b 100644 --- a/web/app/api/billing/apple/account-token/route.ts +++ b/web/app/api/billing/apple/account-token/route.ts @@ -1,6 +1,7 @@ import { appleAccountTokenResponse, requestedAppleBundleId, + requestedBillingPeriods, requestedStoreKitEnvironment, } from "../../../../../services/billing/apple/accountToken"; import { authenticateAppleBillingRequest } from "../../../../../services/billing/apple/routeAuth"; @@ -23,6 +24,7 @@ export async function POST(request: Request): Promise { const body = await appleAccountTokenResponse(auth.user, { bundleId: requestedAppleBundleId(request.headers.get("x-cmux-bundle-id")), storeKitEnvironment: requestedStoreKitEnvironment(request.headers.get("x-cmux-storekit-environment")), + billingPeriods: requestedBillingPeriods(request.headers.get("x-cmux-billing-periods")), }); return jsonResponse(body, 200, { "cache-control": "no-store" }); } catch (error) { diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index 711ee2aab976..97d9246a7ff5 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -93,6 +93,7 @@ import { VmTimingRecorder, } from "../../../services/vms/timings"; import { getGoVmUsage, GO_SAVED_VM_LIMIT } from "../../../services/vms/goUsage"; +import { getPlanBudgetUsage, isBudgetPlanId, type PlanBudgetUsage } from "../../../services/vms/planUsage"; import { publishVmOps, vmSyncReplaceOp } from "../../../services/vms/presencePublisher"; @@ -104,6 +105,18 @@ import { publishVmOps, vmSyncReplaceOp } from "../../../services/vms/presencePub export const maxDuration = 960; const VM_CREATE_ADMISSION_BUDGET_MS = 200; +/** Compute-hours to one decimal, disk in whole GB, and the reset time. */ +function planBudgetLimits(usage: PlanBudgetUsage | null) { + if (!usage) return {}; + return { + computeHoursIncluded: usage.includedComputeSeconds / 3600, + computeHoursUsed: Math.round(usage.usedComputeSeconds / 360) / 10, + computeBudgetResetsAt: usage.periodEnd.toISOString(), + storageBudgetGb: usage.storageBudgetMb / 1024, + storageUsedGb: Math.ceil(usage.retainedStorageMb / 1024), + }; +} + function createResourceFields(resourceReservation: { readonly vcpus: number; readonly memoryMb: number } | null | undefined) { if (!resourceReservation) return {}; return { @@ -253,6 +266,10 @@ export async function GET(request: Request): Promise { .catch(() => null), savedVmLimit: GO_SAVED_VM_LIMIT, } : {}), + // Pro and Max monthly allowance; absent when none applies. + ...(isBudgetPlanId(listEntitlements.planId) + ? await getPlanBudgetUsage(user.id).then(planBudgetLimits).catch(() => ({})) + : {}), // The earliest expiry across the caller's machines: what a fleet header // counts down to. Null when nothing is on a window. freeAccessExpiresAt: vms.reduce( diff --git a/web/db/migrations/20261014160000_go_runtime_limits_apple/migration.sql b/web/db/migrations/20261014160000_go_runtime_limits_apple/migration.sql new file mode 100644 index 000000000000..4f98e57ba733 --- /dev/null +++ b/web/db/migrations/20261014160000_go_runtime_limits_apple/migration.sql @@ -0,0 +1,58 @@ +-- Go's runtime allowance resets on the subscription's own billing period. An +-- App Store Go subscription has no stripe_subscriptions row, so the guard also +-- reads apple_subscriptions: the period runs from the newest transaction's +-- purchase date to the expiry, extended by a grace period. Which App Store +-- subscriptions grant a plan (Sandbox policy) is decided before the row gets +-- billing_plan_id 'go'; this guard only needs the period. Same rule as +-- services/vms/planUsage.ts. Only the period lookup changes. +CREATE OR REPLACE FUNCTION cmux_guard_go_vm_capacity() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + live_count integer; + active_count integer; + period_start timestamptz; + period_end timestamptz; + used_seconds double precision; +BEGIN + IF NEW.billing_plan_id IS DISTINCT FROM 'go' OR NEW.status NOT IN ('provisioning', 'running', 'paused') THEN + RETURN NEW; + END IF; + PERFORM pg_advisory_xact_lock(hashtextextended('cmux-go:' || NEW.user_id, 0)); + SELECT count(*), count(*) FILTER (WHERE status IN ('provisioning','running')) + INTO live_count, active_count FROM cloud_vms + WHERE user_id = NEW.user_id AND id <> NEW.id AND status IN ('provisioning','running','paused'); + IF live_count >= 2 THEN + RAISE EXCEPTION 'Go supports two saved VMs in total' USING ERRCODE = '23514', CONSTRAINT = 'cmux_go_saved_limit'; + END IF; + IF NEW.status IN ('provisioning','running') AND active_count >= 1 THEN + RAISE EXCEPTION 'Go supports one active VM' USING ERRCODE = '23514', CONSTRAINT = 'cmux_go_active_limit'; + END IF; + IF NEW.status IN ('provisioning','running') AND (TG_OP = 'INSERT' OR OLD.status <> NEW.status) THEN + SELECT to_timestamp(coalesce( + (raw->'items'->'data'->0->>'current_period_start')::double precision, + (raw->>'current_period_start')::double precision)), current_period_end + INTO period_start, period_end FROM stripe_subscriptions + WHERE stack_user_id = NEW.user_id AND scope = 'user' AND plan = 'go' + AND status IN ('active','trialing') ORDER BY updated_at DESC LIMIT 1; + IF period_start IS NULL OR period_end IS NULL OR period_end <= clock_timestamp() THEN + SELECT coalesce(purchase_date, expires_at - interval '31 days'), + greatest(expires_at, coalesce(grace_period_expires_at, expires_at)) + INTO period_start, period_end FROM apple_subscriptions + WHERE user_id = NEW.user_id AND plan_id = 'go' + AND status IN ('active','grace_period','billing_retry') + AND greatest(expires_at, coalesce(grace_period_expires_at, expires_at)) > clock_timestamp() + ORDER BY expires_at DESC NULLS LAST LIMIT 1; + END IF; + IF period_start IS NULL OR period_end IS NULL OR period_start >= period_end OR period_end <= clock_timestamp() THEN + RAISE EXCEPTION 'Go billing period is unavailable' USING ERRCODE = '23514', CONSTRAINT = 'cmux_go_period_unavailable'; + END IF; + SELECT coalesce(sum(greatest(0, extract(epoch FROM + least(coalesce(ended_at, clock_timestamp()), period_end) - greatest(started_at, period_start)))),0) + INTO used_seconds FROM cloud_vm_runtime_intervals + WHERE user_id = NEW.user_id AND started_at < period_end AND (ended_at IS NULL OR ended_at > period_start); + IF used_seconds >= 144000 THEN + RAISE EXCEPTION 'Go runtime limit reached' USING ERRCODE = '23514', CONSTRAINT = 'cmux_go_hours_limit'; + END IF; + END IF; + RETURN NEW; +END; +$$; diff --git a/web/messages/ar.json b/web/messages/ar.json index 1cc76cda2849..bd0405d6f48a 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -114,6 +114,16 @@ "shapeMessage": "تدعم Go معالجين افتراضيين وذاكرة 4 GiB وقرصاً بسعة 16 GiB.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "تم استهلاك حصة الحوسبة في {plan}", + "computeMessage": "استهلكت خطة {plan} ساعات الحوسبة البالغة {included} لفترة الفوترة هذه. ساعة الحوسبة هي 1 vCPU مع 2 GB من الذاكرة لمدة ساعة واحدة. يمكن تشغيل أجهزتك مرة أخرى في {resetDate}.", + "computeAction": "تحتفظ الأجهزة المتوقفة مؤقتًا بملفاتها. تواصل مع الدعم إذا احتجت إلى حوسبة إضافية قبل {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "تم استهلاك حصة القرص في {plan}", + "storageMessage": "تتضمن خطة {plan} مساحة قرص إجمالية قدرها {included} GB لكل الأجهزة المحفوظة. تستخدم الأجهزة المحفوظة {used} GB، ويحتاج هذا الطلب إلى {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "لا تكفي موارد Cloud VM", "memoryMessage": "تستخدم أجهزتك الافتراضية بالفعل {used} من {pool} GB من RAM. يحتاج هذا الجهاز الافتراضي إلى {requested} GB.", diff --git a/web/messages/bs.json b/web/messages/bs.json index a0c979c0e298..c4e1b426c30c 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -114,6 +114,16 @@ "shapeMessage": "Go podržava 2 vCPU, 4 GiB RAM-a i 16 GiB diska.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "Kvota računanja za {plan} je potrošena", + "computeMessage": "Vaš {plan} plan je potrošio svojih {included} računskih sati za ovaj obračunski period. Jedan računski sat je 1 vCPU sa 2 GB memorije tokom jednog sata. Vaše mašine se ponovo mogu pokrenuti {resetDate}.", + "computeAction": "Pauzirane mašine zadržavaju svoje datoteke. Kontaktirajte podršku ako vam treba više računanja prije {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "Kvota diska za {plan} je potrošena", + "storageMessage": "Vaš {plan} plan uključuje ukupno {included} GB diska za sve sačuvane mašine. Sačuvane mašine koriste {used} GB, a ovaj zahtjev treba {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Nema dovoljno Cloud VM resursa", "memoryMessage": "Vaši VM-ovi već koriste {used} od {pool} GB RAM-a. Ovom VM-u treba {requested} GB.", diff --git a/web/messages/da.json b/web/messages/da.json index b7ddc831e60e..6db9190d796b 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -114,6 +114,16 @@ "shapeMessage": "Go understøtter 2 vCPU, 4 GiB RAM og 16 GiB disk.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan}-regnekvote brugt op", + "computeMessage": "Din {plan}-plan har brugt sine {included} regnetimer i denne faktureringsperiode. En regnetime er 1 vCPU med 2 GB hukommelse i én time. Dine maskiner kan starte igen den {resetDate}.", + "computeAction": "Pausede maskiner beholder deres filer. Kontakt support, hvis du har brug for mere regnekraft før den {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan}-diskkvote brugt op", + "storageMessage": "Din {plan}-plan omfatter {included} GB disk på tværs af alle gemte maskiner. Gemte maskiner bruger {used} GB, og denne anmodning kræver {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Ikke nok Cloud VM-ressourcer", "memoryMessage": "Dine VM'er bruger allerede {used} af {pool} GB RAM. Denne VM skal bruge {requested} GB.", diff --git a/web/messages/de.json b/web/messages/de.json index b669fa637adc..f5a157c93bf8 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -114,6 +114,16 @@ "shapeMessage": "Go unterstützt 2 vCPU, 4 GiB RAM und 16 GiB Speicherplatz.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan}-Rechenkontingent aufgebraucht", + "computeMessage": "Dein {plan}-Plan hat seine {included} Rechenstunden für diesen Abrechnungszeitraum aufgebraucht. Eine Rechenstunde entspricht 1 vCPU mit 2 GB Arbeitsspeicher für eine Stunde. Deine Maschinen können ab dem {resetDate} wieder starten.", + "computeAction": "Pausierte Maschinen behalten ihre Dateien. Wende dich an den Support, wenn du vor dem {resetDate} mehr Rechenleistung brauchst.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan}-Speicherkontingent aufgebraucht", + "storageMessage": "Dein {plan}-Plan umfasst insgesamt {included} GB Speicher für alle gespeicherten Maschinen. Gespeicherte Maschinen belegen {used} GB, und diese Anfrage benötigt {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Nicht genug Cloud-VM-Ressourcen", "memoryMessage": "Ihre VMs nutzen bereits {used} von {pool} GB RAM. Diese VM benötigt {requested} GB.", diff --git a/web/messages/en.json b/web/messages/en.json index b8df12aa4932..3b741388fa37 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -35,7 +35,7 @@ "workspaceMissingID": "The peer did not return a workspace ID. No command was started.", "missingDaemon": "cmux-tui daemon binary not found at %s", "envSingleLineValue": "env set: values must be a single line", - "routeToken": "refusing a coderouter route token in the VM; use the Freestyle TLS edge", + "routeToken": "refusing a coderouter route token in the VM; use the host-managed authenticated route", "missingCodeRouter": "CodeRouter is not configured in this VM (CMUX_CODEROUTER_URL is missing)", "insecureCodeRouter": "CodeRouter requires an https:// endpoint; refusing an insecure URL", "invalidCodeRouter": "CodeRouter endpoint must be a bare https:// host", @@ -281,6 +281,16 @@ "shapeMessage": "Go supports 2 vCPU, 4 GiB RAM, and 16 GiB disk.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan} compute allowance used", + "computeMessage": "Your {plan} plan has used its {included} compute-hours for this billing period. A compute-hour is 1 vCPU with 2 GB of memory running for one hour. Your machines can start again on {resetDate}.", + "computeAction": "Paused machines keep their files. Contact support if you need more compute before {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan} disk allowance used", + "storageMessage": "Your {plan} plan includes {included} GB of disk across all saved machines. Saved machines use {used} GB, and this request needs {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Not enough Cloud VM resources", "memoryMessage": "Your VMs already use {used} of {pool} GB RAM. This VM needs {requested} GB.", @@ -713,7 +723,7 @@ "banners": { "cancelled": "Your plan will cancel at the end of the current billing period.", "resumed": "Your plan has been resumed and will renew normally.", - "nosub": "No active Stripe subscription was found for this account.", + "nosub": "No active web subscription was found for this account.", "error": "Billing could not be updated. Try again shortly.", "pastDue": "Your latest payment failed. Update your payment method to keep your plan active.", "team_admin_required": "Only team admins can manage this team's billing.", @@ -4898,11 +4908,11 @@ "metaDescription": "How cmux Cloud machines are isolated, where credentials live, and how to limit what a machine can reach with an outbound network policy.", "intro": "This page describes the security model of cmux Cloud machines: what isolates a machine, where credentials live, and how to limit what a machine can reach on the network.", "isolationTitle": "Isolation", - "isolationVm": "Each machine is a Freestyle microVM with its own Linux kernel. It is not a container on a shared kernel.", + "isolationVm": "Each machine is a microVM with its own Linux kernel. It is not a container on a shared kernel.", "isolationRoot": "You have passwordless root in the machine by design. The VM is the security boundary, not the user account in it. Every process in the machine, including an agent, can do what root can do there.", "isolationHome": "Recreating a machine resets its home directory. Recreate a machine to return it to a known state.", "credentialsTitle": "Credentials", - "credentialsModel": "Model credentials for CodeRouter never enter the machine. The Freestyle TLS edge adds them to requests after the requests leave the machine. The provider stores these header values write-only, so nobody can read them back after cmux sets them.", + "credentialsModel": "Model credentials for CodeRouter never enter the machine. The cmux Cloud TLS edge adds them to requests after the requests leave the machine. The provider stores these header values write-only, so nobody can read them back after cmux sets them.", "credentialsAttach": "Attach credentials, which let a client open a session on the machine, are short-lived and scoped to one machine and one session.", "previewsTitle": "Port previews", "previewsDesc": "Port previews go over your private WireGuard network. They have no public URL. Only devices on your private network can open them.", diff --git a/web/messages/es.json b/web/messages/es.json index abef5a1dff6e..a09313f2b2ed 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -114,6 +114,16 @@ "shapeMessage": "Go admite 2 vCPU, 4 GiB de RAM y 16 GiB de disco.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "Cuota de cómputo de {plan} agotada", + "computeMessage": "Tu plan {plan} ha usado sus {included} horas de cómputo de este período de facturación. Una hora de cómputo es 1 vCPU con 2 GB de memoria durante una hora. Tus máquinas podrán iniciarse de nuevo el {resetDate}.", + "computeAction": "Las máquinas en pausa conservan sus archivos. Contacta con soporte si necesitas más cómputo antes del {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "Cuota de disco de {plan} agotada", + "storageMessage": "Tu plan {plan} incluye {included} GB de disco entre todas las máquinas guardadas. Las máquinas guardadas usan {used} GB y esta solicitud necesita {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "No hay suficientes recursos de Cloud VM", "memoryMessage": "Tus VMs ya usan {used} de {pool} GB de RAM. Esta VM necesita {requested} GB.", diff --git a/web/messages/fr.json b/web/messages/fr.json index 2134da0a86e3..046be7526eae 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -114,6 +114,16 @@ "shapeMessage": "Go prend en charge 2 vCPU, 4 Gio de RAM et 16 Gio de disque.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "Quota de calcul {plan} épuisé", + "computeMessage": "Votre forfait {plan} a utilisé ses {included} heures de calcul pour cette période de facturation. Une heure de calcul correspond à 1 vCPU avec 2 Go de mémoire pendant une heure. Vos machines pourront redémarrer le {resetDate}.", + "computeAction": "Les machines en pause conservent leurs fichiers. Contactez le support si vous avez besoin de plus de calcul avant le {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "Quota de disque {plan} épuisé", + "storageMessage": "Votre forfait {plan} inclut {included} Go de disque pour l'ensemble des machines enregistrées. Les machines enregistrées utilisent {used} Go, et cette demande nécessite {requested} Go.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Ressources Cloud VM insuffisantes", "memoryMessage": "Vos VMs utilisent déjà {used} Go de RAM sur {pool}. Cette VM a besoin de {requested} Go.", diff --git a/web/messages/it.json b/web/messages/it.json index 7045f182a9cd..d48631c9aa9f 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -114,6 +114,16 @@ "shapeMessage": "Go supporta 2 vCPU, 4 GiB di RAM e 16 GiB di disco.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "Quota di calcolo {plan} esaurita", + "computeMessage": "Il tuo piano {plan} ha usato le sue {included} ore di calcolo per questo periodo di fatturazione. Un'ora di calcolo corrisponde a 1 vCPU con 2 GB di memoria per un'ora. Le tue macchine potranno ripartire il {resetDate}.", + "computeAction": "Le macchine in pausa conservano i loro file. Contatta il supporto se ti serve più calcolo prima del {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "Quota disco {plan} esaurita", + "storageMessage": "Il tuo piano {plan} include {included} GB di disco per tutte le macchine salvate. Le macchine salvate usano {used} GB e questa richiesta ne richiede {requested}.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Risorse Cloud VM insufficienti", "memoryMessage": "Le tue VM usano già {used} di {pool} GB di RAM. Questa VM ha bisogno di {requested} GB.", diff --git a/web/messages/ja.json b/web/messages/ja.json index c044c054ba7c..607b669a12d0 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -281,6 +281,16 @@ "shapeMessage": "Go は 2 vCPU、4 GiB RAM、16 GiB ディスクに対応しています。", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan} のコンピュート枠を使い切りました", + "computeMessage": "{plan} プランは、この請求期間の {included} コンピュート時間を使い切りました。1 コンピュート時間は、1 vCPU と 2 GB メモリを 1 時間実行した量です。マシンは {resetDate} から再び起動できます。", + "computeAction": "一時停止中のマシンのファイルは保持されます。{resetDate} より前に追加のコンピュートが必要な場合はサポートにお問い合わせください。", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan} のディスク枠を使い切りました", + "storageMessage": "{plan} プランでは、保存済みのすべてのマシンで合計 {included} GB のディスクを使用できます。保存済みマシンは {used} GB を使用しており、このリクエストには {requested} GB が必要です。", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Cloud VM のリソースが足りません", "memoryMessage": "VM はすでに {pool} GB の RAM のうち {used} GB を使っています。この VM には {requested} GB が必要です。", @@ -713,7 +723,7 @@ "banners": { "cancelled": "現在の請求期間の終了時にプランがキャンセルされます。", "resumed": "プランは再開され、通常どおり更新されます。", - "nosub": "このアカウントに有効なStripeサブスクリプションは見つかりませんでした。", + "nosub": "このアカウントに有効なWebサブスクリプションは見つかりませんでした。", "error": "請求を更新できませんでした。しばらくしてから再試行してください。", "pastDue": "最新のお支払いに失敗しました。プランを継続するには支払い方法を更新してください。", "team_admin_required": "このチームの請求を管理できるのはチーム管理者のみです。", @@ -4810,11 +4820,11 @@ "metaDescription": "cmux Cloudマシンの分離方法、認証情報の保管場所、送信ネットワークポリシーでマシンの接続先を制限する方法を説明します。", "intro": "このページでは、cmux Cloudマシンのセキュリティモデルを説明します。マシンを分離するもの、認証情報の保管場所、ネットワーク上でマシンが接続できる宛先を制限する方法です。", "isolationTitle": "分離", - "isolationVm": "各マシンは、専用のLinuxカーネルを持つFreestyleのmicroVMです。共有カーネル上のコンテナではありません。", + "isolationVm": "各マシンは、専用のLinuxカーネルを持つmicroVMです。共有カーネル上のコンテナではありません。", "isolationRoot": "マシン内では、設計上パスワードなしでrootになれます。セキュリティ境界はVMであり、VM内のユーザーアカウントではありません。エージェントを含め、マシン内のすべてのプロセスはrootと同じ操作ができます。", "isolationHome": "マシンを再作成すると、ホームディレクトリがリセットされます。既知の状態に戻すには、マシンを再作成してください。", "credentialsTitle": "認証情報", - "credentialsModel": "CodeRouterのモデル認証情報がマシンに入ることはありません。リクエストがマシンを出た後に、FreestyleのTLSエッジが認証情報を追加します。プロバイダーはこれらのヘッダー値を書き込み専用で保存するため、cmuxが設定した後は誰も読み出せません。", + "credentialsModel": "CodeRouterのモデル認証情報がマシンに入ることはありません。リクエストがマシンを出た後に、cmux CloudのTLSエッジが認証情報を追加します。プロバイダーはこれらのヘッダー値を書き込み専用で保存するため、cmuxが設定した後は誰も読み出せません。", "credentialsAttach": "クライアントがマシン上でセッションを開くためのアタッチ認証情報は短時間だけ有効で、1台のマシンと1つのセッションに限定されます。", "previewsTitle": "ポートプレビュー", "previewsDesc": "ポートプレビューは、あなたのプライベートWireGuardネットワークを通ります。公開URLはありません。プライベートネットワーク上のデバイスだけが開けます。", diff --git a/web/messages/km.json b/web/messages/km.json index f3197f78c9b8..f0949ea2a486 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -114,6 +114,16 @@ "shapeMessage": "Go គាំទ្រ 2 vCPU, RAM 4 GiB និងថាស 16 GiB។", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "បានប្រើកូតាគណនា {plan} អស់ហើយ", + "computeMessage": "គម្រោង {plan} របស់អ្នកបានប្រើម៉ោងគណនា {included} សម្រាប់រយៈពេលចេញវិក្កយបត្រនេះអស់ហើយ។ ម៉ោងគណនាមួយគឺ 1 vCPU ជាមួយអង្គចងចាំ 2 GB ដំណើរការរយៈពេលមួយម៉ោង។ ម៉ាស៊ីនរបស់អ្នកអាចចាប់ផ្តើមម្តងទៀតនៅ {resetDate}។", + "computeAction": "ម៉ាស៊ីនដែលបានផ្អាករក្សាទុកឯកសាររបស់វា។ សូមទាក់ទងផ្នែកជំនួយ ប្រសិនបើអ្នកត្រូវការការគណនាបន្ថែមមុន {resetDate}។", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "បានប្រើកូតាឌីស {plan} អស់ហើយ", + "storageMessage": "គម្រោង {plan} របស់អ្នករួមបញ្ចូលឌីសសរុប {included} GB សម្រាប់ម៉ាស៊ីនដែលបានរក្សាទុកទាំងអស់។ ម៉ាស៊ីនដែលបានរក្សាទុកប្រើ {used} GB ហើយសំណើនេះត្រូវការ {requested} GB។", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "ធនធាន Cloud VM មិនគ្រប់គ្រាន់", "memoryMessage": "VM របស់អ្នកបានប្រើ RAM {used} ក្នុងចំណោម {pool} GB រួចហើយ។ VM នេះត្រូវការ {requested} GB។", diff --git a/web/messages/ko.json b/web/messages/ko.json index 8152f1b756a1..0923f2ebdb61 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -114,6 +114,16 @@ "shapeMessage": "Go는 2 vCPU, 4 GiB 메모리, 16 GiB 디스크를 지원합니다.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan} 컴퓨팅 한도를 모두 사용했습니다", + "computeMessage": "{plan} 플랜이 이번 청구 기간의 {included} 컴퓨팅 시간을 모두 사용했습니다. 1 컴퓨팅 시간은 2 GB 메모리의 vCPU 1개를 1시간 실행한 양입니다. 머신은 {resetDate}부터 다시 시작할 수 있습니다.", + "computeAction": "일시 중지된 머신의 파일은 유지됩니다. {resetDate} 전에 더 많은 컴퓨팅이 필요하면 지원팀에 문의하세요.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan} 디스크 한도를 모두 사용했습니다", + "storageMessage": "{plan} 플랜에는 저장된 모든 머신을 합쳐 {included} GB의 디스크가 포함됩니다. 저장된 머신이 {used} GB를 사용 중이며 이 요청에는 {requested} GB가 필요합니다.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Cloud VM 리소스가 부족합니다", "memoryMessage": "VM이 이미 {pool} GB RAM 중 {used} GB를 사용하고 있습니다. 이 VM에는 {requested} GB가 필요합니다.", diff --git a/web/messages/no.json b/web/messages/no.json index e787a89240bf..025eb879a6c2 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -114,6 +114,16 @@ "shapeMessage": "Go støtter 2 vCPU, 4 GiB RAM og 16 GiB disk.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan}-regnekvoten er brukt opp", + "computeMessage": "{plan}-planen din har brukt sine {included} regnetimer i denne faktureringsperioden. En regnetime er 1 vCPU med 2 GB minne i én time. Maskinene dine kan starte igjen {resetDate}.", + "computeAction": "Pausede maskiner beholder filene sine. Kontakt support hvis du trenger mer regnekraft før {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan}-diskkvoten er brukt opp", + "storageMessage": "{plan}-planen din inkluderer {included} GB disk totalt for alle lagrede maskiner. Lagrede maskiner bruker {used} GB, og denne forespørselen trenger {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Ikke nok Cloud VM-ressurser", "memoryMessage": "VM-ene dine bruker allerede {used} av {pool} GB RAM. Denne VM-en trenger {requested} GB.", diff --git a/web/messages/pl.json b/web/messages/pl.json index f696a7606100..669bd8c8757f 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -114,6 +114,16 @@ "shapeMessage": "Go obsługuje 2 vCPU, 4 GiB RAM i 16 GiB dysku.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "Limit obliczeń planu {plan} wyczerpany", + "computeMessage": "Twój plan {plan} wykorzystał {included} godzin obliczeniowych w tym okresie rozliczeniowym. Godzina obliczeniowa to 1 vCPU z 2 GB pamięci przez jedną godzinę. Maszyny będzie można ponownie uruchomić {resetDate}.", + "computeAction": "Wstrzymane maszyny zachowują swoje pliki. Skontaktuj się z pomocą techniczną, jeśli potrzebujesz więcej mocy obliczeniowej przed {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "Limit dysku planu {plan} wyczerpany", + "storageMessage": "Twój plan {plan} obejmuje łącznie {included} GB dysku dla wszystkich zapisanych maszyn. Zapisane maszyny zajmują {used} GB, a to żądanie wymaga {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Za mało zasobów Cloud VM", "memoryMessage": "Twoje maszyny VM używają już {used} z {pool} GB RAM. Ta maszyna VM potrzebuje {requested} GB.", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index ac60b37a60f8..8f2555450eb6 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -114,6 +114,16 @@ "shapeMessage": "Go suporta 2 vCPU, 4 GiB de RAM e 16 GiB de disco.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "Cota de computação do {plan} esgotada", + "computeMessage": "Seu plano {plan} usou as {included} horas de computação deste período de cobrança. Uma hora de computação é 1 vCPU com 2 GB de memória durante uma hora. Suas máquinas podem iniciar novamente em {resetDate}.", + "computeAction": "Máquinas pausadas mantêm seus arquivos. Fale com o suporte se precisar de mais computação antes de {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "Cota de disco do {plan} esgotada", + "storageMessage": "Seu plano {plan} inclui {included} GB de disco para todas as máquinas salvas. As máquinas salvas usam {used} GB, e esta solicitação precisa de {requested} GB.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Recursos de Cloud VM insuficientes", "memoryMessage": "Suas VMs já usam {used} de {pool} GB de RAM. Esta VM precisa de {requested} GB.", diff --git a/web/messages/ru.json b/web/messages/ru.json index 0ca32784f077..71315eee34b8 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -114,6 +114,16 @@ "shapeMessage": "Go поддерживает 2 vCPU, 4 GiB ОЗУ и 16 GiB диска.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "Вычислительный лимит {plan} исчерпан", + "computeMessage": "Тариф {plan} израсходовал {included} вычислительных часов за этот расчётный период. Вычислительный час — это 1 vCPU с 2 ГБ памяти в течение одного часа. Машины снова можно будет запустить {resetDate}.", + "computeAction": "Приостановленные машины сохраняют свои файлы. Обратитесь в поддержку, если вам нужно больше вычислений до {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "Лимит диска {plan} исчерпан", + "storageMessage": "Тариф {plan} включает {included} ГБ диска на все сохранённые машины. Сохранённые машины занимают {used} ГБ, а этому запросу нужно {requested} ГБ.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Недостаточно ресурсов Cloud VM", "memoryMessage": "Ваши ВМ уже используют {used} из {pool} ГБ RAM. Этой ВМ нужно {requested} ГБ.", diff --git a/web/messages/th.json b/web/messages/th.json index 01c9e1c05270..a77fa181d32a 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -114,6 +114,16 @@ "shapeMessage": "Go รองรับ 2 vCPU, RAM 4 GiB และดิสก์ 16 GiB", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "ใช้โควตาการประมวลผลของ {plan} หมดแล้ว", + "computeMessage": "แพ็กเกจ {plan} ของคุณใช้ชั่วโมงประมวลผล {included} ชั่วโมงของรอบการเรียกเก็บเงินนี้หมดแล้ว หนึ่งชั่วโมงประมวลผลคือ 1 vCPU พร้อมหน่วยความจำ 2 GB ทำงานหนึ่งชั่วโมง เครื่องของคุณจะเริ่มทำงานได้อีกครั้งในวันที่ {resetDate}", + "computeAction": "เครื่องที่หยุดชั่วคราวจะเก็บไฟล์ไว้ โปรดติดต่อฝ่ายสนับสนุนหากต้องการการประมวลผลเพิ่มก่อนวันที่ {resetDate}", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "ใช้โควตาดิสก์ของ {plan} หมดแล้ว", + "storageMessage": "แพ็กเกจ {plan} ของคุณมีดิสก์รวม {included} GB สำหรับเครื่องที่บันทึกไว้ทั้งหมด เครื่องที่บันทึกไว้ใช้ {used} GB และคำขอนี้ต้องการ {requested} GB", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "ทรัพยากร Cloud VM ไม่พอ", "memoryMessage": "VM ของคุณใช้ RAM ไปแล้ว {used} จาก {pool} GB VM นี้ต้องใช้ {requested} GB", diff --git a/web/messages/tr.json b/web/messages/tr.json index 506a4814fa8d..06163690e261 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -114,6 +114,16 @@ "shapeMessage": "Go, 2 vCPU, 4 GiB RAM ve 16 GiB disk destekler.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan} işlem kotası doldu", + "computeMessage": "{plan} planınız bu fatura dönemindeki {included} işlem saatini kullandı. Bir işlem saati, 2 GB bellekli 1 vCPU'nun bir saat çalışmasıdır. Makineleriniz {resetDate} tarihinde yeniden başlatılabilir.", + "computeAction": "Duraklatılan makineler dosyalarını korur. {resetDate} tarihinden önce daha fazla işlem gücüne ihtiyacınız varsa destekle iletişime geçin.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan} disk kotası doldu", + "storageMessage": "{plan} planınız, kayıtlı tüm makineler için toplam {included} GB disk içerir. Kayıtlı makineler {used} GB kullanıyor ve bu istek {requested} GB gerektiriyor.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Yeterli Cloud VM kaynağı yok", "memoryMessage": "VM'leriniz zaten {pool} GB RAM'in {used} GB'ını kullanıyor. Bu VM'nin {requested} GB'a ihtiyacı var.", diff --git a/web/messages/uk.json b/web/messages/uk.json index 11966d475d85..72a137cf3539 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -114,6 +114,16 @@ "shapeMessage": "Go підтримує 2 vCPU, 4 GiB оперативної пам’яті та 16 GiB диска.", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "Обчислювальний ліміт {plan} вичерпано", + "computeMessage": "Тариф {plan} використав {included} обчислювальних годин за цей розрахунковий період. Обчислювальна година — це 1 vCPU з 2 ГБ пам'яті протягом однієї години. Машини знову можна буде запустити {resetDate}.", + "computeAction": "Призупинені машини зберігають свої файли. Зверніться до підтримки, якщо вам потрібно більше обчислень до {resetDate}.", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "Ліміт диска {plan} вичерпано", + "storageMessage": "Тариф {plan} включає {included} ГБ диска на всі збережені машини. Збережені машини займають {used} ГБ, а цьому запиту потрібно {requested} ГБ.", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Недостатньо ресурсів Cloud VM", "memoryMessage": "Ваші ВМ уже використовують {used} з {pool} ГБ RAM. Цій ВМ потрібно {requested} ГБ.", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index ae01f60efff2..63bdf642ec0c 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -114,6 +114,16 @@ "shapeMessage": "Go 支持 2 vCPU、4 GiB 内存和 16 GiB 磁盘。", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan} 计算额度已用完", + "computeMessage": "你的 {plan} 方案已用完本计费周期的 {included} 计算小时。1 计算小时是 1 个 vCPU 搭配 2 GB 内存运行一小时。你的机器可在 {resetDate} 再次启动。", + "computeAction": "已暂停的机器会保留其文件。如需在 {resetDate} 之前获得更多计算资源,请联系支持团队。", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan} 磁盘额度已用完", + "storageMessage": "你的 {plan} 方案为所有已保存的机器总共提供 {included} GB 磁盘。已保存的机器使用了 {used} GB,此请求需要 {requested} GB。", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Cloud VM 资源不足", "memoryMessage": "你的 VM 已使用 {pool} GB 内存中的 {used} GB。此 VM 需要 {requested} GB。", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 582afa96123b..e8cba710fed3 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -114,6 +114,16 @@ "shapeMessage": "Go 支援 2 vCPU、4 GiB 記憶體和 16 GiB 磁碟。", "shapeAction": "cmux vm new --size 4g\ncmux billing checkout --plan pro" }, + "planBudget": { + "computeTitle": "{plan} 運算額度已用完", + "computeMessage": "你的 {plan} 方案已用完本計費週期的 {included} 運算小時。1 運算小時是 1 個 vCPU 搭配 2 GB 記憶體執行一小時。你的機器可在 {resetDate} 再次啟動。", + "computeAction": "已暫停的機器會保留其檔案。如需在 {resetDate} 之前取得更多運算資源,請聯絡支援團隊。", + "computeActionUpgrade": "cmux billing checkout --plan max", + "storageTitle": "{plan} 磁碟額度已用完", + "storageMessage": "你的 {plan} 方案為所有已儲存的機器總共提供 {included} GB 磁碟。已儲存的機器使用了 {used} GB,此請求需要 {requested} GB。", + "storageAction": "cmux vm ls\ncmux vm rm ", + "storageActionUpgrade": "cmux vm ls\ncmux vm rm \ncmux billing checkout --plan max" + }, "resourcePool": { "title": "Cloud VM 資源不足", "memoryMessage": "你的 VM 已使用 {pool} GB RAM 中的 {used} GB。這台 VM 需要 {requested} GB。", diff --git a/web/services/billing/analytics/query.ts b/web/services/billing/analytics/query.ts index f70ffaa2ed54..183b8f8aa1d1 100644 --- a/web/services/billing/analytics/query.ts +++ b/web/services/billing/analytics/query.ts @@ -307,7 +307,7 @@ export type SubscriptionAnalyticsReport = SubscriptionMetrics & { }; }; -/** `..monthly` names its plan; anything else has none. */ +/** `..` names its plan; anything else has none. */ function planFromProductId(productId: string | null): string | null { const parts = productId?.split(".") ?? []; return parts.length >= 3 ? parts[parts.length - 2] ?? null : null; diff --git a/web/services/billing/analytics/records.ts b/web/services/billing/analytics/records.ts index 555e58d5b5d3..984f38f44e56 100644 --- a/web/services/billing/analytics/records.ts +++ b/web/services/billing/analytics/records.ts @@ -7,7 +7,7 @@ // gross price in USD. Every metric is a question about which intervals cover // a moment in time. -import { APPLE_USD_LIST_PRICE } from "../apple/config"; +import { APPLE_USD_LIST_PRICE, appleUsdListPrice } from "../apple/config"; import { GO_PRICING_USD, MAX_PRICING_USD, PRO_PRICING_USD, TEAM_PRICING_USD } from "../plans"; import { toUsd } from "./fx"; @@ -123,8 +123,12 @@ export function applePriceUsd( planId: string, priceMilliunits: number | null, currency: string | null, + /** Months the charge covers; 11 or more is a yearly product's list price. */ + intervalMonths = 1, ): ApplePrice { - const listPrice = (APPLE_USD_LIST_PRICE as Readonly>)[planId] ?? 0; + const listPrice = planId in APPLE_USD_LIST_PRICE + ? appleUsdListPrice(planId as keyof typeof APPLE_USD_LIST_PRICE, intervalMonths >= 11 ? "yearly" : "monthly") + : 0; if (priceMilliunits === null) return { usd: listPrice, fallback: true, paid: true }; if (priceMilliunits <= 0) return { usd: 0, fallback: false, paid: false }; const converted = toUsd(priceMilliunits / 1000, currency); @@ -141,8 +145,8 @@ function appleTransactionInterval( const end = tx.revokedAt && tx.revokedAt < tx.expiresAt ? tx.revokedAt : tx.expiresAt; if (end <= tx.purchaseDate) return null; const planId = tx.planId ?? fallbackPlan; - const price = applePriceUsd(planId, tx.priceMilliunits, tx.currency); const months = periodMonths(tx.purchaseDate, tx.expiresAt); + const price = applePriceUsd(planId, tx.priceMilliunits, tx.currency, months); return { interval: { start: tx.purchaseDate, @@ -246,7 +250,8 @@ export function appleRefundEvents( for (const tx of transactions) { if (!tx.revokedAt || (tx.revocationReason !== 0 && tx.revocationReason !== 1)) continue; const planId = tx.planId ?? row.planId; - const price = applePriceUsd(planId, tx.priceMilliunits, tx.currency); + const months = tx.purchaseDate ? periodMonths(tx.purchaseDate, tx.expiresAt) : 1; + const price = applePriceUsd(planId, tx.priceMilliunits, tx.currency, months); refunds.push({ source: "apple", id: tx.transactionId, diff --git a/web/services/billing/apple/accountToken.ts b/web/services/billing/apple/accountToken.ts index cb8fc5c83840..6316a21b412f 100644 --- a/web/services/billing/apple/accountToken.ts +++ b/web/services/billing/apple/accountToken.ts @@ -12,7 +12,9 @@ import { APP_STORE_BUNDLE_ID, appleBundleIds, appleEnvironmentGrantsEntitlement, + APPLE_PLAN_BILLING_PERIODS, appleProductId, + type AppleBillingPeriod, isAcceptedAppleBundleId, isSignedAppleEnvironment, type SignedAppleEnvironment, @@ -25,6 +27,11 @@ export type AppleIneligibleReason = "stripe_subscription_active" | "team_billing export type AppleAccountTokenApp = { readonly bundleId: string | null; readonly storeKitEnvironment: SignedAppleEnvironment | null; + /** + * Periods the app can show (`x-cmux-billing-periods`). Older apps list + * every product in one plan list, so they get monthly products only. + */ + readonly billingPeriods?: readonly AppleBillingPeriod[]; }; export type AppleAccountTokenResponse = { @@ -36,10 +43,19 @@ export type AppleAccountTokenResponse = { readonly source: PersonalBillingSource; readonly manageUrl?: string; }; - readonly products: readonly { readonly productId: string; readonly planId: PersonalPlanId }[]; + readonly products: readonly { + readonly productId: string; + readonly planId: PersonalPlanId; + readonly billingPeriod: AppleBillingPeriod; + }[]; }; -export type AppleAccountUser = ProReconcileUser & BillingTeamUserLike & { readonly id: string }; +export type AppleAccountUser = ProReconcileUser & BillingTeamUserLike & { + readonly id: string; + /** The signed-in account's primary email; production Sandbox purchases need it verified and on the allowlist. */ + readonly primaryEmail?: string | null; + readonly primaryEmailVerified?: boolean; +}; export type AppleAccountTokenDependencies = { readonly store: Pick; @@ -83,6 +99,12 @@ export function requestedAppleBundleId(header: string | null): string | null { return isAcceptedAppleBundleId(value) ? value : null; } +/** `x-cmux-billing-periods: monthly,yearly`: the periods this app can show; monthly always. */ +export function requestedBillingPeriods(header: string | null): readonly AppleBillingPeriod[] { + const values = new Set((header ?? "").split(",").map((value) => value.trim().toLowerCase())); + return values.has("yearly") ? ["monthly", "yearly"] : ["monthly"]; +} + /** `x-cmux-storekit-environment`: `Sandbox` or `Production`, else unknown. */ export function requestedStoreKitEnvironment(header: string | null): SignedAppleEnvironment | null { const value = header?.trim(); @@ -94,12 +116,18 @@ export function requestedStoreKitEnvironment(header: string | null): SignedApple * builds always buy in the Sandbox and App Store builds in Production, so an * app that did not say which applies is assumed Sandbox unless it is the App * Store bundle. Selling a purchase that grants nothing would show a completed - * purchase with no plan. + * purchase with no plan. In production a Sandbox purchase grants only for an + * allowlisted account email, so every other TestFlight tester sees no + * products instead of a purchase that grants nothing. */ -function purchasesGrant(app: AppleAccountTokenApp, env: Record): boolean { +function purchasesGrant( + app: AppleAccountTokenApp, + accountEmail: string | null | undefined, + env: Record, +): boolean { if (!app.bundleId) return false; const environment = app.storeKitEnvironment ?? (app.bundleId === APP_STORE_BUNDLE_ID ? "Production" : "Sandbox"); - return appleEnvironmentGrantsEntitlement({ environment, bundleId: app.bundleId }, env); + return appleEnvironmentGrantsEntitlement({ environment, bundleId: app.bundleId, accountEmail }, env); } /** `POST /api/billing/apple/account-token`. */ @@ -118,7 +146,7 @@ export async function appleAccountTokenResponse( const reason = ineligibleReason({ stripeActive, teamBilled, - purchasesGrant: purchasesGrant(app, deps.env ?? process.env), + purchasesGrant: purchasesGrant(app, user.primaryEmailVerified ? user.primaryEmail : null, deps.env ?? process.env), }); const plans: PersonalPlanId[] = goEnabled ? ["go", "pro", "max"] : ["pro", "max"]; const { bundleId } = app; @@ -132,7 +160,9 @@ export async function appleAccountTokenResponse( ...(status.manageUrl ? { manageUrl: status.manageUrl } : {}), }, products: bundleId && reason !== "purchases_unavailable" - ? plans.map((planId) => ({ productId: appleProductId(bundleId, planId), planId })) + ? plans.flatMap((planId) => APPLE_PLAN_BILLING_PERIODS[planId] + .filter((billingPeriod) => (app.billingPeriods ?? ["monthly"]).includes(billingPeriod)) + .map((billingPeriod) => ({ productId: appleProductId(bundleId, planId, billingPeriod), planId, billingPeriod }))) : [], }; } diff --git a/web/services/billing/apple/config.ts b/web/services/billing/apple/config.ts index 45527b94bbe6..08f043a1e62a 100644 --- a/web/services/billing/apple/config.ts +++ b/web/services/billing/apple/config.ts @@ -32,6 +32,31 @@ export const APPLE_USD_LIST_PRICE: Readonly> = { max: 299.99, }; +/** App Store billing periods, the last segment of a product ID. */ +export const APPLE_BILLING_PERIODS = ["monthly", "yearly"] as const; +export type AppleBillingPeriod = (typeof APPLE_BILLING_PERIODS)[number]; + +/** + * Periods each plan sells, matching the web prices (plans.ts): Pro and Max + * monthly or yearly, Go monthly only. + */ +export const APPLE_PLAN_BILLING_PERIODS: Readonly> = { + go: ["monthly"], + pro: ["monthly", "yearly"], + max: ["monthly", "yearly"], +}; + +/** + * USD estimate for one yearly charge when a transaction is in another + * currency: the monthly list price with the web's 20% yearly discount + * (12 months for the price of 9.6). Replace with the App Store yearly prices + * once they are set. + */ +export function appleUsdListPrice(planId: PersonalPlanId, period: AppleBillingPeriod): number { + const monthly = APPLE_USD_LIST_PRICE[planId]; + return period === "yearly" ? Math.round(monthly * 9.6 * 100) / 100 : monthly; +} + export function isSignedAppleEnvironment(value: unknown): value is SignedAppleEnvironment { return value === "Production" || value === "Sandbox"; } @@ -91,16 +116,36 @@ export function appleServerApiCredentials(env: Env = process.env): AppleServerAp return { keyId, issuerId, privateKey }; } +/** + * Lowercased cmux account emails whose Sandbox purchases of the App Store + * bundle grant a plan in production (`APPLE_IAP_SANDBOX_ACCOUNT_EMAILS`, + * comma-separated). App Review's demo account must be listed, or App Review + * sees no products. + */ +export function appleSandboxAccountEmails(env: Env = process.env): readonly string[] { + return (list(env.APPLE_IAP_SANDBOX_ACCOUNT_EMAILS) ?? []).map((email) => email.toLowerCase()); +} + +/** + * Whether the Sandbox decision depends on the cmux account's email. True only + * in a production deployment without an `APPLE_IAP_SANDBOX_ENTITLEMENTS` + * override, so other callers never need to read the email. + */ +export function appleSandboxGrantNeedsAccountEmail(env: Env = process.env): boolean { + return env.VERCEL_ENV === "production" && trimmed(env.APPLE_IAP_SANDBOX_ENTITLEMENTS) === null; +} + /** * Whether a Sandbox subscription may grant a real plan here. Sandbox * purchases are free, so production grants them only for the App Store - * bundle, which is how App Review exercises purchases. TestFlight builds of - * the beta bundle stay unentitled in production. Other deployments grant - * every Sandbox purchase. `APPLE_IAP_SANDBOX_ENTITLEMENTS` overrides with - * `all`, `none`, or a comma-separated bundle list. + * bundle and only to cmux accounts in `APPLE_IAP_SANDBOX_ACCOUNT_EMAILS` + * (App Review's demo account and the team's own testers). Any other TestFlight + * tester stays unentitled. Other deployments grant every Sandbox purchase. + * `APPLE_IAP_SANDBOX_ENTITLEMENTS` overrides both with `all`, `none`, or a + * comma-separated bundle list, and then the email list is not consulted. */ export function appleEnvironmentGrantsEntitlement( - input: { readonly environment: string; readonly bundleId: string }, + input: { readonly environment: string; readonly bundleId: string; readonly accountEmail?: string | null }, env: Env = process.env, ): boolean { if (input.environment === "Production") return true; @@ -109,17 +154,37 @@ export function appleEnvironmentGrantsEntitlement( if (override === "all") return true; if (override === "none") return false; if (override) return list(override)?.includes(input.bundleId) ?? false; - if (env.VERCEL_ENV === "production") return input.bundleId === APP_STORE_BUNDLE_ID; - return true; + if (env.VERCEL_ENV !== "production") return true; + if (input.bundleId !== APP_STORE_BUNDLE_ID) return false; + const email = input.accountEmail?.trim().toLowerCase(); + return email ? appleSandboxAccountEmails(env).includes(email) : false; } -/** `..monthly`, the product ID for one plan in one app. */ -export function appleProductId(bundleId: string, planId: PersonalPlanId): string { - return `${bundleId}.${planId}.monthly`; +/** `..`, the product ID for one plan and period in one app. */ +export function appleProductId(bundleId: string, planId: PersonalPlanId, period: AppleBillingPeriod = "monthly"): string { + return `${bundleId}.${planId}.${period}`; +} + +/** The plan and period an Apple product sells, or null for a product this server does not know. */ +export function appleProductFor( + productId: unknown, + bundleId: unknown, +): { readonly planId: PersonalPlanId; readonly billingPeriod: AppleBillingPeriod } | null { + if (typeof productId !== "string" || typeof bundleId !== "string") return null; + for (const planId of APPLE_PLAN_IDS) { + for (const billingPeriod of APPLE_PLAN_BILLING_PERIODS[planId]) { + if (appleProductId(bundleId, planId, billingPeriod) === productId) return { planId, billingPeriod }; + } + } + return null; } /** The plan an Apple product sells, or null for a product this server does not know. */ export function planIdForAppleProduct(productId: unknown, bundleId: unknown): PersonalPlanId | null { - if (typeof productId !== "string" || typeof bundleId !== "string") return null; - return APPLE_PLAN_IDS.find((planId) => appleProductId(bundleId, planId) === productId) ?? null; + return appleProductFor(productId, bundleId)?.planId ?? null; +} + +/** The billing period of a known product ID (`monthly` when unknown). */ +export function appleBillingPeriodForProduct(productId: string, bundleId: string): AppleBillingPeriod { + return appleProductFor(productId, bundleId)?.billingPeriod ?? "monthly"; } diff --git a/web/services/billing/apple/entitlement.ts b/web/services/billing/apple/entitlement.ts index 10b636902145..ff178d828062 100644 --- a/web/services/billing/apple/entitlement.ts +++ b/web/services/billing/apple/entitlement.ts @@ -7,7 +7,7 @@ import { cloudDb } from "../../../db/client"; import { appleSubscriptions, type AppleSubscriptionStatus } from "../../../db/schema"; import type { PersonalPlanId } from "../pro"; import { planTierRank } from "../planTier"; -import { appleEnvironmentGrantsEntitlement } from "./config"; +import { APP_STORE_BUNDLE_ID, appleEnvironmentGrantsEntitlement, appleSandboxGrantNeedsAccountEmail } from "./config"; /** Personal plans sold in the App Store; the best grant wins by tier. */ const APPLE_PLAN_IDS: ReadonlySet = new Set(["go", "pro", "max"]); @@ -23,6 +23,8 @@ export type AppleEntitlementRow = { readonly expiresAt: Date | null; readonly gracePeriodExpiresAt: Date | null; readonly autoRenewEnabled?: boolean | null; + /** Purchase date of the newest transaction: the start of the current period. */ + readonly purchaseDate?: Date | null; }; @@ -40,9 +42,10 @@ export function appleSubscriptionGrantsAccess( row: AppleEntitlementRow, now: Date = new Date(), env: Env = process.env, + accountEmail: string | null = null, ): boolean { if (!APPLE_PLAN_IDS.has(row.planId)) return false; - if (!appleEnvironmentGrantsEntitlement(row, env)) return false; + if (!appleEnvironmentGrantsEntitlement({ ...row, accountEmail }, env)) return false; switch (row.status) { case "active": return isAfter(row.expiresAt, now); @@ -60,10 +63,11 @@ export function bestAppleEntitlement( rows: readonly Row[], now: Date = new Date(), env: Env = process.env, + accountEmail: string | null = null, ): Row | null { let best: Row | null = null; for (const row of rows) { - if (!appleSubscriptionGrantsAccess(row, now, env)) continue; + if (!appleSubscriptionGrantsAccess(row, now, env, accountEmail)) continue; if (!best || planTierRank(row.planId) > planTierRank(best.planId)) best = row; } return best; @@ -80,19 +84,42 @@ export async function appleSubscriptionRowsForUser(userId: string): Promise { + const { getStackServerApp, isStackConfigured } = await import("../../../app/lib/stack"); + if (!isStackConfigured()) return null; + const user = await getStackServerApp().getUser(userId); + return user?.primaryEmailVerified ? user.primaryEmail ?? null : null; +} + +export type AppleEntitlementLookupOptions = { + readonly now?: Date; + readonly env?: Env; + /** Defaults to the Stack account's primary email. */ + readonly accountEmail?: (userId: string) => Promise; +}; + /** * The user's granting Apple subscription (highest plan), or null. A * deployment without a database grants nothing, like the Stripe query. */ export async function activeAppleEntitlementForUser( userId: string, - now: Date = new Date(), + options: AppleEntitlementLookupOptions | Date = {}, ): Promise { + const { now = new Date(), env = process.env, accountEmail = stackPrimaryEmail } = + options instanceof Date ? { now: options } : options; let rows: AppleEntitlementRow[]; try { rows = await appleSubscriptionRowsForUser(userId); @@ -101,7 +128,12 @@ export async function activeAppleEntitlementForUser( throw error; } // Lightweight database doubles in older tests may return non-array values. - return Array.isArray(rows) ? bestAppleEntitlement(rows, now) : null; + if (!Array.isArray(rows)) return null; + const needsEmail = appleSandboxGrantNeedsAccountEmail(env) && + rows.some((row) => row.environment === "Sandbox" && row.bundleId === APP_STORE_BUNDLE_ID && + appleSubscriptionGrantsAccess(row, now, {})); + const email = needsEmail ? await accountEmail(userId) : null; + return bestAppleEntitlement(rows, now, env, email); } export async function activeApplePlanForUser(userId: string): Promise { diff --git a/web/services/billing/apple/service.ts b/web/services/billing/apple/service.ts index 6a97522a53fd..6ea09e94ac99 100644 --- a/web/services/billing/apple/service.ts +++ b/web/services/billing/apple/service.ts @@ -11,7 +11,8 @@ import type { } from "@apple/app-store-server-library"; import { captureServerEvent, type ServerEventInput } from "../../analytics/serverEvents"; -import { isSignedAppleEnvironment } from "./config"; +import { appleBillingPeriodForProduct, isSignedAppleEnvironment } from "./config"; +import { activeAppleEntitlementForUser, type AppleEntitlementRow } from "./entitlement"; import { appleServerApi, type AppleServerApi } from "./serverApi"; import { appleRevenueEstimate, @@ -42,6 +43,11 @@ export type AppleIapDependencies = { /** Re-derives the user's `cmuxPlan` mirror from Stripe and Apple. */ readonly applyEntitlement: (userId: string) => Promise; readonly captureEvent: (input: ServerEventInput) => Promise; + /** + * The user's granting Apple subscription after a write, used to report + * `subscription_entitlement_granted`. Without it no grant event is sent. + */ + readonly activeEntitlement?: (userId: string) => Promise; readonly now: () => Date; }; @@ -84,6 +90,7 @@ export function defaultAppleIapDependencies(): AppleIapDependencies { await applyPersonalPlanEntitlement(userId); }, captureEvent: (input) => captureServerEvent(input), + activeEntitlement: (userId) => activeAppleEntitlementForUser(userId), now: () => new Date(), }; return defaultDependencies; @@ -202,6 +209,7 @@ export async function recordClientAppleTransaction( await deps.store.recordTransaction(appleTransactionRowFrom(fresh.transaction, input.userId)); } await applyEntitlements(write, deps); + await captureEntitlementGranted(written, deps); return { planId: written.planId, status: written.status, @@ -298,7 +306,12 @@ export async function receiveAppleNotification( type NotificationApplication = | { readonly kind: "skip"; readonly reason: string } - | { readonly kind: "applied"; readonly userId: string; readonly state: AppleSubscriptionState }; + | { + readonly kind: "applied"; + readonly userId: string; + readonly state: AppleSubscriptionState; + readonly written: AppleSubscriptionRow; + }; async function applyNotification( row: AppleNotificationRow, @@ -346,7 +359,7 @@ async function applyNotification( await deps.store.recordTransaction(appleTransactionRowFrom(current.transaction, userId)); } await applyEntitlements(write, deps); - return { kind: "applied", userId, state }; + return { kind: "applied", userId, state, written: write.current }; } /** @@ -398,9 +411,49 @@ export async function processAppleNotification( } await deps.store.markNotificationProcessed(row.notificationUuid, deps.now()); await captureLifecycleEvent(row, application.userId, application.state, deps); + await captureEntitlementGranted(application.written, deps); return "processed"; } +/** + * `subscription_entitlement_granted` once the written subscription is the + * plan the user now holds through Apple. Sent from both the app-posted + * transaction and the notification for the same transaction; the shared + * insert ID keeps one event per transaction (a purchase, then each renewal). + * Best effort: analytics never fails a purchase. + */ +async function captureEntitlementGranted( + written: AppleSubscriptionRow, + deps: AppleIapDependencies, +): Promise { + if (!deps.activeEntitlement) return; + try { + const granting = await deps.activeEntitlement(written.userId); + if (granting?.originalTransactionId !== written.originalTransactionId) return; + await deps.captureEvent({ + event: "subscription_entitlement_granted", + distinctId: written.userId, + insertId: `apple_entitlement:${written.lastTransactionId ?? written.originalTransactionId}`, + properties: { + source: "apple", + plan_id: written.planId, + product_id: written.productId, + billing_period: appleBillingPeriodForProduct(written.productId, written.bundleId), + environment: written.environment, + original_transaction_id: written.originalTransactionId, + transaction_id: written.lastTransactionId, + status: written.status, + expires_at: written.expiresAt?.toISOString() ?? null, + }, + }); + } catch (error) { + console.warn("Apple entitlement analytics failed", { + originalTransactionId: written.originalTransactionId, + error: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200), + }); + } +} + async function captureLifecycleEvent( row: AppleNotificationRow, userId: string, @@ -420,6 +473,7 @@ async function captureLifecycleEvent( source: "apple", plan_id: state.planId, product_id: state.productId, + billing_period: appleBillingPeriodForProduct(state.productId, state.bundleId), storefront: state.storefront, currency: state.currency, price: revenue.price, diff --git a/web/services/billing/apple/state.ts b/web/services/billing/apple/state.ts index fefb0fc03ca0..93e8ecdf07d7 100644 --- a/web/services/billing/apple/state.ts +++ b/web/services/billing/apple/state.ts @@ -8,7 +8,7 @@ import type { import type { AppleSubscriptionStatus } from "../../../db/schema"; import type { PersonalPlanId } from "../pro"; -import { APPLE_USD_LIST_PRICE, planIdForAppleProduct } from "./config"; +import { appleBillingPeriodForProduct, appleUsdListPrice, planIdForAppleProduct } from "./config"; export type AppleSubscriptionState = { readonly originalTransactionId: string; @@ -255,9 +255,10 @@ const YEAR_MS = 365 * 24 * 60 * 60 * 1000; export function appleRevenueEstimate(state: Pick< AppleSubscriptionState, "planId" | "currency" | "priceMilliunits" | "purchaseDate" | "originalPurchaseDate" ->): { price: number | null; priceUsd: number; netUsd: number; commission: number } { +> & Partial>): { price: number | null; priceUsd: number; netUsd: number; commission: number } { const price = state.priceMilliunits === null ? null : state.priceMilliunits / 1000; - const priceUsd = state.currency === "USD" && price !== null ? price : APPLE_USD_LIST_PRICE[state.planId]; + const period = state.productId && state.bundleId ? appleBillingPeriodForProduct(state.productId, state.bundleId) : "monthly"; + const priceUsd = state.currency === "USD" && price !== null ? price : appleUsdListPrice(state.planId, period); const tenure = state.purchaseDate && state.originalPurchaseDate ? state.purchaseDate.getTime() - state.originalPurchaseDate.getTime() : 0; diff --git a/web/services/vms/errors.ts b/web/services/vms/errors.ts index b774703c95eb..f737076b5b62 100644 --- a/web/services/vms/errors.ts +++ b/web/services/vms/errors.ts @@ -217,6 +217,22 @@ export class VmUsageLimitExceededError extends Data.TaggedError("VmUsageLimitExc readonly usedHours: number; }> {} +/** + * A Pro or Max monthly allowance is used up: compute-hours (machines may not + * start until `resetsAt`) or the total disk of saved machines. + */ +export class VmPlanBudgetExceededError extends Data.TaggedError("VmPlanBudgetExceededError")<{ + readonly planId: "pro" | "max"; + readonly resource: "compute" | "storage"; + /** Compute-hours, or GB of disk. */ + readonly included: number; + readonly used: number; + /** GB of disk the refused request needed; 0 for compute. */ + readonly requested: number; + /** When the compute allowance resets (the end of the billing period). */ + readonly resetsAt: Date; +}> {} + export class VmSavedLimitExceededError extends Data.TaggedError("VmSavedLimitExceededError")<{ readonly limit: number; readonly current: number; @@ -318,6 +334,7 @@ export type VmWorkflowError = | VmResourcePoolExceededError | VmUsageLimitExceededError | VmSavedLimitExceededError + | VmPlanBudgetExceededError | VmGoShapeError | VmCreateCreditsInsufficientError | VmBillingError @@ -479,6 +496,7 @@ const vmWorkflowErrorTagRecord = { VmResourcePoolExceededError: true, VmUsageLimitExceededError: true, VmSavedLimitExceededError: true, + VmPlanBudgetExceededError: true, VmGoShapeError: true, VmCreateCreditsInsufficientError: true, VmBillingError: true, diff --git a/web/services/vms/goPause.ts b/web/services/vms/goPause.ts index 36343d10745b..85d634af8d8d 100644 --- a/web/services/vms/goPause.ts +++ b/web/services/vms/goPause.ts @@ -12,26 +12,54 @@ export function pauseGoVm( vm: CloudVmRow, providerVmId: string, usedSeconds?: number, +) { + return pauseForAllowance(repo, providers, vm, providerVmId, { reason: "go_hours_limit", planId: "go", capRuntime: true, usedSeconds }); +} + +/** + * Pause a Pro or Max machine whose plan used its monthly compute allowance. + * Unlike Go there is no provider runtime cap: a Pro or Max machine resumes + * through the control plane, which re-checks the allowance. + */ +export function pauseBudgetVm( + repo: VmRepositoryShape, + providers: VmProviderGatewayShape, + vm: CloudVmRow, + providerVmId: string, + usedComputeSeconds?: number, +) { + return pauseForAllowance(repo, providers, vm, providerVmId, { + reason: "compute_budget", planId: vm.billingPlanId ?? "pro", capRuntime: false, usedSeconds: usedComputeSeconds, + }); +} + +function pauseForAllowance( + repo: VmRepositoryShape, + providers: VmProviderGatewayShape, + vm: CloudVmRow, + providerVmId: string, + options: { readonly reason: string; readonly planId: string; readonly capRuntime: boolean; readonly usedSeconds?: number }, ) { return Effect.gen(function* () { - if (!providers.pause || !providers.setRuntimeBudget) { + const setRuntimeBudget = providers.setRuntimeBudget; + if (!providers.pause || (options.capRuntime && !setRuntimeBudget)) { return yield* Effect.fail(new VmOperationUnsupportedError({ provider: vm.provider, operation: "pause" })); } const mergeMetadata = repo.mergeProviderMetadata; if (!mergeMetadata) return yield* Effect.fail(new VmDatabaseError({ operation: "go_pause_intent", cause: "Durable metadata writes are unavailable" })); yield* mergeMetadata({ id: vm.id, patch: { - [GO_PAUSE_INTENT_KEY]: { requestedAt: new Date().toISOString(), reason: "go_hours_limit" }, + [GO_PAUSE_INTENT_KEY]: { requestedAt: new Date().toISOString(), reason: options.reason }, } }); - yield* providers.setRuntimeBudget(vm.provider, providerVmId, 0); + if (options.capRuntime && setRuntimeBudget) yield* setRuntimeBudget(vm.provider, providerVmId, 0); // The database may already say paused. An idempotent provider pause also // stops machines resumed outside the control plane. yield* providers.pause(vm.provider, providerVmId); const recorded = yield* repo.markProviderObservedStatus({ id: vm.id, providerVmId, status: "paused" }); if (!recorded) return yield* Effect.fail(new VmNotFoundError({ vmId: providerVmId })); yield* mergeMetadata({ id: vm.id, patch: { [GO_PAUSE_INTENT_KEY]: null } }); - yield* repo.recordUsageEvent({ userId: vm.userId, billingTeamId: vm.billingTeamId, billingPlanId: "go", + yield* repo.recordUsageEvent({ userId: vm.userId, billingTeamId: vm.billingTeamId, billingPlanId: options.planId, vmId: vm.id, eventType: "vm.paused", provider: vm.provider, imageId: vm.imageId, - metadata: { source: "go_hours_limit", usedSeconds, automated: true }, + metadata: { source: options.reason, usedSeconds: options.usedSeconds, automated: true }, }).pipe(Effect.catchAll(() => Effect.void)); }); } diff --git a/web/services/vms/goRuntimeLimits.ts b/web/services/vms/goRuntimeLimits.ts index 308e979b1dc4..2e45cf81fb80 100644 --- a/web/services/vms/goRuntimeLimits.ts +++ b/web/services/vms/goRuntimeLimits.ts @@ -1,40 +1,71 @@ -import { and, asc, eq, or, sql } from "drizzle-orm"; +import { and, asc, inArray, or, sql, eq } from "drizzle-orm"; import * as Effect from "effect/Effect"; import { cloudDb } from "../../db/client"; import { cloudVms } from "../../db/schema"; import { getGoVmUsage } from "./goUsage"; -import { GO_PAUSE_INTENT_KEY, pauseGoVm } from "./goPause"; +import { GO_PAUSE_INTENT_KEY, pauseBudgetVm, pauseGoVm } from "./goPause"; import { VmDatabaseError } from "./errors"; +import { getPlanBudgetUsage, planBudgetsEnforced, type PlanBudgetUsage } from "./planUsage"; import { VmProviderGateway } from "./providerGateway"; -import { VmRepository } from "./repository"; +import { VmRepository, type CloudVmRow } from "./repository"; -/** The minute job stops compute only after a durable runtime total reaches its cap. */ +/** + * The minute job stops compute only after a durable runtime total reaches its + * cap: Go's VM-hours, and the Pro and Max compute allowances. One allowance + * read per user per run covers all of that user's running machines. + */ export function enforceGoRuntimeLimits() { return Effect.gen(function* () { const repo = yield* VmRepository; const providers = yield* VmProviderGateway; const rows = yield* Effect.tryPromise({ try: () => cloudDb().select().from(cloudVms).where(and( - eq(cloudVms.billingPlanId, "go"), or(eq(cloudVms.status, "running"), sql`${cloudVms.providerMetadata}->'cmuxGoPauseIntent' is not null and ${cloudVms.providerMetadata}->'cmuxGoPauseIntent' <> 'null'::jsonb`), + inArray(cloudVms.billingPlanId, planBudgetsEnforced() ? ["go", "pro", "max"] : ["go"]), + or(eq(cloudVms.status, "running"), sql`${cloudVms.providerMetadata}->'cmuxGoPauseIntent' is not null and ${cloudVms.providerMetadata}->'cmuxGoPauseIntent' <> 'null'::jsonb`), )).orderBy(asc(cloudVms.updatedAt)), catch: (cause) => new VmDatabaseError({ operation: "go_runtime_candidates", cause }), }); + const budgets = new Map>(); + const budgetFor = (userId: string) => { + let usage = budgets.get(userId); + if (!usage) { + usage = getPlanBudgetUsage(userId); + budgets.set(userId, usage); + } + return usage; + }; + const clearIntent = (vm: CloudVmRow) => repo.mergeProviderMetadata + ? repo.mergeProviderMetadata({ id: vm.id, patch: { [GO_PAUSE_INTENT_KEY]: null } }) + : Effect.void; const outcomes = yield* Effect.forEach(rows, (vm) => Effect.gen(function* () { if (!vm.providerVmId) return "skipped" as const; + const pending = vm.providerMetadata[GO_PAUSE_INTENT_KEY] != null; + if (vm.billingPlanId !== "go") { + const budget = yield* Effect.tryPromise({ + try: () => budgetFor(vm.userId), + catch: (cause) => new VmDatabaseError({ operation: "plan_budget_usage", cause }), + }); + if (!budget) { + if (pending) yield* clearIntent(vm); + return "skipped" as const; + } + if (!pending && budget.remainingComputeSeconds > 0) return "skipped" as const; + yield* pauseBudgetVm(repo, providers, vm, vm.providerVmId, budget.usedComputeSeconds); + return "paused" as const; + } const usage = yield* Effect.tryPromise({ try: () => getGoVmUsage(vm.userId), catch: (cause) => new VmDatabaseError({ operation: "go_runtime_usage", cause }), }); - const pending = vm.providerMetadata[GO_PAUSE_INTENT_KEY] != null; if (!usage) { - if (pending && repo.mergeProviderMetadata) yield* repo.mergeProviderMetadata({ id: vm.id, patch: { [GO_PAUSE_INTENT_KEY]: null } }); + if (pending) yield* clearIntent(vm); return "skipped" as const; } if (!pending && usage.remainingSeconds > 0) return "skipped" as const; yield* pauseGoVm(repo, providers, vm, vm.providerVmId, usage?.usedSeconds); return "paused" as const; }).pipe(Effect.catchAll((error) => Effect.sync(() => { - console.error("[VM] Go runtime enforcement failed", { vmId: vm.id, error }); + console.error("[VM] runtime allowance enforcement failed", { vmId: vm.id, error }); return "error" as const; }))), { concurrency: 5 }); return { checked: rows.length, paused: outcomes.filter((x) => x === "paused").length, diff --git a/web/services/vms/goUsage.ts b/web/services/vms/goUsage.ts index 719444959e88..70405c999824 100644 --- a/web/services/vms/goUsage.ts +++ b/web/services/vms/goUsage.ts @@ -1,7 +1,9 @@ -import { and, count, desc, eq, gt, inArray, isNull, lt, or } from "drizzle-orm"; +import { and, count, eq, gt, inArray, isNull, lt, or } from "drizzle-orm"; import { cloudDb } from "../../db/client"; -import { cloudVms, cloudVmRuntimeIntervals, stripeSubscriptions } from "../../db/schema"; -import { planItem } from "../billing/subscriptionItems"; +import { cloudVms, cloudVmRuntimeIntervals } from "../../db/schema"; +import { personalBillingPeriod, runtimeSecondsWithinPeriod, subscriptionPeriodStart } from "./planUsage"; + +export { runtimeSecondsWithinPeriod, subscriptionPeriodStart }; export const GO_INCLUDED_VM_HOURS = 40; export const GO_INCLUDED_VM_SECONDS = GO_INCLUDED_VM_HOURS * 3600; @@ -16,41 +18,20 @@ export type GoVmUsage = { readonly savedVms: number; }; -export function runtimeSecondsWithinPeriod( - intervals: readonly { startedAt: Date; endedAt: Date | null }[], - periodStart: Date, - periodEnd: Date, - now: Date, -): number { - const end = Math.min(now.getTime(), periodEnd.getTime()); - return Math.ceil(intervals.reduce((sum, interval) => sum + Math.max(0, - Math.min(interval.endedAt?.getTime() ?? end, end) - - Math.max(interval.startedAt.getTime(), periodStart.getTime()), - ), 0) / 1000); -} - -export function subscriptionPeriodStart(raw: Record | null): Date | null { - const item = planItem(raw as { items?: { data?: Array<{ current_period_start?: unknown; price?: { recurring?: { usage_type?: string | null } | null } | null }> } } | null); - const value = item?.current_period_start ?? raw?.current_period_start; - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? new Date(value * 1000) : null; -} - -/** A renewal resets the allowance using Stripe's billing period, not UTC months. */ +/** + * A renewal resets the allowance using the subscription's own billing period + * (web checkout or the App Store), not UTC months. Null when the user's plan + * is not Go; an exhausted allowance when no Go subscription is active. + */ export async function getGoVmUsage(userId: string, now = new Date()): Promise { const db = cloudDb(); - const [subscription] = await db.select().from(stripeSubscriptions).where(and( - eq(stripeSubscriptions.stackUserId, userId), - eq(stripeSubscriptions.scope, "user"), - inArray(stripeSubscriptions.status, ["active", "trialing"]), - )).orderBy(desc(stripeSubscriptions.updatedAt)).limit(1); + const subscription = await personalBillingPeriod(userId, { now, stripeStatuses: ["active", "trialing"] }); if (!subscription) return { periodStart: now, periodEnd: now, usedSeconds: GO_INCLUDED_VM_SECONDS, remainingSeconds: 0, savedVms: 0 }; - if (subscription.plan !== "go") return null; - const periodStart = subscriptionPeriodStart(subscription.raw); - const periodEnd = subscription.currentPeriodEnd; - if (!periodStart || !periodEnd || periodStart >= periodEnd || now >= periodEnd) { + if (subscription.planId !== "go") return null; + if (!subscription.period) { throw new Error("Go billing period is unavailable; reconcile the subscription before granting runtime"); } + const { start: periodStart, end: periodEnd } = subscription.period; const [intervals, retained] = await Promise.all([ db.select({ startedAt: cloudVmRuntimeIntervals.startedAt, endedAt: cloudVmRuntimeIntervals.endedAt }) .from(cloudVmRuntimeIntervals).where(and( diff --git a/web/services/vms/planUsage.ts b/web/services/vms/planUsage.ts new file mode 100644 index 000000000000..575a07941ce2 --- /dev/null +++ b/web/services/vms/planUsage.ts @@ -0,0 +1,323 @@ +// Monthly allowances of the personal plans, measured on the durable runtime +// meter (`cloud_vm_runtime_intervals`) and reset by the subscription's own +// billing period, whichever store sold it (web checkout or the App Store). +// +// Go counts VM-seconds of its one fixed machine shape (goUsage.ts). Pro and +// Max count compute-seconds: a running machine draws one compute unit per +// vCPU, or per 2 GB of memory when its memory is larger, every second. Pro and +// Max also cap the total disk of their saved machines, because a paused +// machine keeps its disk. Founder's Edition, operator grants, and Team are not +// recurring personal subscriptions and have no allowance here; an operator +// grant to a user who also holds a Pro or Max subscription is metered by that +// subscription. A machine is metered at its current recorded shape, so a +// resize also re-rates the period's earlier runtime of that machine. + +import { and, desc, eq, gt, inArray, isNull, lt, ne, or, sql } from "drizzle-orm"; + +import { cloudDb } from "../../db/client"; +import { cloudVmRuntimeIntervals, cloudVms, stripeSubscriptions } from "../../db/schema"; +import { activeAppleEntitlementForUser } from "../billing/apple/entitlement"; +import type { PersonalPlanId } from "../billing/pro"; +import { planItem } from "../billing/subscriptionItems"; +import { maxDiskMbForPlan } from "./entitlements"; +import { + PLAN_MACHINE_MEMORY_MB, + VM_DISK_MB_DEFAULT, + VM_MEMORY_MB_PER_VCPU, + hasVmResourceReservationMetadata, + vcpusForMemoryMb, + vmResourceReservationFromMetadata, + type VmResourceReservation, +} from "./machineSpec"; + +const PLAN_RANK: Readonly> = { go: 1, pro: 2, max: 3 }; +const PERSONAL_PLANS = Object.keys(PLAN_RANK) as PersonalPlanId[]; +/** Statuses whose period the plan still runs on (mirrors ACTIVE_STRIPE_PRO_STATUSES). */ +const STRIPE_PLAN_STATUSES = ["active", "trialing", "past_due"] as const; +const APPLE_PERIOD_FALLBACK_MS = 31 * 24 * 60 * 60 * 1000; + +export type BudgetPlanId = "pro" | "max"; + +/** + * What one month of Pro and Max includes. One compute-hour is 1 vCPU with + * 2 GB of memory running for one hour. The numbers keep the worst case (the + * whole allowance used and the whole disk kept for the month) under 70% of a + * month's web net revenue ($50 Pro, $200 Max; App Store net is higher) at the + * machine provider's list rates: a compute-hour costs $0.04032 + 2 x $0.0129 + * = $0.06612 and disk $0.063 per GiB-month. + * Pro: 375 x $0.06612 + 160 x $0.063 = $24.80 + $10.08 = $34.88 (69.8%) + * Max: 1500 x $0.06612 + 640 x $0.063 = $99.18 + $40.32 = $139.50 (69.8%) + * The default 4 vCPU / 8 GB machine draws 4 units, so Pro is about 94 hours of + * it a month and Max about 375. + */ +export const PLAN_BUDGETS: Readonly> = { + pro: { computeHours: 375, storageMb: 160 * 1024 }, + max: { computeHours: 1500, storageMb: 640 * 1024 }, +}; + +/** + * The Pro and Max allowances are off unless `CMUX_VM_PLAN_BUDGETS=on`. The + * pricing page promises no hour limits, so by default no Pro or Max user gets + * a compute-hour or disk limit and the 402 refusal never fires. Go's hours are + * always enforced. + */ +export function planBudgetsEnforced(env: Record = process.env): boolean { + return env.CMUX_VM_PLAN_BUDGETS?.trim().toLowerCase() === "on"; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +/** A billed period up to this long is one allowance window (a monthly plan). */ +const SINGLE_WINDOW_MAX_MS = 35 * DAY_MS; +/** A final window shorter than this joins the window before it. */ +const MIN_FINAL_WINDOW_MS = 7 * DAY_MS; + +/** `start` plus whole calendar months, with the day clamped to the month's last day. */ +function addUtcMonths(start: Date, months: number): Date { + const year = start.getUTCFullYear(); + const month = start.getUTCMonth() + months; + const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + const result = new Date(start); + result.setUTCDate(1); + result.setUTCFullYear(year, month, Math.min(start.getUTCDate(), lastDay)); + return result; +} + +/** + * The allowance window that contains `now`. A billed period up to 35 days + * (monthly) is one window; a longer one (yearly) resets every calendar month + * from its start, and a final remainder under 7 days joins the window before + * it. The window that reaches `billedEnd` extends to `accessEnd`, so a grace + * period counts against the last billed window instead of opening a new one. + */ +export function monthlyAllowanceWindow( + start: Date, + billedEnd: Date, + accessEnd: Date, + now: Date, +): { start: Date; end: Date } { + const lastEnd = accessEnd > billedEnd ? accessEnd : billedEnd; + if (billedEnd.getTime() - start.getTime() <= SINGLE_WINDOW_MAX_MS) return { start, end: lastEnd }; + let windowStart = start; + for (let month = 1; ; month += 1) { + const next = addUtcMonths(start, month); + if (billedEnd.getTime() - next.getTime() < MIN_FINAL_WINDOW_MS) return { start: windowStart, end: lastEnd }; + if (next > now) return { start: windowStart, end: next }; + windowStart = next; + } +} + +export function isBudgetPlanId(planId: string | null | undefined): planId is BudgetPlanId { + return planId === "pro" || planId === "max"; +} + +export type PersonalBillingPeriod = { + readonly planId: PersonalPlanId; + readonly source: "stripe" | "apple"; + /** Null when the source has no usable current period (a reconcile is pending). */ + readonly period: { + readonly start: Date; + /** Access ends here (an App Store grace period extends past the billed end). */ + readonly end: Date; + /** The paid period ends here. */ + readonly billedEnd: Date; + } | null; +}; + +function usablePeriod(start: Date | null, end: Date | null, now: Date, billedEnd: Date | null = end): PersonalBillingPeriod["period"] { + if (!start || !end || start >= end || now >= end) return null; + return { start, end, billedEnd: billedEnd && billedEnd > start ? billedEnd : end }; +} + +/** Stripe's period start: the plan item's, else the subscription's (seconds since epoch). */ +export function subscriptionPeriodStart(raw: Record | null): Date | null { + const item = planItem(raw as { items?: { data?: Array<{ current_period_start?: unknown; price?: { recurring?: { usage_type?: string | null } | null } | null }> } } | null); + const value = item?.current_period_start ?? raw?.current_period_start; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? new Date(value * 1000) : null; +} + +function isMissingDatabase(error: unknown): boolean { + return error instanceof Error && /DATABASE_URL is required/.test(error.message); +} + +async function stripePersonalPeriod( + userId: string, + statuses: readonly string[], + now: Date, +): Promise { + const rows = await cloudDb().select({ + plan: stripeSubscriptions.plan, + raw: stripeSubscriptions.raw, + currentPeriodEnd: stripeSubscriptions.currentPeriodEnd, + }).from(stripeSubscriptions).where(and( + eq(stripeSubscriptions.stackUserId, userId), + eq(stripeSubscriptions.scope, "user"), + inArray(stripeSubscriptions.plan, PERSONAL_PLANS), + inArray(stripeSubscriptions.status, [...statuses]), + // Founder's Edition is a one-time purchase stored as an active Pro row. + sql`${stripeSubscriptions.id} not like 'founders\\_%'`, + sql`coalesce(${stripeSubscriptions.raw}->'metadata'->>'founders_edition', '') <> 'true'`, + )).orderBy(desc(stripeSubscriptions.updatedAt)).limit(10); + let best: (typeof rows)[number] | null = null; + for (const row of rows) { + if (!best || PLAN_RANK[row.plan as PersonalPlanId] > PLAN_RANK[best.plan as PersonalPlanId]) best = row; + } + if (!best) return null; + return { + planId: best.plan as PersonalPlanId, + source: "stripe", + period: usablePeriod(subscriptionPeriodStart(best.raw as Record | null), best.currentPeriodEnd, now), + }; +} + +/** + * An App Store subscription's period runs from its newest transaction's + * purchase date to its expiry; a grace period extends the same period, so + * runtime during billing retry still counts against it. Matches the Go + * capacity trigger in the database. + */ +async function applePersonalPeriod(userId: string, now: Date): Promise { + const row = await activeAppleEntitlementForUser(userId, { now }); + if (!row || !(row.planId in PLAN_RANK)) return null; + const expires = row.expiresAt; + const end = expires && row.gracePeriodExpiresAt && row.gracePeriodExpiresAt > expires + ? row.gracePeriodExpiresAt + : expires ?? row.gracePeriodExpiresAt; + const start = row.purchaseDate ?? (expires ? new Date(expires.getTime() - APPLE_PERIOD_FALLBACK_MS) : null); + return { planId: row.planId as PersonalPlanId, source: "apple", period: usablePeriod(start, end, now, expires) }; +} + +/** + * The recurring personal subscription that sets the user's plan and its + * current billing period: the higher plan of the web and App Store + * subscriptions, the web one on a tie. Null when the user has neither. + */ +export async function personalBillingPeriod( + userId: string, + options: { readonly now?: Date; readonly stripeStatuses?: readonly string[] } = {}, +): Promise { + const now = options.now ?? new Date(); + const [stripe, apple] = await Promise.all([ + stripePersonalPeriod(userId, options.stripeStatuses ?? STRIPE_PLAN_STATUSES, now), + applePersonalPeriod(userId, now), + ]); + if (stripe && apple) { + if (PLAN_RANK[apple.planId] !== PLAN_RANK[stripe.planId]) { + return PLAN_RANK[apple.planId] > PLAN_RANK[stripe.planId] ? apple : stripe; + } + // Same plan from both stores: the one with a usable period meters it, + // like the database guard, which falls back to the App Store. + return stripe.period || !apple.period ? stripe : apple; + } + return stripe ?? apple; +} + +/** Seconds of the intervals that fall inside the period, up to `now`. */ +export function runtimeSecondsWithinPeriod( + intervals: readonly { startedAt: Date; endedAt: Date | null }[], + periodStart: Date, + periodEnd: Date, + now: Date, +): number { + const end = Math.min(now.getTime(), periodEnd.getTime()); + return Math.ceil(intervals.reduce((sum, interval) => sum + Math.max(0, + Math.min(interval.endedAt?.getTime() ?? end, end) - + Math.max(interval.startedAt.getTime(), periodStart.getTime()), + ), 0) / 1000); +} + +/** + * Compute units a machine draws while it runs. A machine without a recorded + * shape counts as the default paid machine until reconciliation records it. + */ +export function computeUnitsForReservation(reservation: Pick | null): number { + if (!reservation) return vcpusForMemoryMb(PLAN_MACHINE_MEMORY_MB); + return Math.max(reservation.vcpus, Math.ceil(reservation.memoryMb / VM_MEMORY_MB_PER_VCPU)); +} + +/** The recorded machine shape, or null for a legacy row without one. */ +function recordedReservation(metadata: Record | null): VmResourceReservation | null { + return hasVmResourceReservationMetadata(metadata) ? vmResourceReservationFromMetadata(metadata) : null; +} + +export type PlanBudgetUsage = { + readonly planId: BudgetPlanId; + readonly periodStart: Date; + /** The allowance resets here. */ + readonly periodEnd: Date; + readonly includedComputeSeconds: number; + readonly usedComputeSeconds: number; + readonly remainingComputeSeconds: number; + readonly storageBudgetMb: number; + /** Disk of every provisioning, running, or paused machine, by VM id. */ + readonly retainedDiskMb: ReadonlyMap; + readonly retainedStorageMb: number; +}; + +/** Team-billed machines draw from the team's plan, not this allowance. */ +const personalMachine = or(isNull(cloudVms.billingPlanId), ne(cloudVms.billingPlanId, "team")); + +/** + * The caller's Pro or Max allowance this billing period, or null when no + * allowance applies (no recurring Pro or Max subscription, an unusable + * period, or a deployment without a database). Null never refuses. + */ +export async function getPlanBudgetUsage(userId: string, now = new Date()): Promise { + if (!planBudgetsEnforced()) return null; + let billing: PersonalBillingPeriod | null; + try { + billing = await personalBillingPeriod(userId, { now }); + } catch (error) { + if (isMissingDatabase(error)) return null; + throw error; + } + if (!billing || !isBudgetPlanId(billing.planId)) return null; + if (!billing.period) { + console.warn("[VM] plan allowance skipped: billing period unavailable", { userId, source: billing.source }); + return null; + } + const { start: periodStart, end: periodEnd } = monthlyAllowanceWindow(billing.period.start, billing.period.billedEnd, billing.period.end, now); + const db = cloudDb(); + const [intervals, retained] = await Promise.all([ + db.select({ + startedAt: cloudVmRuntimeIntervals.startedAt, + endedAt: cloudVmRuntimeIntervals.endedAt, + metadata: cloudVms.providerMetadata, + }).from(cloudVmRuntimeIntervals) + .innerJoin(cloudVms, eq(cloudVms.id, cloudVmRuntimeIntervals.vmId)) + .where(and( + eq(cloudVmRuntimeIntervals.userId, userId), + lt(cloudVmRuntimeIntervals.startedAt, periodEnd), + or(isNull(cloudVmRuntimeIntervals.endedAt), gt(cloudVmRuntimeIntervals.endedAt, periodStart)), + personalMachine, + )), + db.select({ id: cloudVms.id, metadata: cloudVms.providerMetadata }).from(cloudVms).where(and( + eq(cloudVms.userId, userId), + inArray(cloudVms.status, ["provisioning", "running", "paused"]), + personalMachine, + )), + ]); + const usedComputeSeconds = intervals.reduce((sum, interval) => + sum + runtimeSecondsWithinPeriod([interval], periodStart, periodEnd, now) * + computeUnitsForReservation(recordedReservation(interval.metadata)), 0); + // A legacy machine reserves the provider's disk ceiling until it is + // measured; the plan's per-machine maximum is the most it can hold. + const machineDiskCeilingMb = maxDiskMbForPlan(billing.planId); + const retainedDiskMb = new Map(retained.map((vm) => [ + vm.id, + Math.min(recordedReservation(vm.metadata)?.diskMb ?? VM_DISK_MB_DEFAULT, machineDiskCeilingMb), + ])); + const budget = PLAN_BUDGETS[billing.planId]; + const includedComputeSeconds = budget.computeHours * 3600; + return { + planId: billing.planId, + periodStart, + periodEnd, + includedComputeSeconds, + usedComputeSeconds, + remainingComputeSeconds: Math.max(0, includedComputeSeconds - usedComputeSeconds), + storageBudgetMb: budget.storageMb, + retainedDiskMb, + retainedStorageMb: [...retainedDiskMb.values()].reduce((sum, mb) => sum + mb, 0), + }; +} diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index ee46b15ab764..2906c3b1d649 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -79,6 +79,7 @@ import { vmRequiresProCopy, vmMemoryErrorCopy, vmGoLimitCopy, + vmPlanBudgetCopy, vmActiveLimitCountedCopy, vmResourcePoolCopy, vmUnsupportedCopy, @@ -1187,6 +1188,30 @@ export const vmWorkflowErrorResponders = { VmResourcePoolExceededError: (error, context) => vmResourcePoolExceededResponse(error, context.locale), VmUsageLimitExceededError: (_error, context) => goLimitResponse("hours", context.locale), VmSavedLimitExceededError: (_error, context) => goLimitResponse("saved", context.locale), + VmPlanBudgetExceededError: async (error, context) => { + const copy = await vmPlanBudgetCopy(context.locale, error); + const upgrade = error.planId === "pro" + ? { upgradeRequired: true, upgradePlanId: "max", upgradeUrl: "https://cmux.com/api/billing/checkout?plan=max" } + : { upgradeRequired: false }; + return vmErrorResponse({ + error: error.resource === "compute" ? "vm_compute_budget_reached" : "vm_storage_budget_reached", + status: 402, + message: copy.message, + action: copy.action, + displayTitle: copy.title, + phase: "billing", + retryable: false, + extra: { + ...upgrade, + planId: error.planId, + budgetResource: error.resource, + budgetIncluded: error.included, + budgetUsed: error.used, + budgetRequested: error.requested, + budgetResetsAt: error.resetsAt.toISOString(), + }, + }); + }, VmGoShapeError: async (_error, context) => { const copy = await vmGoLimitCopy("shape", context.locale); return vmErrorResponse({ diff --git a/web/services/vms/vmErrorMessages.ts b/web/services/vms/vmErrorMessages.ts index 6f15b0efd5eb..2ba659ef1479 100644 --- a/web/services/vms/vmErrorMessages.ts +++ b/web/services/vms/vmErrorMessages.ts @@ -285,6 +285,37 @@ export async function vmGoLimitCopy( return { message: t(`${kind}Message`), action: t(`${kind}Action`) }; } +/** + * Localized copy for a used-up Pro or Max allowance. The reset date is the + * end of the billing period, shown as a UTC calendar date. + */ +export async function vmPlanBudgetCopy( + locale: Locale, + values: { + readonly resource: "compute" | "storage"; + readonly planId: "pro" | "max"; + readonly included: number; + readonly used: number; + readonly requested: number; + readonly resetsAt: Date; + }, +): Promise { + const t = createTranslator({ + locale, + messages: await loadMessages(locale), + namespace: "vmErrors.planBudget", + }) as unknown as (key: string, values?: Record) => string; + const resetDate = new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(values.resetsAt); + const plan = values.planId === "max" ? "Max" : "Pro"; + const args = { plan, included: values.included, used: values.used, requested: values.requested, resetDate }; + const upgrade = values.planId === "pro" ? "Upgrade" : ""; + return { + title: t(`${values.resource}Title`, args), + message: t(`${values.resource}Message`, args), + action: t(`${values.resource}Action${upgrade}`, args), + }; +} + /** * The localized sentence naming the machines an active-VM limit counted, or * "" when there are none. It is appended to the plan sentence, so it starts diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index e4c63818bf41..9d706a39f4cd 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -64,6 +64,7 @@ import { vmResourceResizePendingFromMetadata, vmResourceResizeUnconfirmedFromMetadata, vmProviderResourceSize, + vmDiskMb, type VmComputeResources, type VmResourceReservation, type VmResourceResizePending, @@ -96,6 +97,7 @@ import { VmSnapshotInProgressError, VmSnapshotIdempotencyConflictError, VmUsageLimitExceededError, + VmPlanBudgetExceededError, VmGoShapeError, VM_MODEL_PLANE_FAILURE_CODES, isVmCreateCreditsInsufficientError, @@ -118,6 +120,7 @@ import { type VmResourcePoolPolicy, } from "./entitlements"; import { getGoVmUsage, GO_INCLUDED_VM_HOURS } from "./goUsage"; +import { getPlanBudgetUsage, isBudgetPlanId, type PlanBudgetUsage } from "./planUsage"; import { GO_PAUSE_INTENT_KEY, pauseGoVm } from "./goPause"; import { networkSlugForTeam, networkSlugForUser, privateNetworkUnavailableReason, resolveOwnerNetwork } from "./privateNetwork"; import { listTeamMemberIdsWithTimeout, type VmTeamDirectory } from "./teamDirectory"; @@ -1079,9 +1082,74 @@ function requireGoShape(planId: string | null | undefined, shape: VmResourceRese ? Effect.fail(new VmGoShapeError()) : Effect.void; } -/** Read and reserve the caller's remaining Go runtime allowance. */ -function requireGoCreate(input: Pick[0], "billingPlanId" | "userId" | "imageSize" | "resourceReservation">) { +const MB_PER_GB = 1024; + +function readPlanBudget(userId: string) { + return Effect.tryPromise({ + try: () => getPlanBudgetUsage(userId), + catch: (cause) => new VmBillingError({ operation: "plan_budget", cause }), + }); +} + +function computeBudgetExceeded(usage: PlanBudgetUsage) { + return new VmPlanBudgetExceededError({ + planId: usage.planId, + resource: "compute", + included: Math.round(usage.includedComputeSeconds / 3600), + used: Math.round(usage.usedComputeSeconds / 3600), + requested: 0, + resetsAt: usage.periodEnd, + }); +} + +/** + * Refuse a start when the Pro or Max compute allowance is used up, and a new + * or grown disk when the saved machines' disk would pass the plan's total. + * `addedDiskMb` is the disk the request adds; `replacesVmId` is the machine a + * resize grows, whose current disk the new size replaces. + */ +function requirePlanBudget( + planId: string | null | undefined, + userId: string, + disk: { readonly addedDiskMb: number; readonly replacesVmId?: string } | null, +) { return Effect.gen(function* () { + if (!isBudgetPlanId(planId)) return; + const usage = yield* readPlanBudget(userId); + if (!usage) return; + if (!disk || disk.replacesVmId === undefined) { + if (usage.remainingComputeSeconds <= 0) return yield* Effect.fail(computeBudgetExceeded(usage)); + } + if (!disk) return; + // A legacy restore or fork reserves the provider's disk ceiling until it + // is measured; the plan's per-machine maximum is the most it can hold. + const addedDiskMb = Math.min(disk.addedDiskMb, maxDiskMbForPlan(usage.planId)); + const kept = usage.retainedStorageMb - (disk.replacesVmId ? usage.retainedDiskMb.get(disk.replacesVmId) ?? 0 : 0); + if (kept + addedDiskMb > usage.storageBudgetMb) { + return yield* Effect.fail(new VmPlanBudgetExceededError({ + planId: usage.planId, + resource: "storage", + included: Math.round(usage.storageBudgetMb / MB_PER_GB), + used: Math.ceil(kept / MB_PER_GB), + requested: Math.ceil(addedDiskMb / MB_PER_GB), + resetsAt: usage.periodEnd, + })); + } + }); +} + +/** Disk a create will claim, the same value createVmBeginInput reserves. */ +function requestedCreateDiskMb(input: Pick[0], "imageSize" | "resourceReservation" | "memoryMb">): number { + return (input.resourceReservation ?? vmResourceReservationForCreate({ memoryMb: input.memoryMb, imageSize: input.imageSize })).diskMb; +} + +/** Read and reserve the caller's remaining Go runtime allowance; check the Pro and Max allowances. */ +function requirePlanCreate(input: Pick[0], "billingPlanId" | "userId" | "imageSize" | "resourceReservation" | "memoryMb">) { + return Effect.gen(function* () { + if (isBudgetPlanId(input.billingPlanId)) { + yield* requirePlanBudget(input.billingPlanId, input.userId, { addedDiskMb: requestedCreateDiskMb(input) }); + return undefined; + } if (input.billingPlanId === "go") { yield* requireGoShape("go", input.resourceReservation ?? (input.imageSize ? { vcpus: input.imageSize.cpu, memoryMb: input.imageSize.memoryMb, diskMb: input.imageSize.storageMb, @@ -1206,7 +1274,7 @@ export function createVm(input: CreateVmInput): Effect.Effect getGoVmUsage(vm.userId), catch: (cause) => new VmBillingError({ operation: "go_runtime", cause }) }); if (usage && usage.remainingSeconds <= 0) return yield* Effect.fail(new VmUsageLimitExceededError({ includedHours: 40, usedHours: 40 })); yield* setRuntimeBudget(providers, vm, providerVmId, usage?.remainingSeconds ?? null); + } else { + yield* requirePlanBudget(vm.billingPlanId, vm.userId, null); } const handle = yield* resume(vm.provider, providerVmId).pipe(Effect.tapError(() => bestEffortPause(providers, vm, providerVmId))); if (handle.status === "running") return; @@ -3858,7 +3933,13 @@ function withResumeOnSuspendedAfterFailure( const reserved = yield* reservePausedResumeIfTeam(repo, vm, providerVmId, maxActiveVms, callerPlanId); yield* resumeUntilRunning(providers, vm, providerVmId).pipe( Effect.tapError(() => rollbackPausedResumeReservation(repo, vm, providerVmId, reserved)), - Effect.catchAll(() => Effect.fail(originalError)), + // An allowance refusal is the answer the caller needs; any other + // resume failure reports the operation's own error. + Effect.catchAll((resumeError) => Effect.fail( + resumeError._tag === "VmPlanBudgetExceededError" || resumeError._tag === "VmUsageLimitExceededError" + ? resumeError + : originalError, + )), ); yield* recordRunningTransition(repo, providers, vm, providerVmId, originalError).pipe( Effect.tapError(() => rollbackPausedResumeReservation(repo, vm, providerVmId, reserved)), @@ -4705,6 +4786,9 @@ export function resizeVm(input: { reason: "above_max", })); } + if (input.storageMb > currentMb) { + yield* requirePlanBudget(planId, vm.userId, { addedDiskMb: input.storageMb, replacesVmId: vm.id }); + } // Claim the new disk size under the same billing-team lock used by create. // The live repository always provides this method; test doubles from // before resource tracking may omit it and exercise provider behavior diff --git a/web/tests/apple-iap-account-token.test.ts b/web/tests/apple-iap-account-token.test.ts index 090c5bad0b97..1892703f955f 100644 --- a/web/tests/apple-iap-account-token.test.ts +++ b/web/tests/apple-iap-account-token.test.ts @@ -30,10 +30,22 @@ describe("account token eligibility", () => { const response = await appleAccountTokenResponse(user, app("com.cmux.app"), deps()); expect(response).toMatchObject({ eligible: true, reason: null, currentPlan: { planId: "free", source: "none" } }); expect(response.products).toEqual([ - { productId: "com.cmux.app.go.monthly", planId: "go" }, - { productId: "com.cmux.app.pro.monthly", planId: "pro" }, - { productId: "com.cmux.app.max.monthly", planId: "max" }, + { productId: "com.cmux.app.go.monthly", planId: "go", billingPeriod: "monthly" }, + { productId: "com.cmux.app.pro.monthly", planId: "pro", billingPeriod: "monthly" }, + { productId: "com.cmux.app.max.monthly", planId: "max", billingPeriod: "monthly" }, ]); + // An app that sends `x-cmux-billing-periods: monthly,yearly` also gets the + // yearly Pro and Max products; Go stays monthly like the web. + const periods = accountToken.requestedBillingPeriods("monthly,yearly"); + const both = await appleAccountTokenResponse(user, { ...app("com.cmux.app"), billingPeriods: periods }, deps()); + expect(both.products).toEqual([ + { productId: "com.cmux.app.go.monthly", planId: "go", billingPeriod: "monthly" }, + { productId: "com.cmux.app.pro.monthly", planId: "pro", billingPeriod: "monthly" }, + { productId: "com.cmux.app.pro.yearly", planId: "pro", billingPeriod: "yearly" }, + { productId: "com.cmux.app.max.monthly", planId: "max", billingPeriod: "monthly" }, + { productId: "com.cmux.app.max.yearly", planId: "max", billingPeriod: "yearly" }, + ]); + expect(accountToken.requestedBillingPeriods(null)).toEqual(["monthly"]); const noGo = await appleAccountTokenResponse(user, app("dev.cmux.app.beta"), deps({ goPlanEnabled: async () => false })); expect(noGo.products.map((product) => product.productId)).toEqual(["dev.cmux.app.beta.pro.monthly", "dev.cmux.app.beta.max.monthly"]); }); @@ -78,12 +90,22 @@ describe("account token eligibility", () => { expect(response.products).toHaveLength(3); }); - test("the App Store app sells in Production and to App Review in the Sandbox", async () => { + test("the App Store app sells in Production, and in the Sandbox only to allowlisted accounts", async () => { + const env = { ...production, APPLE_IAP_SANDBOX_ACCOUNT_EMAILS: "review@example.com" }; + const reviewer = { ...user, primaryEmail: "Review@example.com", primaryEmailVerified: true }; for (const environment of ["Production", "Sandbox", null] as const) { - const response = await appleAccountTokenResponse(user, app("com.cmux.app", environment), deps({ env: production })); + const response = await appleAccountTokenResponse(reviewer, app("com.cmux.app", environment), deps({ env })); expect(response).toMatchObject({ eligible: true, reason: null }); expect(response.products).toHaveLength(3); } + const tester = { ...user, primaryEmail: "tester@example.com", primaryEmailVerified: true }; + expect(await appleAccountTokenResponse(tester, app("com.cmux.app", "Sandbox"), deps({ env }))) + .toMatchObject(unavailable); + expect(await appleAccountTokenResponse(tester, app("com.cmux.app", "Production"), deps({ env }))) + .toMatchObject({ eligible: true, reason: null }); + // An unverified address could be anyone's. + expect(await appleAccountTokenResponse({ ...reviewer, primaryEmailVerified: false }, app("com.cmux.app", "Sandbox"), deps({ env }))) + .toMatchObject(unavailable); }); test("other deployments sell to every Sandbox build", async () => { diff --git a/web/tests/apple-iap-routes.test.ts b/web/tests/apple-iap-routes.test.ts index c496f04a9692..cc3f1d491568 100644 --- a/web/tests/apple-iap-routes.test.ts +++ b/web/tests/apple-iap-routes.test.ts @@ -78,11 +78,12 @@ describe("Apple billing route auth", () => { "x-stack-refresh-token": "refresh-token", "x-cmux-bundle-id": "dev.cmux.app.beta", "x-cmux-storekit-environment": "Sandbox", + "x-cmux-billing-periods": "monthly,yearly", })); expect(response.status).toBe(200); expect(await response.json()).toEqual({ appAccountToken: "token-user-a", - app: { bundleId: "dev.cmux.app.beta", storeKitEnvironment: "Sandbox" }, + app: { bundleId: "dev.cmux.app.beta", storeKitEnvironment: "Sandbox", billingPeriods: ["monthly", "yearly"] }, }); expect(getUserCalls[0]).toMatchObject({ tokenStore: { accessToken: "access-token" } }); }); diff --git a/web/tests/apple-iap-service.test.ts b/web/tests/apple-iap-service.test.ts index 9011e97f8a77..161de1071cbf 100644 --- a/web/tests/apple-iap-service.test.ts +++ b/web/tests/apple-iap-service.test.ts @@ -225,7 +225,7 @@ describe("App Store Server Notifications", () => { event: "subscription_started", distinctId: "user-a", properties: { - source: "apple", plan_id: "pro", product_id: `${BUNDLE}.pro.monthly`, storefront: "USA", currency: "USD", + source: "apple", plan_id: "pro", product_id: `${BUNDLE}.pro.monthly`, billing_period: "monthly", storefront: "USA", currency: "USD", price: 74.99, price_usd_estimate: 74.99, net_usd_estimate: 52.49, environment: "Production", original_transaction_id: ORIGINAL, }, @@ -654,9 +654,13 @@ describe("Apple entitlement policy", () => { expiresAt: new Date(NOW.getTime() + DAY), gracePeriodExpiresAt: null, }; - test("production grants Sandbox purchases only for the App Store bundle (App Review)", () => { - const production = { VERCEL_ENV: "production" }; - expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: BUNDLE }, production)).toBe(true); + test("production grants Sandbox purchases only for the App Store bundle and allowlisted accounts", () => { + const production = { VERCEL_ENV: "production", APPLE_IAP_SANDBOX_ACCOUNT_EMAILS: "review@example.com, Lawrence@Example.com" }; + expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: BUNDLE, accountEmail: "review@example.com" }, production)).toBe(true); + expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: BUNDLE, accountEmail: "lawrence@example.com" }, production)).toBe(true); + expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: BUNDLE, accountEmail: "tester@example.com" }, production)).toBe(false); + expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: BUNDLE }, production)).toBe(false); + expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: "dev.cmux.app.beta", accountEmail: "review@example.com" }, production)).toBe(false); expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: "dev.cmux.app.beta" }, production)).toBe(false); expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: BUNDLE }, { ...production, APPLE_IAP_SANDBOX_ENTITLEMENTS: "none" })).toBe(false); expect(appleEnvironmentGrantsEntitlement({ environment: "Sandbox", bundleId: "dev.cmux.app.beta" }, {})).toBe(true); @@ -686,5 +690,10 @@ describe("Apple revenue estimates", () => { planId: "max", currency: "EUR", priceMilliunits: 299990, purchaseDate: NOW, originalPurchaseDate: new Date(NOW.getTime() - 400 * DAY), }); expect(euro).toMatchObject({ price: 299.99, priceUsd: 299.99, netUsd: 254.99, commission: 0.15 }); + const yearly = appleRevenueEstimate({ + planId: "max", productId: `${BUNDLE}.max.yearly`, bundleId: BUNDLE, currency: "EUR", priceMilliunits: 2_500_000, + purchaseDate: NOW, originalPurchaseDate: NOW, + }); + expect(yearly).toMatchObject({ priceUsd: 2879.9, commission: 0.3 }); }); }); diff --git a/web/tests/dashboard-billing-screen.test.tsx b/web/tests/dashboard-billing-screen.test.tsx index 13d50359ecc4..2e74c40d2353 100644 --- a/web/tests/dashboard-billing-screen.test.tsx +++ b/web/tests/dashboard-billing-screen.test.tsx @@ -551,7 +551,7 @@ describe("dashboard billing screen", () => { for (const [billing, message] of [ ["cancelled", "Your plan will cancel at the end of the current billing period."], ["resumed", "Your plan has been resumed and will renew normally."], - ["nosub", "No active Stripe subscription was found for this account."], + ["nosub", "No active web subscription was found for this account."], ["error", "Billing could not be updated. Try again shortly."], ] as const) { test(`renders ${billing} banner`, async () => { diff --git a/web/tests/fixtures/go-runtime-limits.sql b/web/tests/fixtures/go-runtime-limits.sql index 30213fd73927..09cacc51dea5 100644 --- a/web/tests/fixtures/go-runtime-limits.sql +++ b/web/tests/fixtures/go-runtime-limits.sql @@ -57,4 +57,50 @@ BEGIN RAISE NOTICE 'PASS: runtime transitions, cross-team cap, saved cap, deleted VM accounting, renewal'; END; $$; + +-- An App Store Go subscription (no web subscription row) has a billing period. +DO $$ +DECLARE + u text := 'go-meter-apple-' || gen_random_uuid(); + apple_vm uuid; + rejected boolean; + constraint_seen text; +BEGIN + rejected := false; + BEGIN + INSERT INTO cloud_vms(user_id,billing_team_id,billing_plan_id,provider,image_id,status) + VALUES (u,u,'go','freestyle','test','running'); + EXCEPTION WHEN check_violation THEN + GET STACKED DIAGNOSTICS constraint_seen=CONSTRAINT_NAME; + rejected := constraint_seen='cmux_go_period_unavailable'; + END; + IF NOT rejected THEN RAISE EXCEPTION 'Go started without any subscription'; END IF; + INSERT INTO apple_subscriptions(original_transaction_id,user_id,bundle_id,environment,product_id,plan_id,status, + purchase_date,expires_at,state_signed_at) + VALUES (u,u,'com.cmux.app','Production','com.cmux.app.go.monthly','go','active', + now() - interval '3 days', now() + interval '27 days', now()); + INSERT INTO cloud_vms(user_id,billing_team_id,billing_plan_id,provider,image_id,status) + VALUES (u,u,'go','freestyle','test','running') RETURNING id INTO apple_vm; + UPDATE cloud_vms SET status='paused' WHERE id=apple_vm; + UPDATE cloud_vm_runtime_intervals SET started_at=now()-interval '41 hours', ended_at=now() WHERE vm_id=apple_vm; + rejected := false; + BEGIN + UPDATE cloud_vms SET status='running' WHERE id=apple_vm; + EXCEPTION WHEN check_violation THEN + GET STACKED DIAGNOSTICS constraint_seen=CONSTRAINT_NAME; + rejected := constraint_seen='cmux_go_hours_limit'; + END; + IF NOT rejected THEN RAISE EXCEPTION 'App Store Go bypassed the runtime cap'; END IF; + UPDATE apple_subscriptions SET status='expired' WHERE original_transaction_id=u; + rejected := false; + BEGIN + UPDATE cloud_vms SET status='running' WHERE id=apple_vm; + EXCEPTION WHEN check_violation THEN + GET STACKED DIAGNOSTICS constraint_seen=CONSTRAINT_NAME; + rejected := constraint_seen='cmux_go_period_unavailable'; + END; + IF NOT rejected THEN RAISE EXCEPTION 'an expired App Store Go subscription kept a period'; END IF; + RAISE NOTICE 'PASS: App Store Go period, runtime cap, expiry'; +END; +$$; ROLLBACK; diff --git a/web/tests/plan-allowance-db-behavior.test.ts b/web/tests/plan-allowance-db-behavior.test.ts new file mode 100644 index 000000000000..85e9de8e7d8f --- /dev/null +++ b/web/tests/plan-allowance-db-behavior.test.ts @@ -0,0 +1,179 @@ +// Database-backed proof of the personal plan allowances: an App Store Go +// subscriber gets Go hours (the database capacity guard and the app agree on +// the period), and Pro and Max meter compute units and saved disk on the +// subscription's own billing period from either store. Gated like the other +// *-db-behavior tests; every test uses its own user ids. + +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import postgres, { type Sql } from "postgres"; +import { randomUUID } from "node:crypto"; + +import { closeCloudDbForTests } from "../db/client"; +import { getGoVmUsage, GO_INCLUDED_VM_SECONDS } from "../services/vms/goUsage"; +import { getPlanBudgetUsage, PLAN_BUDGETS } from "../services/vms/planUsage"; + +const runDbTests = process.env.CMUX_DB_TEST === "1"; +const dbTest = runDbTests ? test : test.skip; +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +let sql: Sql | null = null; +const users: string[] = []; + +beforeAll(() => { + if (!runDbTests) return; + const databaseURL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL; + if (!databaseURL) throw new Error("DATABASE_URL is required when CMUX_DB_TEST=1"); + sql = postgres(databaseURL, { max: 2 }); +}); + +afterAll(async () => { + if (sql && users.length > 0) { + await sql`delete from cloud_vms where user_id in ${sql(users)}`; + await sql`delete from apple_subscriptions where user_id in ${sql(users)}`; + await sql`delete from stripe_subscriptions where stack_user_id in ${sql(users)}`; + } + await closeCloudDbForTests(); + await sql?.end(); +}); + +function db(): Sql { + if (!sql) throw new Error("database is not connected"); + return sql; +} + +function newUser(label: string): string { + const id = `plan-allowance-${label}-${randomUUID()}`; + users.push(id); + return id; +} + +async function appleSubscription(userId: string, planId: string, purchaseDate: Date, expiresAt: Date, period = "monthly") { + await db()`insert into apple_subscriptions ( + original_transaction_id, user_id, bundle_id, environment, product_id, plan_id, status, + purchase_date, original_purchase_date, expires_at, state_signed_at + ) values ( + ${`otx-${userId}`}, ${userId}, 'com.cmux.app', 'Production', ${`com.cmux.app.${planId}.${period}`}, ${planId}, 'active', + ${purchaseDate}, ${purchaseDate}, ${expiresAt}, ${purchaseDate} + )`; +} + +async function stripeSubscription(userId: string, planId: string, periodStart: Date, periodEnd: Date, id = `sub-${userId}`) { + await db()`insert into stripe_subscriptions (id, customer_id, stack_user_id, status, plan, scope, current_period_end, raw) + values (${id}, ${`cus-${userId}`}, ${userId}, 'active', ${planId}, 'user', ${periodEnd}, + ${db().json({ current_period_start: Math.floor(periodStart.getTime() / 1000) })})`; +} + +/** A machine whose only runtime interval ran from `startedAt` to `endedAt`. */ +async function machine(input: { + readonly userId: string; + readonly planId: string; + readonly status: "running" | "paused"; + readonly reservation?: { vcpus: number; memoryMb: number; diskMb: number }; + readonly startedAt: Date; + readonly endedAt: Date; +}) { + const metadata = input.reservation ? { cmuxResourceReservation: input.reservation } : {}; + const [row] = await db()<{ id: string }[]>`insert into cloud_vms (user_id, billing_team_id, billing_plan_id, provider, image_id, status, provider_metadata) + values (${input.userId}, ${input.userId}, ${input.planId}, 'freestyle', 'test', 'running', ${db().json(metadata)}) + returning id`; + if (input.status === "paused") await db()`update cloud_vms set status = 'paused' where id = ${row!.id}`; + await db()`update cloud_vm_runtime_intervals set started_at = ${input.startedAt}, ended_at = ${input.endedAt} + where vm_id = ${row!.id}`; + return row!.id; +} + +describe("Go hours for App Store subscribers", () => { + dbTest("an App Store Go subscription starts a Go machine and meters it on its own period", async () => { + const now = new Date(); + const userId = newUser("apple-go"); + const purchased = new Date(now.getTime() - 3 * DAY); + await appleSubscription(userId, "go", purchased, new Date(now.getTime() + 27 * DAY)); + + const fresh = await getGoVmUsage(userId, now); + expect(fresh?.periodStart.getTime()).toBe(purchased.getTime()); + expect(fresh?.remainingSeconds).toBe(GO_INCLUDED_VM_SECONDS); + + // The capacity guard accepts the start (it refused every App Store Go + // subscriber before), and the meter counts the run. + await machine({ + userId, planId: "go", status: "paused", + reservation: { vcpus: 2, memoryMb: 4096, diskMb: 16384 }, + startedAt: new Date(now.getTime() - 41 * HOUR), endedAt: new Date(now.getTime() - HOUR), + }); + const used = await getGoVmUsage(userId, now); + expect(used?.usedSeconds).toBe(40 * 3600); + expect(used?.remainingSeconds).toBe(0); + }); +}); + +describe("Pro and Max allowances", () => { + const budgetSwitch = process.env.CMUX_VM_PLAN_BUDGETS; + afterEach(() => { + if (budgetSwitch === undefined) delete process.env.CMUX_VM_PLAN_BUDGETS; + else process.env.CMUX_VM_PLAN_BUDGETS = budgetSwitch; + }); + + dbTest("Pro meters compute units and saved disk on the web subscription's period", async () => { + const now = new Date(); + const userId = newUser("stripe-pro"); + const periodStart = new Date(now.getTime() - 2 * DAY); + const periodEnd = new Date(now.getTime() + 28 * DAY); + await stripeSubscription(userId, "pro", periodStart, periodEnd); + // Off by default: the pricing page promises no hour limits. + delete process.env.CMUX_VM_PLAN_BUDGETS; + expect(await getPlanBudgetUsage(userId, now)).toBeNull(); + process.env.CMUX_VM_PLAN_BUDGETS = "on"; + // 10 hours of an 8 vCPU / 16 GB machine is 80 compute-hours; 4 hours of a + // legacy machine without a recorded shape counts as the default 4 units. + await machine({ + userId, planId: "pro", status: "paused", + reservation: { vcpus: 8, memoryMb: 16384, diskMb: 65536 }, + startedAt: new Date(now.getTime() - 11 * HOUR), endedAt: new Date(now.getTime() - HOUR), + }); + await machine({ + userId, planId: "pro", status: "paused", + startedAt: new Date(now.getTime() - 5 * HOUR), endedAt: new Date(now.getTime() - HOUR), + }); + // A run before the period started does not count. + await machine({ + userId, planId: "pro", status: "paused", + reservation: { vcpus: 4, memoryMb: 8192, diskMb: 32768 }, + startedAt: new Date(periodStart.getTime() - 10 * HOUR), endedAt: new Date(periodStart.getTime() - HOUR), + }); + + const usage = await getPlanBudgetUsage(userId, now); + expect(usage).toMatchObject({ planId: "pro", includedComputeSeconds: PLAN_BUDGETS.pro.computeHours * 3600 }); + expect(usage!.periodEnd.getTime()).toBe(periodEnd.getTime()); + expect(usage!.usedComputeSeconds).toBe((80 + 16) * 3600); + expect(usage!.retainedStorageMb).toBe(65536 + 32768 + 32768); + }); + + dbTest("an App Store Max subscription outranks a web Pro one; Founder's Edition has no allowance", async () => { + const now = new Date(); + process.env.CMUX_VM_PLAN_BUDGETS = "on"; + const maxUser = newUser("apple-max"); + await stripeSubscription(maxUser, "pro", new Date(now.getTime() - DAY), new Date(now.getTime() + 29 * DAY)); + const purchased = new Date(now.getTime() - 5 * DAY); + await appleSubscription(maxUser, "max", purchased, new Date(now.getTime() + 25 * DAY)); + const usage = await getPlanBudgetUsage(maxUser, now); + expect(usage?.planId).toBe("max"); + expect(usage?.periodStart.getTime()).toBe(purchased.getTime()); + + // A yearly App Store subscription resets the allowance every month. + const yearly = newUser("apple-pro-yearly"); + const bought = new Date(now.getTime() - 40 * DAY); + bought.setUTCDate(Math.min(bought.getUTCDate(), 28)); + await appleSubscription(yearly, "pro", bought, new Date(bought.getTime() + 365 * DAY), "yearly"); + const window = await getPlanBudgetUsage(yearly, now); + const oneMonth = new Date(bought); oneMonth.setUTCMonth(bought.getUTCMonth() + 1); + const twoMonths = new Date(bought); twoMonths.setUTCMonth(bought.getUTCMonth() + 2); + expect(window?.planId).toBe("pro"); + expect(window?.periodStart.getTime()).toBe(oneMonth.getTime()); + expect(window?.periodEnd.getTime()).toBe(twoMonths.getTime()); + + const founder = newUser("founder"); + await stripeSubscription(founder, "pro", new Date(now.getTime() - DAY), new Date(now.getTime() + 365 * DAY), `founders_${founder}`); + expect(await getPlanBudgetUsage(founder, now)).toBeNull(); + }); +}); From 528b9e93de0b7878b1c9113efb9803a5938d90b5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:58:01 -0700 Subject: [PATCH 053/183] CmuxNext tabs: ui.animations.newTab is on by default; a new tab grows in at its slot on the tabOpen spring (~118 ms) with the tabs right of it (candidate for Lawrence's bisect round) Lawrence 2026-10-14 after the instant Cmd-T build: "cmd t has no animation anymore". hq-1d: tabs get the 10-11 feel back (358d010c: the strip changed over 7 frames, ~110-120 ms, monotonic); panes (ui.paneAnimations) and new workspaces stay instant by default. - New structural token tabOpen (response 0.1 s, critically damped, visible end ~118 ms) in design-tokens.json, cmux-motion (MotionSpring::TabOpen, index 12, ALL = 13; additive) and the macOS MotionSpring (same order); parity covers it. A growing tab's width and the x/width of the tabs that make room use it, so the strip never overlaps; later moves go back to move. - ui.animations.newTab default true (AnimationKindsSetting.defaultEnabled, DesignSettings, schema default, design-tokens kinds); the toggle removes the key at the default. - The store's provisional pairing (nx-cmdt-fix) keeps the new tab one element: no stand-in close; nx-cmdt-fix's insert-at-once stays for newTab off and during drags and drops. --- .../Handlers/AppearanceHandlers.swift | 5 +++-- .../Sources/CmuxNextDesign/DesignSettings.swift | 5 +++-- .../CmuxNextDesign/Motion/MotionKinds.swift | 8 ++++---- .../CmuxNextDesign/Motion/MotionTokens.swift | 3 +++ .../CmuxNextDesign/Motion/MotionTunables.swift | 1 + .../CmuxNextSettings/AnimationKindsSetting.swift | 14 +++++++++----- .../Schema/SettingsSchema+Appearance.swift | 2 +- .../Sources/CmuxNextTabs/TabMotion.swift | 2 +- .../CmuxNextTabs/TabStripView+Layout.swift | 4 ++++ .../cmux-app-ffi/include/cmux_motion_ffi.h | 2 +- cmux-tui/crates/cmux-app-ffi/src/motion.rs | 2 +- cmux-tui/crates/cmux-motion/build.rs | 3 ++- .../cmux-motion/examples/motion_trajectories.rs | 3 ++- cmux-tui/crates/cmux-motion/src/tokens.rs | 6 +++++- plans/cmux-next/motion.md | 9 ++++++--- .../spec-proposals/visuals/design-tokens.json | 16 +++++++++++++--- 16 files changed, 59 insertions(+), 26 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift index 08f87011a6d8..151254f0642e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift @@ -150,12 +150,13 @@ enum AppearanceHandlers { context.writeSetting("set titlebar", WindowTitlebarSetting.configPath, value, reloadOnFailure: true) } - /// `ui.animations.`: flipped at once, then written (off, the default, removes the key). + /// `ui.animations.`: flipped at once, then written (its default removes the key). private static func toggleAnimation(_ kind: MotionKind, _ context: AppActionContext) throws { try requireUnmanaged(AnimationKindsSetting.path(kind), context) let on = !context.design.enabledAnimations.contains(kind) if on { context.design.enabledAnimations.insert(kind) } else { context.design.enabledAnimations.remove(kind) } - context.writeSetting("toggle \(kind.rawValue) animations", AnimationKindsSetting.path(kind), on ? .bool(true) : nil, reloadOnFailure: true) + let value: JSONValue? = on == AnimationKindsSetting.defaultEnabled.contains(kind) ? nil : .bool(on) + context.writeSetting("toggle \(kind.rawValue) animations", AnimationKindsSetting.path(kind), value, reloadOnFailure: true) } /// Body size in points: the override, else the density default. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift index d965d3905dfb..ca622ff24e25 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift @@ -36,8 +36,9 @@ public final class DesignSettings { public var animationSpeed: MotionSpeed = .fast /// `ui.animations.bounce`: structural springs are critically damped or bounce subtly. public var animationBounce: MotionBounce = .none - /// `ui.animations.{newTab,newWorkspace}` set to true (both off by default). - public var enabledAnimations: Set = [] + /// The `ui.animations.{newTab,newWorkspace}` kinds on: new tabs by default + /// (`AnimationKindsSetting.defaultEnabled`), new workspaces off. + public var enabledAnimations: Set = [.newTab] /// `ui.paneAnimations`: pane layout changes spring when true (see /// `Motion.animatesPaneLayout`); off by default. public var paneAnimations = false diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift index 320dce94fde4..841bf3b91e63 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift @@ -9,9 +9,9 @@ public nonisolated enum MotionBounce: String, Sendable, CaseIterable, Codable { case subtle } -/// The structural changes a user can turn animation on for one by one -/// (`ui.animations.newTab`, `ui.animations.newWorkspace`; off by default: -/// they apply in one frame), under `ui.animationSpeed`. Pane layout +/// The structural changes a user can turn animation on or off one by one +/// (`ui.animations.newTab`, on by default; `ui.animations.newWorkspace`, off +/// by default: it applies in one frame), under `ui.animationSpeed`. Pane layout /// (splits, closes, zoom, divider release) is `ui.paneAnimations` /// (`Motion.animatesPaneLayout`). public nonisolated enum MotionKind: String, Sendable, CaseIterable, Codable { @@ -26,7 +26,7 @@ nonisolated extension MotionSpring { /// setting; release, scroll and overlay springs keep their own damping. public var isStructural: Bool { switch self { - case .move, .appear, .disappear: true + case .move, .appear, .disappear, .tabOpen: true default: false } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift index 4015c661b7fe..c1592b681f8d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift @@ -63,6 +63,9 @@ public nonisolated enum MotionSpring: String, Sendable, CaseIterable { /// The panes a zoom leaves fade out, faster than anything arrives /// (`Motion.exit(nearness:)`: the farthest first). case exit + /// A new tab growing in at its final slot while the tabs right of it make room, on one + /// spring (`ui.animations.newTab`, on by default). Structural. + case tabOpen /// Values at `MotionSpeed.fast`: the code default (`MotionTunables`), /// or its Debug Settings override in DEV and NIGHTLY builds. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift index 6e5a317ceb3a..768d1673be85 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift @@ -13,6 +13,7 @@ public nonisolated enum MotionTunables { .move: (SpringParameters(response: 0.15, dampingFraction: 1), "Tab reflow and reorder, sidebar row moves, pane and divider frames."), .zoom: (SpringParameters(response: 0.32, dampingFraction: 0.9), "A pane zooming to fill its screen."), .exit: (SpringParameters(response: 0.12, dampingFraction: 1), "The panes a zoom leaves fading out, the farthest first."), + .tabOpen: (SpringParameters(response: 0.1, dampingFraction: 1), "A new tab growing in at its slot and the tabs right of it making room."), .appear: (SpringParameters(response: 0.135, dampingFraction: 1), "Palette scale-in, tab grow-in, group expand, row insert, sidebar show."), .disappear: (SpringParameters(response: 0.11, dampingFraction: 1), "Tab close, group collapse, row removal, sidebar hide."), .settle: (SpringParameters(response: 0.22, dampingFraction: 0.85), "Release after a drag: tab drop, ghost landing, sidebar row drop."), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift index 6844362ab5f4..c32a08fa43bf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift @@ -1,6 +1,7 @@ public import CmuxNextDesign -/// `ui.animations.{newTab,newWorkspace}` (bool, default false: the change +/// `ui.animations.newTab` (bool, default true: a new tab grows in at its slot; +/// Lawrence 2026-10-14) and `ui.animations.newWorkspace` (bool, default false: the change /// applies in one frame; true opts into its spring) and /// `ui.animations.bounce` ("none" or "subtle") in cmux.json /// (plans/cmux-next/motion.md). They apply under `ui.animationSpeed`: @@ -8,11 +9,14 @@ public import CmuxNextDesign /// is `ui.paneAnimations` (`PaneAnimationsSetting`). public nonisolated struct AnimationKindsSetting: Sendable, Equatable { /// Kinds whose `ui.animations.` is true. - public var enabled: Set = [] + public var enabled: Set = AnimationKindsSetting.defaultEnabled /// `ui.animations.bounce`. public var bounce: MotionBounce = AnimationKindsSetting.bounceFallback - public init(enabled: Set = [], bounce: MotionBounce = AnimationKindsSetting.bounceFallback) { + /// The kinds on when cmux.json does not name them: new tabs. + public static let defaultEnabled: Set = [.newTab] + + public init(enabled: Set = AnimationKindsSetting.defaultEnabled, bounce: MotionBounce = AnimationKindsSetting.bounceFallback) { self.enabled = enabled self.bounce = bounce } @@ -26,7 +30,7 @@ public nonisolated struct AnimationKindsSetting: Sendable, Equatable { /// The kinds turned on and the bounce. A missing key is its default /// with no diagnostic; a bad value is the default plus a diagnostic. static func parse(_ root: JSONValue) -> (AnimationKindsSetting, [SettingsDiagnostic]) { - var enabled: Set = [] + var enabled = defaultEnabled var diagnostics: [SettingsDiagnostic] = [] for kind in MotionKind.allCases { guard let value = root.value(at: path(kind)) else { continue } @@ -34,7 +38,7 @@ public nonisolated struct AnimationKindsSetting: Sendable, Equatable { diagnostics.append(SettingsDiagnostic(kind: .invalidValue, path: path(kind).joined(separator: "."), message: "expected true or false")) continue } - if on { enabled.insert(kind) } + if on { enabled.insert(kind) } else { enabled.remove(kind) } } var bounce = bounceFallback if let value = root.value(at: bouncePath) { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Appearance.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Appearance.swift index 18165f4f024c..b79971aef91e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Appearance.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Appearance.swift @@ -236,7 +236,7 @@ nonisolated enum AppearanceSettingsSchema { title: SettingsText.keyed("settings.ui.animations.newTab", "Animate New Tabs"), help: SettingsText.keyed("settings.ui.animations.newTab.help", "A new tab grows into its place in the tab bar. Off shows it there at once."), - kind: .toggle, default: .bool(false), keywords: ["motion", "animation", "tab"] + kind: .toggle, default: .bool(AnimationKindsSetting.defaultEnabled.contains(.newTab)), keywords: ["motion", "animation", "tab"] ), SettingDescriptor( AnimationKindsSetting.path(.newWorkspace), section: .appearance, group: look, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabMotion.swift index 5ed49e50ee29..b9f9a6c79dfd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabMotion.swift @@ -12,7 +12,7 @@ struct TabMotion { init(x: CGFloat, width: CGFloat, alpha: CGFloat) { self.x = Spring(value: x, token: .move, kind: .position) - self.width = Spring(value: width, token: width == 0 ? .appear : .move) + self.width = Spring(value: width, token: width == 0 ? .tabOpen : .move) self.alpha = Spring(value: alpha, token: .appear, epsilon: 0.004) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift index dd6ca864fc7b..76bd6a85a2c3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift @@ -71,6 +71,10 @@ extension TabStripView { m = TabMotion(x: slot.x, width: grows ? 0 : slot.width, alpha: grows ? 0 : 1) } } + // A tab growing in and the tabs right of it making room move on one spring (`tabOpen`), + // so the strip never overlaps; later moves go back to `move`. + if grows, !added.contains(slot.id) { (m.x.token, m.width.token) = (.tabOpen, .tabOpen) } + else if !grows, m.x.token == .tabOpen, m.x.isSettled { (m.x.token, m.width.token) = (.move, .move) } if drag?.id != slot.id { m.x.target = groupDragX(for: slot) ?? slot.x } if groups.isDragged(slot.id) { m.x.snap() } m.width.target = slot.width diff --git a/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h b/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h index 2e8dce7e1a4b..2c3ebc98a013 100644 --- a/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h +++ b/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h @@ -31,7 +31,7 @@ typedef struct { uint32_t cmux_motion_ffi_abi_version(void); /* token: 0 move, 1 appear, 2 disappear, 3 settle, 4 scroll, 5 screen, - * 6 track, 7 selection, 8 panel, 9 pinDrag, 10 zoom, 11 exit + * 6 track, 7 selection, 8 panel, 9 pinDrag, 10 zoom, 11 exit, 12 tabOpen * (the macOS MotionSpring declares its cases in this order). */ bool cmux_motion_spring_base(uint8_t token, CmuxMotionSpring *out); bool cmux_motion_spring_is_structural(uint8_t token); diff --git a/cmux-tui/crates/cmux-app-ffi/src/motion.rs b/cmux-tui/crates/cmux-app-ffi/src/motion.rs index d0184acf0bec..5ce8b70273e1 100644 --- a/cmux-tui/crates/cmux-app-ffi/src/motion.rs +++ b/cmux-tui/crates/cmux-app-ffi/src/motion.rs @@ -78,7 +78,7 @@ pub extern "C" fn cmux_motion_ffi_abi_version() -> u32 { /// The spring token `token` (`MotionSpring` order: 0 move, 1 appear, 2 /// disappear, 3 settle, 4 scroll, 5 screen, 6 track, 7 selection, 8 panel, -/// 9 pinDrag, 10 zoom, 11 exit; the macOS `MotionSpring` declares its +/// 9 pinDrag, 10 zoom, 11 exit, 12 tabOpen; the macOS `MotionSpring` declares its /// cases in this order) at speed `fast` from the spec, before the policy. Returns /// false for an unknown token. /// diff --git a/cmux-tui/crates/cmux-motion/build.rs b/cmux-tui/crates/cmux-motion/build.rs index 1b2d85c809b1..65bb3f243f0d 100644 --- a/cmux-tui/crates/cmux-motion/build.rs +++ b/cmux-tui/crates/cmux-motion/build.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; const TOKENS: &str = "../../../plans/cmux-next/spec-proposals/visuals/design-tokens.json"; /// `motion.springs` keys in `MotionSpring` declaration order. -const SPRINGS: [&str; 12] = [ +const SPRINGS: [&str; 13] = [ "move", "appear", "disappear", @@ -29,6 +29,7 @@ const SPRINGS: [&str; 12] = [ "pinDrag", "zoom", "exit", + "tabOpen", ]; /// `motion.fades` keys in `MotionFade` declaration order. const FADES: [&str; 12] = [ diff --git a/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs b/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs index 04f9307b7efa..5e592cce19ed 100644 --- a/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs +++ b/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs @@ -13,7 +13,7 @@ use cmux_motion::{ }; /// Tokens both ports have, by their spec name. -const TOKENS: [(&str, MotionSpring); 12] = [ +const TOKENS: [(&str, MotionSpring); 13] = [ ("move", MotionSpring::Move), ("appear", MotionSpring::Appear), ("disappear", MotionSpring::Disappear), @@ -26,6 +26,7 @@ const TOKENS: [(&str, MotionSpring); 12] = [ ("pinDrag", MotionSpring::PinDrag), ("zoom", MotionSpring::Zoom), ("exit", MotionSpring::Exit), + ("tabOpen", MotionSpring::TabOpen), ]; /// Frames until rest (at most 3 s) from 0 toward 200, retargeting to diff --git a/cmux-tui/crates/cmux-motion/src/tokens.rs b/cmux-tui/crates/cmux-motion/src/tokens.rs index e5f3adcf4807..9df6ad45d23f 100644 --- a/cmux-tui/crates/cmux-motion/src/tokens.rs +++ b/cmux-tui/crates/cmux-motion/src/tokens.rs @@ -91,10 +91,13 @@ pub enum MotionSpring { /// (macOS `Motion.exit(nearness:)` lengthens it up to 25% for the /// panes nearer the zoomed one, so the farthest is gone first). Exit, + /// A new tab growing in at its final slot while the tabs right of it + /// make room, on one spring (`ui.animations.newTab`). Structural. + TabOpen, } impl MotionSpring { - pub const ALL: [Self; 12] = [ + pub const ALL: [Self; 13] = [ Self::Move, Self::Appear, Self::Disappear, @@ -107,6 +110,7 @@ impl MotionSpring { Self::PinDrag, Self::Zoom, Self::Exit, + Self::TabOpen, ]; /// The token's tuning at speed `fast` (design-tokens.json diff --git a/plans/cmux-next/motion.md b/plans/cmux-next/motion.md index cd8cca9ef78c..40dcc6b96b45 100644 --- a/plans/cmux-next/motion.md +++ b/plans/cmux-next/motion.md @@ -41,9 +41,12 @@ would do it, spring curves", "proper physics". Design: - Bounce: structural tokens (`move`, `appear`, `disappear`) are critically damped by default (never past the target); `ui.animations.bounce` "subtle" gives them bounce `motion.spring.subtleBounce` (0.15). -- Per kind, under `ui.animationSpeed`, all off by default (Lawrence - 2026-10-14: no big animations by default): `ui.paneAnimations` (pane - layout, rule 9), `ui.animations.newTab`, `ui.animations.newWorkspace`. +- Per kind, under `ui.animationSpeed`: `ui.paneAnimations` (pane layout, + rule 9) and `ui.animations.newWorkspace` off by default (Lawrence + 2026-10-14: no big animations by default), `ui.animations.newTab` on + (Lawrence 2026-10-14 after the instant build: keep the tab-open motion; a + new tab grows in at its slot on the `tabOpen` spring, response 0.1 s, + critically damped, visible end ~118 ms, the tabs right of it on the same spring). Off applies that change in one step; true opts into its spring. cmux-motion `MotionKind` / `AnimationSwitches` carry exactly these three keys (`MotionKind::config_path`). Palette: Toggle New Tab / New Workspace diff --git a/plans/cmux-next/spec-proposals/visuals/design-tokens.json b/plans/cmux-next/spec-proposals/visuals/design-tokens.json index ab7b78bfb73d..e702650ff0a2 100644 --- a/plans/cmux-next/spec-proposals/visuals/design-tokens.json +++ b/plans/cmux-next/spec-proposals/visuals/design-tokens.json @@ -2186,6 +2186,15 @@ "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", "unit": "s", "use": "The panes a zoom leaves fading out; Motion.exit(nearness:) lengthens it up to 25% for nearer panes, so the farthest (the 2x2 opposite corner) is gone first" + }, + "tabOpen": { + "value": { + "response": 0.1, + "dampingFraction": 1 + }, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", + "unit": "s", + "use": "A new tab growing in at its final slot and the tabs right of it making room, on one spring (ui.animations.newTab, on by default; Lawrence 2026-10-14: the tab-open motion of 10-11, about 7 frames / 115 ms). Visible end 118 ms." } }, "springModel": { @@ -2206,7 +2215,8 @@ "value": [ "move", "appear", - "disappear" + "disappear", + "tabOpen" ], "source": "cmux-tui/crates/cmux-motion (MotionSpring::is_structural); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift (MotionSpring.isStructural)", "use": "Springs that take or give up room. They follow motion.bounce; release, scroll and overlay springs keep their own damping." @@ -2223,11 +2233,11 @@ "kinds": { "value": { "paneAnimations": false, - "newTab": false, + "newTab": true, "newWorkspace": false }, "source": "ui.paneAnimations / ui.animations.newTab / ui.animations.newWorkspace (cmux.json); cmux-tui/crates/cmux-motion (AnimationSwitches, MotionKind::config_path)", - "use": "Per-kind switches under ui.animationSpeed, all off by default (Lawrence 2026-10-14: no big animations by default): the change applies in one frame (no crossfade); true opts into its spring. paneAnimations: split grow-in, close, move, swap, zoom and unzoom, column reveal, screen switch, divider release. newTab: a tab growing into its slot. newWorkspace: a sidebar row opening." + "use": "Per-kind switches under ui.animationSpeed, paneAnimations and newWorkspace off by default (Lawrence 2026-10-14: no big animations by default), newTab on (Lawrence 2026-10-14: keep the tab-open motion); off applies the change in one frame (no crossfade). paneAnimations: split grow-in, close, move, swap, zoom and unzoom, column reveal, screen switch, divider release. newTab: a tab growing into its slot. newWorkspace: a sidebar row opening." }, "fades": { "hover": { From c6037052f55ce693d43d157de9cefada4096b68c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 06:11:44 -0700 Subject: [PATCH 054/183] Release v0.65.2 (#20629) * Bump version to 0.65.2 Co-Authored-By: Claude Opus 5.5 * Release notes: move fixes that shipped in 0.65.1, credit reporters, list turn-rail removal Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 73 ++++++++++++++++++- Resources/Localizable.xcstrings | 40 +++++----- cmux.xcodeproj/project.pbxproj | 48 ++++++------ .../docs/changelog/changelog-media.ts | 22 +++++- 4 files changed, 135 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558828dbc9d1..f6c163a841fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,69 @@ All notable changes to cmux are documented here. +## [0.65.2] - 2026-10-14 + +### Changed +- The one-time Cloud welcome also shows on 0.65.2 for anyone who has not seen it yet, and only once ([#20057](https://github.com/manaflow-ai/cmux/pull/20057), [#20243](https://github.com/manaflow-ai/cmux/pull/20243)) +- Cloud upgrade offers the next plan tier and no longer asks Max subscribers to buy Pro ([#20603](https://github.com/manaflow-ai/cmux/pull/20603)) + +### Fixed +- A new Cloud split shows its terminal sooner ([#20016](https://github.com/manaflow-ai/cmux/pull/20016)) +- Cloud machine actions answer within the app's request budget and keep running in the background ([#19982](https://github.com/manaflow-ai/cmux/pull/19982)) +- Fixes a Cloud workspace layout that kept rebuilding the pane it had just reused ([#20198](https://github.com/manaflow-ai/cmux/pull/20198)) +- Fn-Left and Fn-Right send Home and End in the terminal ([#18671](https://github.com/manaflow-ai/cmux/pull/18671)) -- thanks @theoklitosBam7 for the report! +- The terminal reports its real color scheme to CSI 996 queries ([#20024](https://github.com/manaflow-ai/cmux/pull/20024)) -- thanks @teamleaderleo, and thanks @sebushi113 for the report! +- Fixes invisible terminal text on light themes when `minimum-contrast` is set ([#19921](https://github.com/manaflow-ai/cmux/pull/19921)) -- thanks @teamleaderleo, and thanks @travisp for the report! +- Canvas mode shows the translucent window backdrop when `background-opacity` is below 1 ([#17548](https://github.com/manaflow-ai/cmux/pull/17548)) -- thanks @csyio, and thanks @CinnamonRolls1 for the report! +- The Cloud CLI bridge keeps its owner across remote reconnects ([#16213](https://github.com/manaflow-ai/cmux/pull/16213)) -- thanks @teamleaderleo! +- `cmux browser` rejects invalid automation arguments instead of running them ([#18838](https://github.com/manaflow-ai/cmux/pull/18838)) -- thanks @soyeladice-svg, and thanks @teamleaderleo for the report! +- Sign-in refreshes its token only when it is close to expiring, retries when the sign-in service is busy instead of failing, and shows the error details when sign-in fails ([#20237](https://github.com/manaflow-ai/cmux/pull/20237), [#20270](https://github.com/manaflow-ai/cmux/pull/20270)) +- Cmd-N and the controls that share it run a custom `ui.newWorkspace.action` again ([#20287](https://github.com/manaflow-ai/cmux/pull/20287)) -- thanks @nebullii, and thanks @clain23 for the report! +- Restored `cmux ssh` workspaces keep connecting instead of showing SSH errors, and cmux's private route marker no longer leaks into `scp` and `ssh` ([#20586](https://github.com/manaflow-ai/cmux/pull/20586), [#20279](https://github.com/manaflow-ai/cmux/pull/20279)) -- thanks @nebullii, and thanks @dericknwq for the report! +- Agent Running/Idle status stays accurate when a Stop hook never arrives, and Claude stays idle after a helper's PreToolUse follows Stop ([#20564](https://github.com/manaflow-ai/cmux/pull/20564), [#20262](https://github.com/manaflow-ai/cmux/pull/20262)) -- thanks @BMeyendriesch for the report! +- Acknowledged agent notifications stay dismissed ([#20547](https://github.com/manaflow-ai/cmux/pull/20547)) +- Agent sessions restore more reliably after a relaunch: Codex lock contention is retried, a Claude session that never wrote a transcript is not restored, Claude Code resumes in the right directory, and agy and OpenCode auto-resume ([#20246](https://github.com/manaflow-ai/cmux/pull/20246), [#20499](https://github.com/manaflow-ai/cmux/pull/20499), [#20498](https://github.com/manaflow-ai/cmux/pull/20498), [#20249](https://github.com/manaflow-ai/cmux/pull/20249), [#20226](https://github.com/manaflow-ai/cmux/pull/20226), [#20441](https://github.com/manaflow-ai/cmux/pull/20441)) +- OpenCode lifecycle notifications work again ([#20444](https://github.com/manaflow-ai/cmux/pull/20444)) +- Codex no longer asks to review cmux's own hooks, locally or on Cloud machines ([#20482](https://github.com/manaflow-ai/cmux/pull/20482), [#20524](https://github.com/manaflow-ai/cmux/pull/20524)) +- Backspace no longer drops text an input method commits on the same keypress ([#20466](https://github.com/manaflow-ai/cmux/pull/20466)) -- thanks @vjintageboy! +- Return reaches forms in browser panes ([#20449](https://github.com/manaflow-ai/cmux/pull/20449)) +- Windows stay on their own Space after a display reconnects ([#20428](https://github.com/manaflow-ai/cmux/pull/20428)) -- thanks @MinsehKim for the report! +- Remote tmux split shortcuts go to the projected panes ([#18333](https://github.com/manaflow-ai/cmux/pull/18333)) -- thanks @Mirksen, and thanks @dev-fennek for the report! +- Surfaces created through the control API no longer replay inherited startup work ([#20312](https://github.com/manaflow-ai/cmux/pull/20312)) -- thanks @SeanPedersen for the report! +- A custom upload command gets the right remote paths ([#20260](https://github.com/manaflow-ai/cmux/pull/20260)) +- Fixes hangs from per-render tab-strip work ([#20238](https://github.com/manaflow-ai/cmux/pull/20238)) +- Shared team members can enable Cloud, a failed team or plan fetch is no longer read as no team or the free plan, a full Cloud resource pool is explained instead of reported as a missing plan, and the first upgrade click opens checkout ([#20425](https://github.com/manaflow-ai/cmux/pull/20425), [#20056](https://github.com/manaflow-ai/cmux/pull/20056), [#20066](https://github.com/manaflow-ai/cmux/pull/20066), [#20252](https://github.com/manaflow-ai/cmux/pull/20252)) +- Cloud machines recover when frozen, machine stats and pause no longer hang on an unresponsive machine, wedged terminal links recover, and `cmux vm restart` prints OK like pause and resume ([#20361](https://github.com/manaflow-ai/cmux/pull/20361), [#20345](https://github.com/manaflow-ai/cmux/pull/20345), [#20376](https://github.com/manaflow-ai/cmux/pull/20376), [#20526](https://github.com/manaflow-ai/cmux/pull/20526)) + +### Removed +- Claude Code and Codex terminals no longer draw the agent turn rail or the restoring-session banner, so their text lines up with plain terminals again after a relaunch; the Previous/Next Agent Turn shortcuts (Cmd-Shift-Up/Down) and their command palette entries are gone with it ([#20060](https://github.com/manaflow-ai/cmux/pull/20060)) +- Terminal panes no longer show a Jump to Bottom button ([#20180](https://github.com/manaflow-ai/cmux/pull/20180)) + +### Thanks to 18 contributors! + +- [@austinywang](https://github.com/austinywang) +- [@BMeyendriesch](https://github.com/BMeyendriesch) +- [@CinnamonRolls1](https://github.com/CinnamonRolls1) +- [@clain23](https://github.com/clain23) +- [@csyio](https://github.com/csyio) +- [@dericknwq](https://github.com/dericknwq) +- [@dev-fennek](https://github.com/dev-fennek) +- [@lawrencecchen](https://github.com/lawrencecchen) +- [@MinsehKim](https://github.com/MinsehKim) +- [@Mirksen](https://github.com/Mirksen) +- [@nebullii](https://github.com/nebullii) +- [@SeanPedersen](https://github.com/SeanPedersen) +- [@sebushi113](https://github.com/sebushi113) +- [@soyeladice-svg](https://github.com/soyeladice-svg) +- [@teamleaderleo](https://github.com/teamleaderleo) +- [@theoklitosBam7](https://github.com/theoklitosBam7) +- [@travisp](https://github.com/travisp) +- [@vjintageboy](https://github.com/vjintageboy) + ## [0.65.1] - 2026-10-12 ### Added -- Ctrl-Tab and Cmd-Shift-] / Cmd-Shift-[ move between sidebar workspaces from any pane, Ctrl-Cmd-E focuses the workspace sidebar, and Ctrl-Cmd-1…9 focuses a pane by number; all are rebindable ([#19795](https://github.com/manaflow-ai/cmux/pull/19795), [#19324](https://github.com/manaflow-ai/cmux/pull/19324), [#19263](https://github.com/manaflow-ai/cmux/pull/19263)) +- Ctrl-Cmd-E focuses the workspace sidebar, and Ctrl-Cmd-1…9 focuses a pane by number; all are rebindable ([#19324](https://github.com/manaflow-ai/cmux/pull/19324), [#19263](https://github.com/manaflow-ai/cmux/pull/19263)) - Cmd-Shift-B jumps back to the pane where you last sent a prompt ([#18951](https://github.com/manaflow-ai/cmux/pull/18951)) -- thanks @lucasr1b! - Cmd-Option-F global search finds open Claude Code and Codex sessions by what was said in them ([#18962](https://github.com/manaflow-ai/cmux/pull/18962)) -- thanks @lucasr1b! - Programs can report idle, working, blocked, done, or error status with OSC 7501, shown in the workspace sidebar and published as `program_status.changed` on the `cmux-events` stream ([#18742](https://github.com/manaflow-ai/cmux/pull/18742), [#19091](https://github.com/manaflow-ai/cmux/pull/19091)) @@ -26,7 +85,6 @@ All notable changes to cmux are documented here. ### Changed - Cmd-N always creates a local workspace, Cmd-Y creates a Cloud workspace, and Cmd-Shift-Y opens New Cloud Machine ([#18895](https://github.com/manaflow-ai/cmux/pull/18895)) -- Previous and Next tab shortcuts move between workspaces when the focused pane has only one tab ([#18939](https://github.com/manaflow-ai/cmux/pull/18939)) - Cmd-click on a path opens relative paths as files, opens folders in Finder, opens `notes.md:12` in the Markdown viewer, and does nothing for missing paths; Cmd-hover underlines only paths that exist ([#19376](https://github.com/manaflow-ai/cmux/pull/19376), [#19397](https://github.com/manaflow-ai/cmux/pull/19397), [#19443](https://github.com/manaflow-ai/cmux/pull/19443), [#19452](https://github.com/manaflow-ai/cmux/pull/19452)) - cmux downloads are smaller: stable ships thin arm64 and x86_64 builds in an LZMA-compressed DMG with a one-hop update path, and the app drops duplicate binaries and unused libraries ([#19255](https://github.com/manaflow-ai/cmux/pull/19255), [#19211](https://github.com/manaflow-ai/cmux/pull/19211), [#19386](https://github.com/manaflow-ai/cmux/pull/19386), [#19356](https://github.com/manaflow-ai/cmux/pull/19356), [#19428](https://github.com/manaflow-ai/cmux/pull/19428), [#19379](https://github.com/manaflow-ai/cmux/pull/19379), [#19355](https://github.com/manaflow-ai/cmux/pull/19355)) - SSH helper payloads for Linux and macOS hosts download on demand instead of shipping inside the app ([#19305](https://github.com/manaflow-ai/cmux/pull/19305), [#19361](https://github.com/manaflow-ai/cmux/pull/19361)) @@ -40,6 +98,14 @@ All notable changes to cmux are documented here. - cmux Cloud (beta): Cloud machine create and resize options follow your plan's limits, and the Cloud sidebar gets hover, copy, refresh, empty-row, and tab-switch polish ([#18481](https://github.com/manaflow-ai/cmux/pull/18481), [#18303](https://github.com/manaflow-ai/cmux/pull/18303), [#18693](https://github.com/manaflow-ai/cmux/pull/18693), [#19085](https://github.com/manaflow-ai/cmux/pull/19085)) -- thanks @lucasr1b, @azooz2003-bit! ### Fixed +- The Cloud VM limit header names the machines it counts and no longer counts paused ones ([#19968](https://github.com/manaflow-ai/cmux/pull/19968)) +- Fixes dragging a sidebar workspace to reorder it, and a crash when the drag ends ([#20015](https://github.com/manaflow-ai/cmux/pull/20015), [#20018](https://github.com/manaflow-ai/cmux/pull/20018)) +- Cloud connects on the first try, re-enrolls a dead tunnel instead of timing out on every machine, and scopes sign-out to its own build ([#19961](https://github.com/manaflow-ai/cmux/pull/19961), [#19977](https://github.com/manaflow-ai/cmux/pull/19977)) +- Account-owned Macs stay in My Devices across team switches ([#18518](https://github.com/manaflow-ai/cmux/pull/18518)) -- thanks @azooz2003-bit! +- Fixes the terminal mode reset after a hibernated agent is reaped ([#19806](https://github.com/manaflow-ai/cmux/pull/19806)) -- thanks @russell7 for the report! +- Agent status is reported in `cmux ssh-tmux` mirrors ([#19104](https://github.com/manaflow-ai/cmux/pull/19104)) +- Cloud workspaces are selected in the window that owns them, and a window closed during Cloud workspace creation is released ([#19970](https://github.com/manaflow-ai/cmux/pull/19970), [#20001](https://github.com/manaflow-ai/cmux/pull/20001)) +- New Machine is created in process ([#18700](https://github.com/manaflow-ai/cmux/pull/18700)) - Fixes a startup exception crash in CmuxMain ([#18452](https://github.com/manaflow-ai/cmux/pull/18452)) - Fixes a crash from a stack overflow while the Settings window is built ([#18457](https://github.com/manaflow-ai/cmux/pull/18457)) - Fixes a crash on Edit > Undo after a text editor was closed ([#18467](https://github.com/manaflow-ai/cmux/pull/18467)) @@ -117,7 +183,7 @@ All notable changes to cmux are documented here. - Remote tmux (beta): New Workspace can be routed to the host, origin colors and host suffixes are repaired, and detach no longer adds a second replacement terminal ([#19112](https://github.com/manaflow-ai/cmux/pull/19112), [#19096](https://github.com/manaflow-ai/cmux/pull/19096), [#19148](https://github.com/manaflow-ai/cmux/pull/19148)) - Custom sidebars (beta) autofocus only after a user gesture, and `clipboard.write` is gesture-gated and write-only ([#19372](https://github.com/manaflow-ai/cmux/pull/19372), [#19333](https://github.com/manaflow-ai/cmux/pull/19333)) -### Thanks to 67 contributors! +### Thanks to 68 contributors! - [@AdamDureyAtWillship](https://github.com/AdamDureyAtWillship) - [@aidaodedjl](https://github.com/aidaodedjl) @@ -173,6 +239,7 @@ All notable changes to cmux are documented here. - [@pipitmart](https://github.com/pipitmart) - [@roypadina](https://github.com/roypadina) - [@RuslanTar](https://github.com/RuslanTar) +- [@russell7](https://github.com/russell7) - [@s4ff0x](https://github.com/s4ff0x) - [@samuelpatro](https://github.com/samuelpatro) - [@scs0209](https://github.com/scs0209) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ac0be8b7225f..b2b6efe2edc8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -571328,121 +571328,121 @@ "en": { "stringUnit": { "state": "translated", - "value": " (Claude Code and Pi hooks are injected automatically via their cmux wrappers)" + "value": " (Antigravity, Claude Code, Grok, Muse, and Pi hooks are injected automatically via cmux wrappers)" } }, "ar": { "stringUnit": { "state": "translated", - "value": " (يتم حقن خطافات Claude Code وPi تلقائيًا عبر أغلفة cmux)" + "value": " (يتم حقن خطافات Antigravity وClaude Code وGrok وMuse وPi تلقائيًا عبر أغلفة cmux)" } }, "bs": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Claude Code i Pi kukice se automatski ubacuju putem cmux omotača)" } }, "da": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Claude Code- og Pi-hooks indsættes automatisk via deres cmux-wrappere)" } }, "de": { "stringUnit": { "state": "translated", - "value": " (Claude-Code- und Pi-Hooks werden automatisch über ihre cmux-Wrapper eingefügt)" + "value": " (Antigravity-, Claude-Code-, Grok-, Muse- und Pi-Hooks werden automatisch über cmux-Wrapper eingefügt)" } }, "es": { "stringUnit": { "state": "translated", - "value": " (Los hooks de Claude Code y Pi se inyectan automáticamente mediante sus wrappers de cmux)" + "value": " (Los hooks de Antigravity, Claude Code, Grok, Muse y Pi se inyectan automáticamente mediante los wrappers de cmux)" } }, "fr": { "stringUnit": { "state": "translated", - "value": " (Les hooks de Claude Code et Pi sont injectés automatiquement par leurs wrappers cmux)" + "value": " (Les hooks d'Antigravity, Claude Code, Grok, Muse et Pi sont injectés automatiquement par les wrappers cmux)" } }, "it": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Gli hook di Claude Code e Pi vengono iniettati automaticamente tramite i rispettivi wrapper cmux)" } }, "ja": { "stringUnit": { "state": "translated", - "value": " (Claude Code と Pi のフックは cmux ラッパーによって自動的に注入されます)" + "value": " (Antigravity、Claude Code、Grok、Muse、Pi のフックは cmux ラッパーによって自動的に注入されます)" } }, "km": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (hooks របស់ Claude Code និង Pi ត្រូវបានបញ្ចូលដោយស្វ័យប្រវត្តិតាមរយៈ wrapper របស់ cmux)" } }, "ko": { "stringUnit": { "state": "translated", - "value": " (Claude Code 및 Pi 훅은 cmux 래퍼를 통해 자동으로 주입됩니다)" + "value": " (Antigravity, Claude Code, Grok, Muse, Pi 훅은 cmux 래퍼를 통해 자동으로 주입됩니다)" } }, "nb": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Claude Code- og Pi-hooker settes inn automatisk via cmux-innpakningene)" } }, "pl": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Hooki Claude Code i Pi są automatycznie wstrzykiwane przez ich opakowania cmux)" } }, "pt-BR": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Os hooks do Claude Code e do Pi são injetados automaticamente pelos respectivos wrappers do cmux)" } }, "ru": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Хуки Claude Code и Pi автоматически внедряются их оболочками cmux)" } }, "th": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (hooks ของ Claude Code และ Pi จะถูกฉีดโดยอัตโนมัติผ่าน wrapper ของ cmux)" } }, "tr": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Claude Code ve Pi kancaları, cmux sarmalayıcıları aracılığıyla otomatik olarak eklenir)" } }, "uk": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": " (Хуки Claude Code і Pi автоматично додаються їхніми обгортками cmux)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": " (Claude Code 和 Pi 的钩子会通过各自的 cmux 包装器自动注入)" + "value": " (Antigravity、Claude Code、Grok、Muse 和 Pi 的钩子会通过 cmux 包装器自动注入)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": " (Claude Code 和 Pi 的掛鉤會透過各自的 cmux 包裝器自動注入)" + "value": " (Antigravity、Claude Code、Grok、Muse 和 Pi 的掛鉤會透過 cmux 包裝器自動注入)" } } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 343dfd888392..fed60adb4fd3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -19206,10 +19206,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.clitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -19222,10 +19222,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.clitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -19237,10 +19237,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -19328,7 +19328,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -19337,7 +19337,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -19379,7 +19379,7 @@ CODE_SIGN_ENTITLEMENTS = Resources/cmux.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -19388,7 +19388,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; ONLY_ACTIVE_ARCH = NO; OTHER_LDFLAGS = ( "-lc++", @@ -19455,10 +19455,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -19473,7 +19473,7 @@ buildSettings = { CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; @@ -19482,7 +19482,7 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.docktileplugin.debug; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -19498,7 +19498,7 @@ buildSettings = { CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; @@ -19507,7 +19507,7 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.docktileplugin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -19522,11 +19522,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSPrincipalClass = CmuxTestsPrincipal; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -19543,11 +19543,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSPrincipalClass = CmuxTestsPrincipal; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -19565,7 +19565,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = NO; @@ -19579,7 +19579,7 @@ "$(BUILT_PRODUCTS_DIR)", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.debug.tunnel; PRODUCT_MODULE_NAME = cmuxTunnel; PRODUCT_NAME = cmuxTunnel; @@ -19598,7 +19598,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 111; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = NO; @@ -19612,7 +19612,7 @@ "$(BUILT_PRODUCTS_DIR)", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.65.1; + MARKETING_VERSION = 0.65.2; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.tunnel; PRODUCT_MODULE_NAME = cmuxTunnel; PRODUCT_NAME = cmuxTunnel; diff --git a/web/app/[locale]/(landing)/docs/changelog/changelog-media.ts b/web/app/[locale]/(landing)/docs/changelog/changelog-media.ts index 60f076be9f32..3c13ef85bb3e 100644 --- a/web/app/[locale]/(landing)/docs/changelog/changelog-media.ts +++ b/web/app/[locale]/(landing)/docs/changelog/changelog-media.ts @@ -53,13 +53,33 @@ export interface VersionMedia { } export const changelogMedia: Record = { + "0.65.2": { + title: "Reliable Agent Restores, Steadier Cloud, Calmer Sign-In", + features: [ + { + title: "Reliable Agent Restores", + description: + "Agent sessions come back after a relaunch: Codex retries a busy lock, Claude Code resumes in the right directory, agy and OpenCode auto-resume, and Running/Idle stays accurate when a Stop hook never arrives. Codex no longer asks to review cmux's own hooks.", + }, + { + title: "Steadier Cloud", + description: + "Cloud recovers frozen machines and wedged terminal links, shows a new split's terminal sooner, and explains a full resource pool instead of reporting a missing plan. Shared team members can enable Cloud, and the upgrade offer shows your next plan tier.", + }, + { + title: "Calmer Sign-In", + description: + "Sign-in refreshes its token only near expiry, retries when the sign-in service is busy instead of failing, and shows the error details when it does fail.", + }, + ], + }, "0.65.1": { title: "Keyboard Navigation, Session Search, Smarter Cmd-Click, Smaller Download", features: [ { title: "Move Around From the Keyboard", description: - "Ctrl-Tab and Cmd-Shift-] / [ move between sidebar workspaces from any pane. Ctrl-Cmd-E focuses the sidebar, Ctrl-Cmd-1 through 9 focuses a pane by number, and Cmd-Shift-B jumps back to the pane where you last sent a prompt.", + "Ctrl-Cmd-E focuses the sidebar, Ctrl-Cmd-1 through 9 focuses a pane by number, and Cmd-Shift-B jumps back to the pane where you last sent a prompt.", tryIt: "Press `⌃⌘1` to focus the first pane.", }, { From 88cc4d3881220d9862bfecedb00167dfb8013675 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:27:35 -0700 Subject: [PATCH 055/183] iOS billing: Monthly/Yearly plans, refund request, analytics, unavailable-plans retry, no web purchase links (#20572) * iOS billing: product and purchase analytics, unavailable-plans retry, no web purchase links - Add ios_products_loaded, ios_products_failed, ios_purchase_succeeded. - An eligible account with no products shows the unavailable state with Try Again instead of an endless spinner. - Ineligible web subscriber copy: "You subscribe on the web." - New Machine sheet no longer opens cmux.com/pricing without a billing model. * iOS billing: count Ask to Buy approvals, dedupe product-load events, spinner on retry * iOS billing: Monthly/Yearly plans, billing_period analytics, Pro Yearly in dev StoreKit config - Products carry billingPeriod (from the server field, else the product-id suffix); the client sends x-cmux-billing-periods: monthly,yearly. - Plans screen: Monthly/Yearly picker (Yearly first, as on cmux.com/pricing), one offer per plan, per-month equivalent for yearly products. - ios_products_loaded and ios_purchase_succeeded (and ios_purchase_started) carry billing_period. - Renewal disclosure names monthly or yearly renewal. * iOS billing: MobilePlansView initialPeriod parameter * iOS billing: current App Store product from StoreKit entitlements; picker only when a plan has both periods * iOS billing: Request a Refund (StoreKit refund request sheet) with ios_refund_request_opened/result events * iOS billing: refund target fixed at tap; current product only for this cmux account; generation guard --- .../BillingAnalyticsEvent.swift | 20 +++ .../CmuxMobileBilling/BillingModel.swift | 137 +++++++++++++++++- .../CmuxMobileBilling/BillingOffer.swift | 6 +- .../CmuxMobileBilling/BillingPeriod.swift | 25 ++++ .../BillingProductReference.swift | 18 ++- .../CmuxMobileBilling/HTTPBillingAPI.swift | 3 + .../LiveStoreKitClient.swift | 25 +++- .../CmuxMobileBilling/StoreKitClient.swift | 5 + .../CmuxMobileBilling/StoreProduct.swift | 18 ++- .../CmuxMobileBilling/StoreTransaction.swift | 14 +- .../MobilePlanOfferRow.swift | 10 ++ .../CmuxMobileBillingUI/MobilePlansView.swift | 104 +++++++++++-- .../BillingModelTests.swift | 6 +- .../Tests/CmuxMobileBillingTests/Fakes.swift | 4 + .../CmuxMobileCloudUI/CloudSectionView.swift | 42 ++---- ios/Config/cmux-dev.storekit | 22 +++ ios/cmux/Resources/Localizable.xcstrings | 9 +- web/services/analytics/iosEventPolicy.ts | 2 + 18 files changed, 413 insertions(+), 57 deletions(-) create mode 100644 Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingPeriod.swift diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingAnalyticsEvent.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingAnalyticsEvent.swift index 4a7713d64552..96c882fecb43 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingAnalyticsEvent.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingAnalyticsEvent.swift @@ -9,6 +9,14 @@ public struct BillingAnalyticsEvent: Sendable, Equatable, Hashable { /// The plans screen appeared. public static let paywallViewed = BillingAnalyticsEvent(name: "ios_paywall_viewed") + /// StoreKit returned at least one product the server sells to this + /// bundle. Properties: `product_ids` (comma-separated), `storefront`, + /// `count`, `billing_period` (the loaded periods, comma-separated). + public static let productsLoaded = BillingAnalyticsEvent(name: "ios_products_loaded") + /// An eligible account has no product to buy: StoreKit failed, returned + /// none of the server's products, or the server listed none. Properties: + /// `reason`, `storefront`. + public static let productsFailed = BillingAnalyticsEvent(name: "ios_products_failed") /// The user tapped a plan's purchase button. public static let purchaseStarted = BillingAnalyticsEvent(name: "ios_purchase_started") /// The user dismissed the App Store purchase sheet. @@ -16,6 +24,18 @@ public struct BillingAnalyticsEvent: Sendable, Equatable, Hashable { /// The purchase failed before the App Store completed it, or StoreKit /// could not verify it. public static let purchaseFailed = BillingAnalyticsEvent(name: "ios_purchase_failed") + /// The App Store charged the user and StoreKit verified the transaction. + /// `delivery` is `accepted` when the server applied the plan, `deferred` + /// when the server step is retried later. Properties: `product_id`, + /// `plan_id`, `billing_period`, `price`, `currency`, `environment`, + /// `delivery`. + public static let purchaseSucceeded = BillingAnalyticsEvent(name: "ios_purchase_succeeded") + /// The App Store refund request sheet opened from the plans screen. + /// Properties: `product_id`. + public static let refundRequestOpened = BillingAnalyticsEvent(name: "ios_refund_request_opened") + /// The refund request sheet closed. Properties: `product_id`, `result` + /// (`success`, `userCancelled`, or `error`). + public static let refundRequestResult = BillingAnalyticsEvent(name: "ios_refund_request_result") /// The purchase waits for approval (Ask to Buy). public static let purchasePending = BillingAnalyticsEvent(name: "ios_purchase_pending") /// The user tapped Restore Purchases. diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingModel.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingModel.swift index c79a4ecc2857..00759213fece 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingModel.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingModel.swift @@ -32,6 +32,14 @@ public final class BillingModel { public private(set) var offers: [BillingOffer] = [] /// Why StoreKit products could not be loaded, when they could not. public private(set) var offersFailure: BillingFailure? + /// The product of the account's active App Store subscription on this + /// Apple Account, from StoreKit's current entitlements; nil when unknown. + /// It tells Pro Monthly from Pro Yearly, which the server's plan id + /// cannot. + public private(set) var currentAppleProductID: String? + /// The latest transaction of that subscription, for the App Store refund + /// request sheet; nil when unknown. + public private(set) var currentAppleTransactionID: UInt64? /// The current purchase attempt. public private(set) var purchase: BillingPurchasePhase = .idle /// The current restore attempt. @@ -48,6 +56,9 @@ public final class BillingModel { /// Transactions the server refused for good for the current account. They /// stay unfinished; automatic retries skip them until sign-out. private var rejectedTransactions: [UInt64: BillingFailure] = [:] + /// The last product-load outcome sent to analytics, so a reload with the + /// same result (after a purchase, restore or renewal) is not counted again. + private var lastProductsEvent: String? /// Creates the model. /// - Parameters: @@ -105,6 +116,7 @@ public final class BillingModel { "entry_point": .string(entryPoint.rawValue), "product_id": .string(offer.product.id), "plan_id": .string(offer.planID.rawValue), + "billing_period": .string(offer.billingPeriod.rawValue), ] capture(.purchaseStarted, properties) guard let account = account.account else { @@ -140,6 +152,7 @@ public final class BillingModel { switch await deliver(transaction) { case .accepted(let receipt): purchase = .completed(receipt.planID) + capturePurchaseSucceeded(offer, transaction, delivery: "accepted") await loadAccount() case .rejected(let failure): fail(failure, properties: properties) @@ -149,6 +162,7 @@ public final class BillingModel { // server also learns of it from App Store Server // Notifications, so this is not a failed purchase. purchase = .awaitingServer(productID: offer.product.id) + capturePurchaseSucceeded(offer, transaction, delivery: "deferred") } } } @@ -205,6 +219,20 @@ public final class BillingModel { } } + /// Records that the App Store refund request sheet opened. + /// - Parameter productID: The subscription the refund is for. + public func refundRequestOpened(productID: String) { + capture(.refundRequestOpened, ["product_id": .string(productID)]) + } + + /// Records how the App Store refund request sheet ended. + /// - Parameters: + /// - productID: The subscription the refund is for. + /// - result: `success`, `userCancelled`, or `error`. + public func refundRequestFinished(productID: String, result: String) { + capture(.refundRequestResult, ["product_id": .string(productID), "result": .string(result)]) + } + /// Clears the result banner of the last purchase or restore. public func dismissResult() { if !purchase.isPurchasing { purchase = .idle } @@ -218,6 +246,9 @@ public final class BillingModel { public func resetForSignOut() { accountGeneration += 1 rejectedTransactions = [:] + lastProductsEvent = nil + currentAppleProductID = nil + currentAppleTransactionID = nil account = .idle offers = [] offersFailure = nil @@ -238,6 +269,12 @@ public final class BillingModel { case .deferred: return } + if case .pending(let productID) = purchase, productID == transaction.productID, + let offer = offers.first(where: { $0.product.id == productID }) { + // An approved Ask to Buy purchase: the App Store charged now. + // An awaitingServer purchase already reported its success. + capturePurchaseSucceeded(offer, transaction, delivery: "accepted") + } if purchase.isWaiting(for: transaction.productID) { purchase = .completed(receipt.planID) } @@ -276,28 +313,118 @@ public final class BillingModel { guard generation == accountGeneration else { return } account = .loaded(loaded) await loadOffers(for: loaded, generation: generation) + await loadCurrentAppleProduct(for: loaded, generation: generation) } private func loadOffers(for account: BillingAccount, generation: Int) async { - guard account.eligible, !account.products.isEmpty else { + guard account.eligible else { offers = [] offersFailure = nil return } + guard !account.products.isEmpty else { + // Eligible, but the server sells nothing to this bundle: show + // the unavailable state, not an endless spinner. + offers = [] + offersFailure = .productUnavailable + await captureProductsFailed(reason: "no_server_products", generation: generation) + return + } + if offers.isEmpty { + // Show the spinner again while a retry loads. + offersFailure = nil + } let products: [StoreProduct] do { products = try await store.products(for: account.products.map(\.productID)) } catch { guard generation == accountGeneration else { return } - offersFailure = BillingFailure(error) + let failure = BillingFailure(error) + // Offers from an earlier load stay on screen; the failure shows + // only when there is nothing to buy. + offersFailure = failure + await captureProductsFailed(reason: failure.analyticsReason, generation: generation) return } guard generation == accountGeneration else { return } let byID = Dictionary(products.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) offers = account.products - .compactMap { reference in byID[reference.productID].map { BillingOffer(planID: reference.planID, product: $0) } } - .sorted { $0.planID < $1.planID } - offersFailure = offers.isEmpty ? .productUnavailable : nil + .compactMap { reference in + byID[reference.productID].map { + BillingOffer(planID: reference.planID, product: $0, billingPeriod: reference.billingPeriod) + } + } + .sorted { ($0.planID, $0.billingPeriod) < ($1.planID, $1.billingPeriod) } + if offers.isEmpty { + offersFailure = .productUnavailable + await captureProductsFailed(reason: BillingFailure.productUnavailable.analyticsReason, generation: generation) + } else { + offersFailure = nil + let loadedIDs = offers.map(\.product.id) + let periods = Set(offers.map(\.billingPeriod)).sorted().map(\.rawValue).joined(separator: ",") + let signature = "loaded:" + loadedIDs.joined(separator: ",") + guard signature != lastProductsEvent else { return } + let storefront = await store.storefrontCountryCode() + guard generation == accountGeneration, signature != lastProductsEvent else { return } + lastProductsEvent = signature + capture(.productsLoaded, [ + "product_ids": .string(loadedIDs.joined(separator: ",")), + "storefront": .string(storefront ?? "unknown"), + "count": .int(loadedIDs.count), + "billing_period": .string(periods), + ]) + } + } + + private func loadCurrentAppleProduct(for account: BillingAccount, generation: Int) async { + guard generation == accountGeneration else { return } + guard account.currentPlan.source == .apple else { + currentAppleProductID = nil + currentAppleTransactionID = nil + return + } + let sold = Set(account.products.filter { $0.planID == account.currentPlan.planID }.map(\.productID)) + var current: StoreTransaction? + for entitlement in await store.currentEntitlements() { + // Only this cmux account's subscription: the Apple Account may + // also hold one bought for another cmux account. + if case .verified(let transaction) = entitlement, sold.contains(transaction.productID), + transaction.appAccountToken == account.appAccountToken { + current = transaction + } + } + guard generation == accountGeneration else { return } + currentAppleProductID = current?.productID + currentAppleTransactionID = current?.id + } + + private func captureProductsFailed(reason: String, generation: Int) async { + let signature = "failed:" + reason + guard signature != lastProductsEvent else { return } + let storefront = await store.storefrontCountryCode() + guard generation == accountGeneration, signature != lastProductsEvent else { return } + lastProductsEvent = signature + capture(.productsFailed, [ + "reason": .string(reason), + "storefront": .string(storefront ?? "unknown"), + ]) + } + + private func capturePurchaseSucceeded(_ offer: BillingOffer, _ transaction: StoreTransaction, delivery: String) { + var properties: [String: AnalyticsValue] = [ + "product_id": .string(transaction.productID), + "plan_id": .string(offer.planID.rawValue), + "billing_period": .string(offer.billingPeriod.rawValue), + "environment": .string(transaction.environment ?? "unknown"), + "delivery": .string(delivery), + ] + if let price = offer.product.price { + properties["price"] = .double(NSDecimalNumber(decimal: price).doubleValue) + } + if let currency = offer.product.currencyCode { + properties["currency"] = .string(currency) + } + capture(.purchaseSucceeded, properties) } private func fail(_ failure: BillingFailure, properties: [String: AnalyticsValue]) { diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingOffer.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingOffer.swift index 46b9d175ca73..9e3c91fc789e 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingOffer.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingOffer.swift @@ -7,6 +7,8 @@ public struct BillingOffer: Sendable, Equatable, Hashable, Identifiable { public let planID: BillingPlanID /// The StoreKit product. public let product: StoreProduct + /// How often the product renews. + public let billingPeriod: BillingPeriod /// The App Store product id. public var id: String { product.id } @@ -15,8 +17,10 @@ public struct BillingOffer: Sendable, Equatable, Hashable, Identifiable { /// - Parameters: /// - planID: The plan the product grants. /// - product: The StoreKit product. - public init(planID: BillingPlanID, product: StoreProduct) { + /// - billingPeriod: How often it renews. + public init(planID: BillingPlanID, product: StoreProduct, billingPeriod: BillingPeriod = .monthly) { self.planID = planID self.product = product + self.billingPeriod = billingPeriod } } diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingPeriod.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingPeriod.swift new file mode 100644 index 000000000000..5b8ed09e17de --- /dev/null +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingPeriod.swift @@ -0,0 +1,25 @@ +import Foundation + +/// How often a subscription renews. The server sells each plan monthly and, +/// for some plans, yearly (`docs/billing/ios-in-app-purchases.md`). +public enum BillingPeriod: String, Sendable, Equatable, Hashable, Codable, CaseIterable, Comparable { + case monthly + case yearly + + /// The period of a `..` product id, or nil when the + /// suffix names neither period. + /// - Parameter productID: An App Store product id. + public init?(productID: String) { + if productID.hasSuffix(".yearly") { + self = .yearly + } else if productID.hasSuffix(".monthly") { + self = .monthly + } else { + return nil + } + } + + public static func < (lhs: BillingPeriod, rhs: BillingPeriod) -> Bool { + lhs == .monthly && rhs == .yearly + } +} diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingProductReference.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingProductReference.swift index 8129cd6ef49f..c2253dec122d 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingProductReference.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingProductReference.swift @@ -6,18 +6,34 @@ public struct BillingProductReference: Sendable, Equatable, Hashable, Codable { public let productID: String /// The cmux plan the product grants. public let planID: BillingPlanID + /// How often the product renews. + public let billingPeriod: BillingPeriod /// Creates a product reference. /// - Parameters: /// - productID: The App Store product id. /// - planID: The cmux plan it grants. - public init(productID: String, planID: BillingPlanID) { + /// - billingPeriod: How often it renews; nil reads it from the + /// product id suffix, and monthly when the suffix names no period. + public init(productID: String, planID: BillingPlanID, billingPeriod: BillingPeriod? = nil) { self.productID = productID self.planID = planID + self.billingPeriod = billingPeriod ?? BillingPeriod(productID: productID) ?? .monthly } enum CodingKeys: String, CodingKey { case productID = "productId" case planID = "planId" + case billingPeriod + } + + /// Decodes a product; a missing or unknown `billingPeriod` (a server that + /// predates yearly plans) falls back to the product id suffix. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let productID = try container.decode(String.self, forKey: .productID) + let planID = try container.decode(BillingPlanID.self, forKey: .planID) + let raw = try container.decodeIfPresent(String.self, forKey: .billingPeriod) + self.init(productID: productID, planID: planID, billingPeriod: raw.flatMap(BillingPeriod.init(rawValue:))) } } diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/HTTPBillingAPI.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/HTTPBillingAPI.swift index 8b6a1356273e..0e55a3a3f0f8 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/HTTPBillingAPI.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/HTTPBillingAPI.swift @@ -87,6 +87,9 @@ public struct HTTPBillingAPI: BillingAPI { request.setValue("Bearer \(pair.accessToken)", forHTTPHeaderField: "Authorization") request.setValue(pair.refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token") request.setValue(bundleID, forHTTPHeaderField: "x-cmux-bundle-id") + // This client offers a Monthly/Yearly choice, so the server may list + // yearly products; older clients do not send it and get monthly only. + request.setValue("monthly,yearly", forHTTPHeaderField: "x-cmux-billing-periods") if let environment = await storeKitEnvironment() { request.setValue(environment, forHTTPHeaderField: "x-cmux-storekit-environment") } diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/LiveStoreKitClient.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/LiveStoreKitClient.swift index 28915649a6a1..332244c2baba 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/LiveStoreKitClient.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/LiveStoreKitClient.swift @@ -92,6 +92,10 @@ public actor LiveStoreKitClient: StoreKitClient { } } + public func storefrontCountryCode() async -> String? { + await Storefront.current?.countryCode + } + public func finish(transactionID: UInt64) async { if let transaction = heldTransactions.removeValue(forKey: transactionID) { await transaction.finish() @@ -128,7 +132,8 @@ public actor LiveStoreKitClient: StoreKitClient { id: transaction.id, productID: transaction.productID, jwsRepresentation: verification.jwsRepresentation, - appAccountToken: transaction.appAccountToken + appAccountToken: transaction.appAccountToken, + environment: Self.environmentName(transaction.environment) )) case .unverified(let transaction, _): return .unverified(transactionID: transaction.id, productID: transaction.productID) @@ -136,15 +141,29 @@ public actor LiveStoreKitClient: StoreKitClient { } private static func storeProduct(_ product: Product) -> StoreProduct { - StoreProduct( + let period = product.subscription?.subscriptionPeriod + let isYearly = period.map { $0.unit == .year && $0.value == 1 } ?? false + return StoreProduct( id: product.id, displayName: product.displayName, productDescription: product.description, displayPrice: product.displayPrice, - periodUnitName: product.subscription.map { $0.subscriptionPeriod.unit.localizedDescription } + periodUnitName: period.map { $0.unit.localizedDescription }, + price: product.price, + currencyCode: product.priceFormatStyle.currencyCode, + monthlyEquivalentDisplayPrice: isYearly ? (product.price / 12).formatted(product.priceFormatStyle) : nil ) } + private static func environmentName(_ environment: AppStore.Environment) -> String? { + switch environment { + case .production: "Production" + case .sandbox: "Sandbox" + case .xcode: "Xcode" + default: nil + } + } + private static func normalized(_ error: any Error) -> StoreKitClientError { if let error = error as? StoreKitClientError { return error } if let error = error as? StoreKitError { diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreKitClient.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreKitClient.swift index e8fbe16543c8..616c99bf7615 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreKitClient.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreKitClient.swift @@ -38,6 +38,11 @@ public protocol StoreKitClient: Sendable { /// that prompt, or a StoreKit error. func syncWithAppStore() async throws + /// The App Store storefront country code (ISO 3166-1 alpha-3, such as + /// `USA`), for analytics. + /// - Returns: The country code, or nil when StoreKit has no storefront. + func storefrontCountryCode() async -> String? + /// Finishes a transaction after the server accepted it. /// - Parameter transactionID: The transaction to finish. func finish(transactionID: UInt64) async diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreProduct.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreProduct.swift index 8338a0200153..1070165c2aa4 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreProduct.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreProduct.swift @@ -15,6 +15,13 @@ public struct StoreProduct: Sendable, Equatable, Hashable, Identifiable { public let displayPrice: String /// The localized renewal period, such as `month`; nil when unknown. public let periodUnitName: String? + /// The numeric price in ``currencyCode``, for analytics; nil when unknown. + public let price: Decimal? + /// The ISO 4217 currency code of ``price``, such as `USD`; nil when unknown. + public let currencyCode: String? + /// For a yearly product, the yearly price divided by 12 in the + /// storefront's currency format, such as `$58.33`; nil otherwise. + public let monthlyEquivalentDisplayPrice: String? /// Creates a product value. /// - Parameters: @@ -23,17 +30,26 @@ public struct StoreProduct: Sendable, Equatable, Hashable, Identifiable { /// - productDescription: The description. /// - displayPrice: The localized price. /// - periodUnitName: The localized renewal period, when known. + /// - price: The numeric price, when known. + /// - currencyCode: The ISO 4217 currency code of `price`, when known. + /// - monthlyEquivalentDisplayPrice: A yearly product's per-month price. public init( id: String, displayName: String, productDescription: String, displayPrice: String, - periodUnitName: String? = nil + periodUnitName: String? = nil, + price: Decimal? = nil, + currencyCode: String? = nil, + monthlyEquivalentDisplayPrice: String? = nil ) { self.id = id self.displayName = displayName self.productDescription = productDescription self.displayPrice = displayPrice self.periodUnitName = periodUnitName + self.price = price + self.currencyCode = currencyCode + self.monthlyEquivalentDisplayPrice = monthlyEquivalentDisplayPrice } } diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreTransaction.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreTransaction.swift index 4269637eac4a..5e9217c2a6b5 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreTransaction.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/StoreTransaction.swift @@ -14,6 +14,10 @@ public struct StoreTransaction: Sendable, Equatable, Hashable, Identifiable { public let jwsRepresentation: String /// The `appAccountToken` the purchase carried, when it carried one. public let appAccountToken: UUID? + /// The App Store environment of the transaction: `Production`, + /// `Sandbox` (TestFlight and App Review), or `Xcode` (a StoreKit + /// configuration file); nil when StoreKit does not say. + public let environment: String? /// Creates a transaction value. /// - Parameters: @@ -21,10 +25,18 @@ public struct StoreTransaction: Sendable, Equatable, Hashable, Identifiable { /// - productID: The product id. /// - jwsRepresentation: The signed JWS. /// - appAccountToken: The purchase's account token, when present. - public init(id: UInt64, productID: String, jwsRepresentation: String, appAccountToken: UUID?) { + /// - environment: The App Store environment, when known. + public init( + id: UInt64, + productID: String, + jwsRepresentation: String, + appAccountToken: UUID?, + environment: String? = nil + ) { self.id = id self.productID = productID self.jwsRepresentation = jwsRepresentation self.appAccountToken = appAccountToken + self.environment = environment } } diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBillingUI/MobilePlanOfferRow.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBillingUI/MobilePlanOfferRow.swift index ef4c8cbf1e73..c4d25f4dd413 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBillingUI/MobilePlanOfferRow.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBillingUI/MobilePlanOfferRow.swift @@ -29,6 +29,16 @@ struct MobilePlanOfferRow: View { .monospacedDigit() .accessibilityIdentifier("BillingOfferPrice.\(offer.planID.rawValue)") } + if let monthly = offer.product.monthlyEquivalentDisplayPrice { + Text(String( + format: L10n.string("mobile.billing.monthlyEquivalent", defaultValue: "%@ / month, billed yearly"), + monthly + )) + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + .accessibilityIdentifier("BillingOfferMonthlyEquivalent.\(offer.planID.rawValue)") + } ForEach(copy.features, id: \.self) { feature in Label(feature, systemImage: "checkmark") .font(.subheadline) diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBillingUI/MobilePlansView.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBillingUI/MobilePlansView.swift index 8172512e5ae2..182d0e2adf65 100644 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBillingUI/MobilePlansView.swift +++ b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBillingUI/MobilePlansView.swift @@ -23,6 +23,12 @@ import StoreKit public struct MobilePlansView: View { @Environment(BillingModel.self) private var billing @State private var showingManageSubscriptions = false + @State private var showingRefundRequest = false + /// The subscription the open refund sheet is for, fixed at the tap so a + /// reload while the sheet is up cannot change or drop it. + @State private var refundTarget: RefundTarget? + /// The Monthly/Yearly choice. + @State private var period: BillingPeriod private let entryPoint: BillingEntryPoint /// The Terms of Use page. @@ -31,9 +37,13 @@ public struct MobilePlansView: View { static let privacyURL = URL(string: "https://cmux.com/privacy-policy")! /// Creates the plans screen. - /// - Parameter entryPoint: Where the screen was opened from, for analytics. - public init(entryPoint: BillingEntryPoint) { + /// - Parameters: + /// - entryPoint: Where the screen was opened from, for analytics. + /// - initialPeriod: The billing period selected first. Yearly by + /// default, as on cmux.com/pricing. + public init(entryPoint: BillingEntryPoint, initialPeriod: BillingPeriod = .yearly) { self.entryPoint = entryPoint + _period = State(initialValue: initialPeriod) } public var body: some View { @@ -53,6 +63,20 @@ public struct MobilePlansView: View { } .refreshable { await billing.refresh() } .manageSubscriptionsSheet(isPresented: $showingManageSubscriptions) + .refundRequestSheet( + for: refundTarget?.transactionID ?? 0, + isPresented: $showingRefundRequest + ) { result in + guard let target = refundTarget else { return } + refundTarget = nil + let outcome: String + switch result { + case .success(.success): outcome = "success" + case .success(.userCancelled): outcome = "userCancelled" + default: outcome = "error" + } + billing.refundRequestFinished(productID: target.productID, result: outcome) + } .onDisappear { billing.dismissResult() } .accessibilityIdentifier("BillingPlansView") } @@ -111,21 +135,42 @@ public struct MobilePlansView: View { @ViewBuilder private func offersSection(_ account: BillingAccount) -> some View { if billing.offers.isEmpty { - Section { - if let failure = billing.offersFailure { + if let failure = billing.offersFailure { + Section { Text(BillingFailureCopy(failure: failure).message) .foregroundStyle(.secondary) - } else { + Button(L10n.string("mobile.billing.retry", defaultValue: "Try Again")) { + Task { await billing.refresh() } + } + } + .accessibilityIdentifier("BillingOffersUnavailable") + } else { + Section { ProgressView() } + .accessibilityIdentifier("BillingOffersLoading") } } else { let applePlan = account.currentPlan.source == .apple ? account.currentPlan.planID : nil - ForEach(billing.offers) { offer in + if hasPeriodChoice { + Section { + Picker(L10n.string("mobile.billing.period", defaultValue: "Billing period"), selection: $period) { + Text(L10n.string("mobile.billing.period.monthly", defaultValue: "Monthly")) + .tag(BillingPeriod.monthly) + Text(L10n.string("mobile.billing.period.yearly", defaultValue: "Yearly")) + .tag(BillingPeriod.yearly) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("BillingPeriodPicker") + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + ForEach(visibleOffers) { offer in Section { MobilePlanOfferRow( offer: offer, - isCurrent: applePlan == offer.planID, + isCurrent: isCurrent(offer, applePlan: applePlan), isSwitch: applePlan != nil, isBusy: billing.purchase.isPurchasing, buy: { Task { await billing.buy(offer, entryPoint: entryPoint) } } @@ -135,6 +180,32 @@ public struct MobilePlansView: View { } } + /// True when at least one plan is sold both monthly and yearly. + private var hasPeriodChoice: Bool { + Dictionary(grouping: billing.offers, by: \.planID).values.contains { offers in + Set(offers.map(\.billingPeriod)).count > 1 + } + } + + /// The offer is the active App Store subscription. With the active + /// product known, only that product is current, so the other period of + /// the same plan stays buyable as a switch; otherwise the plan decides. + private func isCurrent(_ offer: BillingOffer, applePlan: BillingPlanID?) -> Bool { + if let productID = billing.currentAppleProductID { return productID == offer.product.id } + return applePlan == offer.planID + } + + /// One offer per plan: the chosen period when the plan has it, else the + /// period the plan is sold in (Go is monthly only). + private var visibleOffers: [BillingOffer] { + var byPlan: [BillingPlanID: BillingOffer] = [:] + for offer in billing.offers { + if let existing = byPlan[offer.planID], existing.billingPeriod == period { continue } + byPlan[offer.planID] = offer + } + return byPlan.values.sorted { $0.planID < $1.planID } + } + @ViewBuilder private var resultSection: some View { if let message = resultMessage { @@ -165,6 +236,15 @@ public struct MobilePlansView: View { showingManageSubscriptions = true } .accessibilityIdentifier("BillingManage") + if let productID = billing.currentAppleProductID, + let transactionID = billing.currentAppleTransactionID { + Button(L10n.string("mobile.billing.refund", defaultValue: "Request a Refund")) { + refundTarget = RefundTarget(productID: productID, transactionID: transactionID) + billing.refundRequestOpened(productID: productID) + showingRefundRequest = true + } + .accessibilityIdentifier("BillingRefund") + } } } } @@ -177,7 +257,7 @@ public struct MobilePlansView: View { } footer: { Text(L10n.string( "mobile.billing.disclosure", - defaultValue: "Subscriptions renew automatically each month at the price shown until cancelled. Payment is charged to your Apple Account when you confirm the purchase. Your account is charged for renewal within 24 hours before the current period ends unless auto-renew is turned off at least 24 hours before then. Manage or cancel your subscription in your Apple Account settings." + defaultValue: "Subscriptions renew automatically at the end of each month or year, as shown, at the price shown until cancelled. Payment is charged to your Apple Account when you confirm the purchase. Your account is charged for renewal within 24 hours before the current period ends unless auto-renew is turned off at least 24 hours before then. Manage or cancel your subscription in your Apple Account settings." )) } } @@ -238,7 +318,7 @@ public struct MobilePlansView: View { // billing (App Review Guideline 3.1.1). L10n.string( "mobile.billing.ineligible.web", - defaultValue: "You subscribe on the web. Manage your plan at cmux.com." + defaultValue: "You subscribe on the web." ) case .teamBilling: L10n.string( @@ -258,4 +338,10 @@ public struct MobilePlansView: View { } } } + +/// The subscription a refund request is for. +private struct RefundTarget: Equatable { + let productID: String + let transactionID: UInt64 +} #endif diff --git a/Packages/iOS/CmuxMobileBilling/Tests/CmuxMobileBillingTests/BillingModelTests.swift b/Packages/iOS/CmuxMobileBilling/Tests/CmuxMobileBillingTests/BillingModelTests.swift index 0fe9bb7f5c03..5b05e66aeafa 100644 --- a/Packages/iOS/CmuxMobileBilling/Tests/CmuxMobileBillingTests/BillingModelTests.swift +++ b/Packages/iOS/CmuxMobileBilling/Tests/CmuxMobileBillingTests/BillingModelTests.swift @@ -44,7 +44,7 @@ struct BillingRig { ]) #expect(rig.model.purchase == .completed(.pro)) #expect(await rig.store.purchaseTokens == [BillingFixtures.token]) - #expect(rig.analytics.names == ["ios_purchase_started"]) + #expect(rig.analytics.names == ["ios_products_loaded", "ios_purchase_started", "ios_purchase_succeeded"]) } @Test func serverRejectionKeepsTransactionUnfinishedUntilRetrySucceeds() async throws { @@ -188,7 +188,7 @@ struct BillingRig { #expect(rig.model.purchase == .idle) #expect(await rig.api.submitted.isEmpty) - #expect(rig.analytics.names == ["ios_purchase_started", "ios_purchase_cancelled"]) + #expect(rig.analytics.names == ["ios_products_loaded", "ios_purchase_started", "ios_purchase_cancelled"]) } @Test func storeKitCancellationErrorCountsAsCancel() async throws { @@ -307,7 +307,7 @@ struct BillingRig { #expect(await rig.store.syncCount == 1) #expect(await rig.api.submitted == ["jws-21"]) #expect(rig.model.restore == .completed(acceptedCount: 1)) - #expect(rig.analytics.names == ["ios_restore_started", "ios_restore_completed"]) + #expect(rig.analytics.names == ["ios_products_loaded", "ios_restore_started", "ios_restore_completed"]) #expect(rig.analytics.events.last?.1["restored_count"] == .int(1)) } diff --git a/Packages/iOS/CmuxMobileBilling/Tests/CmuxMobileBillingTests/Fakes.swift b/Packages/iOS/CmuxMobileBilling/Tests/CmuxMobileBillingTests/Fakes.swift index 5f509383a925..be028c12546c 100644 --- a/Packages/iOS/CmuxMobileBilling/Tests/CmuxMobileBillingTests/Fakes.swift +++ b/Packages/iOS/CmuxMobileBilling/Tests/CmuxMobileBillingTests/Fakes.swift @@ -126,6 +126,10 @@ actor FakeStoreKitClient: StoreKitClient { if let syncError { throw syncError } } + func storefrontCountryCode() async -> String? { + "USA" + } + func finish(transactionID: UInt64) async { // Record every effect before publishing completion, so a waiter that // returns on `finished` or the stream sees the log entry too. diff --git a/Packages/iOS/CmuxMobileCloudUI/Sources/CmuxMobileCloudUI/CloudSectionView.swift b/Packages/iOS/CmuxMobileCloudUI/Sources/CmuxMobileCloudUI/CloudSectionView.swift index d665a26dafc2..140e8650e88f 100644 --- a/Packages/iOS/CmuxMobileCloudUI/Sources/CmuxMobileCloudUI/CloudSectionView.swift +++ b/Packages/iOS/CmuxMobileCloudUI/Sources/CmuxMobileCloudUI/CloudSectionView.swift @@ -181,9 +181,9 @@ struct CloudCreateMachineSheet: View { let availableKinds: Set? let limits: CloudMachineLimits? @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL /// App Store billing; when present, upgrade actions open the in-app plans - /// sheet instead of the web pricing page. + /// sheet. Without it the locked sizes stay as plain text: the App Store + /// build never links to a web purchase page (App Review Guideline 3.1.1). @Environment(BillingModel.self) private var billing: BillingModel? @State private var selectedMemoryMb: Int @State private var isPlansSheetPresented = false @@ -221,10 +221,11 @@ struct CloudCreateMachineSheet: View { } ForEach(lockedMemoryOptions, id: \.self) { memoryMb in Button { - openUpgradePage(planID: upgradePlanID(for: memoryMb)) + openPlans() } label: { Label(lockedSizeMenuTitle(memoryMb), systemImage: "lock.fill") } + .disabled(billing == nil) .accessibilityIdentifier("CloudCreateMachineLockedSize.\(memoryMb)") } } label: { @@ -246,9 +247,9 @@ struct CloudCreateMachineSheet: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) - if let upgradeActionTitle { + if billing != nil, let upgradeActionTitle { Button(upgradeActionTitle) { - openUpgradePage(planID: highestLockedMemoryUpgradePlanID) + openPlans() } .controlSize(.small) .buttonStyle(.bordered) @@ -358,7 +359,6 @@ struct CloudCreateMachineSheet: View { } } - private static let pricingURL = URL(string: "https://cmux.com/pricing")! private static let fallbackMemoryMb = 8192 private var machineKind: CloudMachineKind { @@ -409,10 +409,6 @@ struct CloudCreateMachineSheet: View { return ListFormatter.localizedString(byJoining: names) } - private var highestLockedMemoryUpgradePlanID: String? { - lockedMemoryUpgradePlanIDs.max { upgradePriority($0) < upgradePriority($1) } - } - private var upgradeActionTitle: String? { guard let planNames = lockedMemoryUpgradePlanNames else { return nil } if lockedMemoryUpgradePlanIDs == ["max"] { @@ -511,14 +507,6 @@ struct CloudCreateMachineSheet: View { } } - private func upgradePriority(_ planID: String) -> Int { - switch normalizedPlanID(planID) { - case "max": return 2 - case "pro": return 1 - default: return 0 - } - } - private func memoryLabel(_ memoryMb: Int) -> String { String( format: L10n.string("mobile.cloud.create.size.gb", defaultValue: "%d GB"), @@ -545,19 +533,11 @@ struct CloudCreateMachineSheet: View { } } - private func openUpgradePage(planID: String?) { - // App Store builds sell plans in app (Guideline 3.1.1); the web - // pricing page remains only for hosts without a billing model. - if billing != nil { - isPlansSheetPresented = true - return - } - guard let planID, var components = URLComponents(url: Self.pricingURL, resolvingAgainstBaseURL: false) else { - openURL(Self.pricingURL) - return - } - components.queryItems = [URLQueryItem(name: "plan", value: planID)] - openURL(components.url ?? Self.pricingURL) + /// Opens the in-app plans sheet. Plans are sold only in app (App Review + /// Guideline 3.1.1), so without a billing model this does nothing. + private func openPlans() { + guard billing != nil else { return } + isPlansSheetPresented = true } } diff --git a/ios/Config/cmux-dev.storekit b/ios/Config/cmux-dev.storekit index 70bfae67060c..8e3bcdaff8b6 100644 --- a/ios/Config/cmux-dev.storekit +++ b/ios/Config/cmux-dev.storekit @@ -68,6 +68,28 @@ "type" : "RecurringSubscription", "winbackOffers" : [] }, + { + "adHocOffers" : [], + "codeOffers" : [], + "displayPrice" : "699.99", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "6749000004", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Up to 5 Cloud VMs sharing 20 vCPUs and 40 GB RAM.", + "displayName" : "cmux Pro", + "locale" : "en_US" + } + ], + "productID" : "dev.cmux.ios.pro.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Pro Yearly", + "subscriptionGroupID" : "21490001", + "type" : "RecurringSubscription", + "winbackOffers" : [] + }, { "adHocOffers" : [], "codeOffers" : [], diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index c9103ee65d3c..3fb3dac67d66 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -104038,7 +104038,12 @@ "mobile.billing.awaitingServer": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Purchase complete. Your plan updates as soon as cmux confirms it. You don't need to buy again."}},"de":{"stringUnit":{"state":"translated","value":"Kauf abgeschlossen. Dein Tarif wird aktualisiert, sobald cmux ihn bestätigt. Du musst nicht erneut kaufen."}},"fr":{"stringUnit":{"state":"translated","value":"Achat terminé. Votre forfait sera mis à jour dès que cmux l’aura confirmé. Inutile d’acheter à nouveau."}},"ar":{"stringUnit":{"state":"translated","value":"اكتملت عملية الشراء. سيتم تحديث خطتك فور تأكيد cmux لها. لا داعي للشراء مرة أخرى."}},"es":{"stringUnit":{"state":"translated","value":"Compra completada. Tu plan se actualizará en cuanto cmux la confirme. No necesitas comprar de nuevo."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"購買完成。cmux 確認後就會更新你的方案,不需要再次購買。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"购买完成。cmux 确认后会立即更新你的方案,无需再次购买。"}},"ko":{"stringUnit":{"state":"translated","value":"구입이 완료되었습니다. cmux가 확인하는 즉시 플랜이 업데이트됩니다. 다시 구입할 필요가 없습니다."}},"ja":{"stringUnit":{"state":"translated","value":"購入が完了しました。cmux が確認するとすぐにプランが更新されます。再購入は不要です。"}}}}, "mobile.billing.completed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"You're on %@ now."}},"de":{"stringUnit":{"state":"translated","value":"Du nutzt jetzt %@."}},"fr":{"stringUnit":{"state":"translated","value":"Vous avez maintenant le forfait %@."}},"ar":{"stringUnit":{"state":"translated","value":"أنت الآن على خطة %@."}},"es":{"stringUnit":{"state":"translated","value":"Ya tienes el plan %@."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你現在使用 %@ 方案。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你现在使用 %@ 方案。"}},"ko":{"stringUnit":{"state":"translated","value":"이제 %@ 플랜을 사용 중입니다."}},"ja":{"stringUnit":{"state":"translated","value":"%@ プランになりました。"}}}}, "mobile.billing.currentPlan": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Current plan"}},"de":{"stringUnit":{"state":"translated","value":"Aktueller Tarif"}},"fr":{"stringUnit":{"state":"translated","value":"Forfait actuel"}},"ar":{"stringUnit":{"state":"translated","value":"الخطة الحالية"}},"es":{"stringUnit":{"state":"translated","value":"Plan actual"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"目前方案"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"当前方案"}},"ko":{"stringUnit":{"state":"translated","value":"현재 플랜"}},"ja":{"stringUnit":{"state":"translated","value":"現在のプラン"}}}}, - "mobile.billing.disclosure": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Subscriptions renew automatically each month at the price shown until cancelled. Payment is charged to your Apple Account when you confirm the purchase. Your account is charged for renewal within 24 hours before the current period ends unless auto-renew is turned off at least 24 hours before then. Manage or cancel your subscription in your Apple Account settings."}},"de":{"stringUnit":{"state":"translated","value":"Abos verlängern sich jeden Monat automatisch zum angezeigten Preis, bis sie gekündigt werden. Die Zahlung wird bei Bestätigung des Kaufs deinem Apple Account belastet. Die Verlängerung wird innerhalb von 24 Stunden vor Ende des aktuellen Zeitraums belastet, sofern die automatische Verlängerung nicht mindestens 24 Stunden vorher deaktiviert wird. Verwalte oder kündige dein Abo in den Einstellungen deines Apple Accounts."}},"fr":{"stringUnit":{"state":"translated","value":"Les abonnements se renouvellent automatiquement chaque mois au prix indiqué jusqu’à leur résiliation. Le paiement est débité de votre compte Apple lorsque vous confirmez l’achat. Le renouvellement est facturé dans les 24 heures précédant la fin de la période en cours, sauf si le renouvellement automatique est désactivé au moins 24 heures avant. Gérez ou résiliez votre abonnement dans les réglages de votre compte Apple."}},"ar":{"stringUnit":{"state":"translated","value":"تتجدد الاشتراكات تلقائيًا كل شهر بالسعر المعروض حتى يتم إلغاؤها. يتم تحصيل الدفعة من حساب Apple الخاص بك عند تأكيد الشراء. يتم تحصيل رسوم التجديد خلال 24 ساعة قبل نهاية الفترة الحالية ما لم يتم إيقاف التجديد التلقائي قبل ذلك بـ 24 ساعة على الأقل. يمكنك إدارة اشتراكك أو إلغاؤه من إعدادات حساب Apple."}},"es":{"stringUnit":{"state":"translated","value":"Las suscripciones se renuevan automáticamente cada mes al precio indicado hasta que se cancelen. El pago se carga en tu cuenta de Apple al confirmar la compra. La renovación se cobra en las 24 horas anteriores al final del periodo actual, salvo que desactives la renovación automática al menos 24 horas antes. Gestiona o cancela tu suscripción en los ajustes de tu cuenta de Apple."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"訂閱會以顯示的價格每月自動續訂,直到取消為止。確認購買時,款項將從你的 Apple 帳號扣款。除非在目前期間結束前至少 24 小時關閉自動續訂,否則將在目前期間結束前 24 小時內收取續訂費用。你可以在 Apple 帳號設定中管理或取消訂閱。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"订阅会以显示的价格每月自动续订,直至取消。确认购买时,将从你的 Apple 账户扣款。除非在当前周期结束前至少 24 小时关闭自动续订,否则将在当前周期结束前 24 小时内收取续订费用。你可以在 Apple 账户设置中管理或取消订阅。"}},"ko":{"stringUnit":{"state":"translated","value":"구독은 취소할 때까지 매월 표시된 가격으로 자동 갱신됩니다. 구입을 확인하면 Apple 계정으로 결제됩니다. 현재 기간이 끝나기 최소 24시간 전에 자동 갱신을 끄지 않으면 현재 기간 종료 전 24시간 이내에 갱신 요금이 청구됩니다. Apple 계정 설정에서 구독을 관리하거나 취소할 수 있습니다."}},"ja":{"stringUnit":{"state":"translated","value":"サブスクリプションは解約するまで、表示価格で毎月自動更新されます。購入を確定すると Apple アカウントに請求されます。現在の期間が終了する 24 時間前までに自動更新をオフにしない限り、期間終了前の 24 時間以内に更新料金が請求されます。サブスクリプションの管理や解約は Apple アカウントの設定で行えます。"}}}}, + "mobile.billing.period": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Billing period"}},"de":{"stringUnit":{"state":"translated","value":"Abrechnungszeitraum"}},"fr":{"stringUnit":{"state":"translated","value":"Période de facturation"}},"ar":{"stringUnit":{"state":"translated","value":"فترة الفوترة"}},"es":{"stringUnit":{"state":"translated","value":"Periodo de facturación"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"計費週期"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"计费周期"}},"ko":{"stringUnit":{"state":"translated","value":"결제 주기"}},"ja":{"stringUnit":{"state":"translated","value":"請求期間"}}}}, + "mobile.billing.period.monthly": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Monthly"}},"de":{"stringUnit":{"state":"translated","value":"Monatlich"}},"fr":{"stringUnit":{"state":"translated","value":"Mensuel"}},"ar":{"stringUnit":{"state":"translated","value":"شهري"}},"es":{"stringUnit":{"state":"translated","value":"Mensual"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"每月"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"每月"}},"ko":{"stringUnit":{"state":"translated","value":"월간"}},"ja":{"stringUnit":{"state":"translated","value":"月額"}}}}, + "mobile.billing.period.yearly": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Yearly"}},"de":{"stringUnit":{"state":"translated","value":"Jährlich"}},"fr":{"stringUnit":{"state":"translated","value":"Annuel"}},"ar":{"stringUnit":{"state":"translated","value":"سنوي"}},"es":{"stringUnit":{"state":"translated","value":"Anual"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"每年"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"每年"}},"ko":{"stringUnit":{"state":"translated","value":"연간"}},"ja":{"stringUnit":{"state":"translated","value":"年額"}}}}, + "mobile.billing.monthlyEquivalent": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"%@ / month, billed yearly"}},"de":{"stringUnit":{"state":"translated","value":"%@ / Monat, jährliche Abrechnung"}},"fr":{"stringUnit":{"state":"translated","value":"%@ / mois, facturé annuellement"}},"ar":{"stringUnit":{"state":"translated","value":"%@ / شهريًا، يُدفع سنويًا"}},"es":{"stringUnit":{"state":"translated","value":"%@ / mes, facturado anualmente"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"每月 %@,按年計費"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"每月 %@,按年计费"}},"ko":{"stringUnit":{"state":"translated","value":"월 %@, 연간 결제"}},"ja":{"stringUnit":{"state":"translated","value":"月額 %@(年払い)"}}}}, + "mobile.billing.refund": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Request a Refund"}},"de":{"stringUnit":{"state":"translated","value":"Rückerstattung anfordern"}},"fr":{"stringUnit":{"state":"translated","value":"Demander un remboursement"}},"ar":{"stringUnit":{"state":"translated","value":"طلب استرداد المبلغ"}},"es":{"stringUnit":{"state":"translated","value":"Solicitar un reembolso"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"申請退款"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"申请退款"}},"ko":{"stringUnit":{"state":"translated","value":"환불 요청"}},"ja":{"stringUnit":{"state":"translated","value":"返金をリクエスト"}}}}, + "mobile.billing.disclosure": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Subscriptions renew automatically at the end of each month or year, as shown, at the price shown until cancelled. Payment is charged to your Apple Account when you confirm the purchase. Your account is charged for renewal within 24 hours before the current period ends unless auto-renew is turned off at least 24 hours before then. Manage or cancel your subscription in your Apple Account settings."}},"de":{"stringUnit":{"state":"translated","value":"Abos verlängern sich am Ende jedes Monats oder Jahres, wie angezeigt, automatisch zum angezeigten Preis, bis sie gekündigt werden. Die Zahlung wird bei Bestätigung des Kaufs deinem Apple Account belastet. Die Verlängerung wird innerhalb von 24 Stunden vor Ende des aktuellen Zeitraums belastet, sofern die automatische Verlängerung nicht mindestens 24 Stunden vorher deaktiviert wird. Verwalte oder kündige dein Abo in den Einstellungen deines Apple Accounts."}},"fr":{"stringUnit":{"state":"translated","value":"Les abonnements se renouvellent automatiquement à la fin de chaque mois ou de chaque année, comme indiqué, au prix indiqué jusqu’à leur résiliation. Le paiement est débité de votre compte Apple lorsque vous confirmez l’achat. Le renouvellement est facturé dans les 24 heures précédant la fin de la période en cours, sauf si le renouvellement automatique est désactivé au moins 24 heures avant. Gérez ou résiliez votre abonnement dans les réglages de votre compte Apple."}},"ar":{"stringUnit":{"state":"translated","value":"تتجدد الاشتراكات تلقائيًا في نهاية كل شهر أو سنة، كما هو معروض، بالسعر المعروض حتى يتم إلغاؤها. يتم تحصيل الدفعة من حساب Apple الخاص بك عند تأكيد الشراء. يتم تحصيل رسوم التجديد خلال 24 ساعة قبل نهاية الفترة الحالية ما لم يتم إيقاف التجديد التلقائي قبل ذلك بـ 24 ساعة على الأقل. يمكنك إدارة اشتراكك أو إلغاؤه من إعدادات حساب Apple."}},"es":{"stringUnit":{"state":"translated","value":"Las suscripciones se renuevan automáticamente al final de cada mes o año, según se indica, al precio indicado hasta que se cancelen. El pago se carga en tu cuenta de Apple al confirmar la compra. La renovación se cobra en las 24 horas anteriores al final del periodo actual, salvo que desactives la renovación automática al menos 24 horas antes. Gestiona o cancela tu suscripción en los ajustes de tu cuenta de Apple."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"訂閱會依顯示的週期在每月或每年結束時,以顯示的價格自動續訂,直到取消為止。確認購買時,款項將從你的 Apple 帳號扣款。除非在目前期間結束前至少 24 小時關閉自動續訂,否則將在目前期間結束前 24 小時內收取續訂費用。你可以在 Apple 帳號設定中管理或取消訂閱。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"订阅会按显示的周期在每月或每年结束时,以显示的价格自动续订,直至取消。确认购买时,款项将从你的 Apple 账户扣除。除非在当前周期结束前至少 24 小时关闭自动续订,否则将在当前周期结束前 24 小时内收取续订费用。你可以在 Apple 账户设置中管理或取消订阅。"}},"ko":{"stringUnit":{"state":"translated","value":"구독은 취소할 때까지 표시된 대로 매월 또는 매년 말에 표시된 가격으로 자동 갱신됩니다. 구매를 확인하면 Apple 계정으로 결제됩니다. 현재 기간이 끝나기 최소 24시간 전에 자동 갱신을 끄지 않으면 현재 기간이 끝나기 전 24시간 이내에 갱신 요금이 청구됩니다. Apple 계정 설정에서 구독을 관리하거나 취소하세요."}},"ja":{"stringUnit":{"state":"translated","value":"サブスクリプションは、解約されるまで、表示どおり毎月末または毎年末に表示価格で自動更新されます。購入を確定すると Apple アカウントに請求されます。現在の期間が終了する 24 時間前までに自動更新をオフにしない限り、現在の期間の終了前 24 時間以内に更新料金が請求されます。サブスクリプションの管理や解約は Apple アカウントの設定で行えます。"}}}}, "mobile.billing.done": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Done"}},"de":{"stringUnit":{"state":"translated","value":"Fertig"}},"fr":{"stringUnit":{"state":"translated","value":"OK"}},"ar":{"stringUnit":{"state":"translated","value":"تم"}},"es":{"stringUnit":{"state":"translated","value":"OK"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"完成"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"完成"}},"ko":{"stringUnit":{"state":"translated","value":"완료"}},"ja":{"stringUnit":{"state":"translated","value":"完了"}}}}, "mobile.billing.error.accountMismatch": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This subscription belongs to another cmux account. Sign in with that account to use it."}},"de":{"stringUnit":{"state":"translated","value":"Dieses Abo gehört zu einem anderen cmux-Konto. Melde dich mit diesem Konto an, um es zu nutzen."}},"fr":{"stringUnit":{"state":"translated","value":"Cet abonnement appartient à un autre compte cmux. Connectez-vous à ce compte pour l’utiliser."}},"ar":{"stringUnit":{"state":"translated","value":"ينتمي هذا الاشتراك إلى حساب cmux آخر. سجّل الدخول باستخدام ذلك الحساب لاستخدامه."}},"es":{"stringUnit":{"state":"translated","value":"Esta suscripción pertenece a otra cuenta de cmux. Inicia sesión con esa cuenta para usarla."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"此訂閱屬於另一個 cmux 帳號。請使用該帳號登入以使用此訂閱。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"此订阅属于另一个 cmux 账户。请使用该账户登录以使用此订阅。"}},"ko":{"stringUnit":{"state":"translated","value":"이 구독은 다른 cmux 계정에 속해 있습니다. 해당 계정으로 로그인하여 사용하세요."}},"ja":{"stringUnit":{"state":"translated","value":"このサブスクリプションは別の cmux アカウントに属しています。利用するにはそのアカウントでサインインしてください。"}}}}, "mobile.billing.error.network": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn't reach the App Store or cmux. Check your connection and try again."}},"de":{"stringUnit":{"state":"translated","value":"App Store oder cmux ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut."}},"fr":{"stringUnit":{"state":"translated","value":"Impossible de joindre l’App Store ou cmux. Vérifiez votre connexion et réessayez."}},"ar":{"stringUnit":{"state":"translated","value":"تعذر الوصول إلى App Store أو cmux. تحقق من اتصالك وحاول مرة أخرى."}},"es":{"stringUnit":{"state":"translated","value":"No se pudo conectar con el App Store o cmux. Comprueba tu conexión e inténtalo de nuevo."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法連線至 App Store 或 cmux。請檢查連線後再試一次。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法连接 App Store 或 cmux。请检查网络连接后重试。"}},"ko":{"stringUnit":{"state":"translated","value":"App Store 또는 cmux에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요."}},"ja":{"stringUnit":{"state":"translated","value":"App Store または cmux に接続できませんでした。接続を確認して、もう一度お試しください。"}}}}, @@ -104053,7 +104058,7 @@ "mobile.billing.ineligible.other": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Plans can't be bought in the app for this account."}},"de":{"stringUnit":{"state":"translated","value":"Für dieses Konto können keine Tarife in der App gekauft werden."}},"fr":{"stringUnit":{"state":"translated","value":"Les forfaits ne peuvent pas être achetés dans l’app pour ce compte."}},"ar":{"stringUnit":{"state":"translated","value":"لا يمكن شراء الخطط داخل التطبيق لهذا الحساب."}},"es":{"stringUnit":{"state":"translated","value":"No se pueden comprar planes en la app para esta cuenta."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"此帳號無法在 App 內購買方案。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"此账户无法在 App 内购买方案。"}},"ko":{"stringUnit":{"state":"translated","value":"이 계정은 앱에서 플랜을 구입할 수 없습니다."}},"ja":{"stringUnit":{"state":"translated","value":"このアカウントではアプリ内でプランを購入できません。"}}}}, "mobile.billing.ineligible.team": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Your team admin manages billing for this account."}},"de":{"stringUnit":{"state":"translated","value":"Die Abrechnung für dieses Konto verwaltet dein Team-Admin."}},"fr":{"stringUnit":{"state":"translated","value":"L’administrateur de votre équipe gère la facturation de ce compte."}},"ar":{"stringUnit":{"state":"translated","value":"يدير مسؤول فريقك الفوترة لهذا الحساب."}},"es":{"stringUnit":{"state":"translated","value":"El administrador de tu equipo gestiona la facturación de esta cuenta."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"此帳號的帳單由你的團隊管理員管理。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"此账户的账单由你的团队管理员管理。"}},"ko":{"stringUnit":{"state":"translated","value":"이 계정의 결제는 팀 관리자가 관리합니다."}},"ja":{"stringUnit":{"state":"translated","value":"このアカウントの請求はチーム管理者が管理しています。"}}}}, "mobile.billing.ineligible.unavailable": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Plans can't be bought in this version of the app."}},"de":{"stringUnit":{"state":"translated","value":"In dieser Version der App können keine Tarife gekauft werden."}},"fr":{"stringUnit":{"state":"translated","value":"Les forfaits ne peuvent pas être achetés dans cette version de l’app."}},"ar":{"stringUnit":{"state":"translated","value":"لا يمكن شراء الخطط في هذا الإصدار من التطبيق."}},"es":{"stringUnit":{"state":"translated","value":"No se pueden comprar planes en esta versión de la app."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"此版本的 App 無法購買方案。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"此版本的 App 无法购买方案。"}},"ko":{"stringUnit":{"state":"translated","value":"이 버전의 앱에서는 플랜을 구입할 수 없습니다."}},"ja":{"stringUnit":{"state":"translated","value":"このバージョンのアプリではプランを購入できません。"}}}}, - "mobile.billing.ineligible.web": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"You subscribe on the web. Manage your plan at cmux.com."}},"de":{"stringUnit":{"state":"translated","value":"Du hast dein Abo im Web abgeschlossen. Verwalte deinen Tarif auf cmux.com."}},"fr":{"stringUnit":{"state":"translated","value":"Vous êtes abonné sur le Web. Gérez votre forfait sur cmux.com."}},"ar":{"stringUnit":{"state":"translated","value":"أنت مشترك عبر الويب. يمكنك إدارة خطتك على cmux.com."}},"es":{"stringUnit":{"state":"translated","value":"Tu suscripción es en la web. Gestiona tu plan en cmux.com."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你是在網頁上訂閱的。請前往 cmux.com 管理你的方案。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你是在网页上订阅的。请前往 cmux.com 管理你的方案。"}},"ko":{"stringUnit":{"state":"translated","value":"웹에서 구독 중입니다. cmux.com에서 플랜을 관리하세요."}},"ja":{"stringUnit":{"state":"translated","value":"Web でサブスクリプションに登録しています。プランは cmux.com で管理してください。"}}}}, + "mobile.billing.ineligible.web": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"You subscribe on the web."}},"de":{"stringUnit":{"state":"translated","value":"Du hast dein Abo im Web abgeschlossen."}},"fr":{"stringUnit":{"state":"translated","value":"Vous êtes abonné sur le Web."}},"ar":{"stringUnit":{"state":"translated","value":"أنت مشترك عبر الويب."}},"es":{"stringUnit":{"state":"translated","value":"Tu suscripción es en la web."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你是在網頁上訂閱的。"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你是在网页上订阅的。"}},"ko":{"stringUnit":{"state":"translated","value":"웹에서 구독 중입니다."}},"ja":{"stringUnit":{"state":"translated","value":"Web でサブスクリプションに登録しています。"}}}}, "mobile.billing.loading": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Loading plans"}},"de":{"stringUnit":{"state":"translated","value":"Tarife werden geladen"}},"fr":{"stringUnit":{"state":"translated","value":"Chargement des forfaits"}},"ar":{"stringUnit":{"state":"translated","value":"جارٍ تحميل الخطط"}},"es":{"stringUnit":{"state":"translated","value":"Cargando planes"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"正在載入方案"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"正在加载方案"}},"ko":{"stringUnit":{"state":"translated","value":"플랜 불러오는 중"}},"ja":{"stringUnit":{"state":"translated","value":"プランを読み込み中"}}}}, "mobile.billing.manage": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Manage Subscription"}},"de":{"stringUnit":{"state":"translated","value":"Abo verwalten"}},"fr":{"stringUnit":{"state":"translated","value":"Gérer l’abonnement"}},"ar":{"stringUnit":{"state":"translated","value":"إدارة الاشتراك"}},"es":{"stringUnit":{"state":"translated","value":"Gestionar suscripción"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"管理訂閱"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"管理订阅"}},"ko":{"stringUnit":{"state":"translated","value":"구독 관리"}},"ja":{"stringUnit":{"state":"translated","value":"サブスクリプションを管理"}}}}, "mobile.billing.offer.current": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Current Plan"}},"de":{"stringUnit":{"state":"translated","value":"Aktueller Tarif"}},"fr":{"stringUnit":{"state":"translated","value":"Forfait actuel"}},"ar":{"stringUnit":{"state":"translated","value":"الخطة الحالية"}},"es":{"stringUnit":{"state":"translated","value":"Plan actual"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"目前方案"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"当前方案"}},"ko":{"stringUnit":{"state":"translated","value":"현재 플랜"}},"ja":{"stringUnit":{"state":"translated","value":"現在のプラン"}}}}, diff --git a/web/services/analytics/iosEventPolicy.ts b/web/services/analytics/iosEventPolicy.ts index f7960c454c96..ad59ee265e1b 100644 --- a/web/services/analytics/iosEventPolicy.ts +++ b/web/services/analytics/iosEventPolicy.ts @@ -51,6 +51,8 @@ const ALLOWED_EVENTS: ReadonlySet = new Set([ "ios_products_loaded", "ios_products_failed", "ios_purchase_succeeded", + "ios_refund_request_opened", + "ios_refund_request_result", // App lifecycle + session "ios_app_first_launch", "ios_app_launched", From 13b05aa3be47b4aa0eb1eb4e6691a24fe3ff9d9d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 06:57:42 -0700 Subject: [PATCH 056/183] refactor: rename acpmux start gate module The base adds hub/start_mode.rs for the default start mode; the gate on modes that do not ask moves to hub/start_gate.rs. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/hub/mod.rs | 2 +- cmux-tui/crates/acpmux/src/hub/person.rs | 2 +- cmux-tui/crates/acpmux/src/hub/{start_mode.rs => start_gate.rs} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename cmux-tui/crates/acpmux/src/hub/{start_mode.rs => start_gate.rs} (100%) diff --git a/cmux-tui/crates/acpmux/src/hub/mod.rs b/cmux-tui/crates/acpmux/src/hub/mod.rs index c77226eae419..cfb7d25830cf 100644 --- a/cmux-tui/crates/acpmux/src/hub/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/mod.rs @@ -60,7 +60,7 @@ pub mod person; mod questions; mod remote_floor; mod remote_sandbox; -mod start_mode; +mod start_gate; pub use permission_groups::PERMISSION_GROUP_OPERATIONS; pub mod rules; mod transfer; diff --git a/cmux-tui/crates/acpmux/src/hub/person.rs b/cmux-tui/crates/acpmux/src/hub/person.rs index c8cdaef7ad52..e19fcbb0aca2 100644 --- a/cmux-tui/crates/acpmux/src/hub/person.rs +++ b/cmux-tui/crates/acpmux/src/hub/person.rs @@ -171,7 +171,7 @@ use super::*; -pub use super::start_mode::{ +pub use super::start_gate::{ CALLER_FIELD, CALLER_TOKEN_ENV, START_MODE_FIELD, StartMode, caller_proves, caller_token, host_label, mode_person_required, take_caller, }; diff --git a/cmux-tui/crates/acpmux/src/hub/start_mode.rs b/cmux-tui/crates/acpmux/src/hub/start_gate.rs similarity index 100% rename from cmux-tui/crates/acpmux/src/hub/start_mode.rs rename to cmux-tui/crates/acpmux/src/hub/start_gate.rs From 4814e6aaf2ccda119e7a2982de2549701d707576 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 06:59:22 -0700 Subject: [PATCH 057/183] test: the default full access never widens a caller session that asks A session in a mode that asks runs `acpmux new`: the new chat gets the base's default start mode (full access) today, wider than its caller. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/tests/fake_agent.py | 3 ++ .../crates/acpmux/tests/person_auto_start.rs | 32 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/tests/fake_agent.py b/cmux-tui/crates/acpmux/tests/fake_agent.py index 48123a249dd1..6f8c18c62d4f 100644 --- a/cmux-tui/crates/acpmux/tests/fake_agent.py +++ b/cmux-tui/crates/acpmux/tests/fake_agent.py @@ -693,6 +693,9 @@ def main(): # FAKE_NO_MODES=1: an adapter that reports no session modes. if os.environ.get("FAKE_NO_MODES") == "1": del result["modes"] + # FAKE_FULL_ACCESS=1: a harness that also offers a full-access mode. + if os.environ.get("FAKE_FULL_ACCESS") == "1" and "modes" in result: + result["modes"]["availableModes"].append({"id": "full-access", "name": "Full access"}) # FAKE_ASK_MODE=1: the sample harness's one mode, "ask". if os.environ.get("FAKE_ASK_MODE") == "1": result["modes"] = {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}]} diff --git a/cmux-tui/crates/acpmux/tests/person_auto_start.rs b/cmux-tui/crates/acpmux/tests/person_auto_start.rs index a1126d226b25..9ca77fa190c0 100644 --- a/cmux-tui/crates/acpmux/tests/person_auto_start.rs +++ b/cmux-tui/crates/acpmux/tests/person_auto_start.rs @@ -161,6 +161,8 @@ struct Daemon { impl Daemon { /// The real daemon with the app's key on `--person-key-fd 3`. The fake /// harness asks in `normal` and `strict`; any other mode does not ask. + /// `wide` also offers `full-access`, the start mode of a new chat + /// (`hub/start_mode.rs`, `defaultPermissionMode` set for a hermetic run). fn start(tag: &str) -> Self { let dir = std::env::temp_dir().join(format!("aas-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); @@ -172,10 +174,12 @@ impl Daemon { "harnesses": { "fake": {"argv": ["python3", FAKE]}, "mirror": {"argv": ["python3", FAKE], "family": "mirror"}, + "wide": {"argv": ["python3", FAKE], "family": "wide", "env": {"FAKE_FULL_ACCESS": "1"}}, }, "defaultHarness": "fake", "permissionPolicy": "ask", - "webAskingModes": {"fake": ["normal", "strict"], "mirror": ["normal", "strict"]}, + "defaultPermissionMode": "fullAccess", + "webAskingModes": {"fake": ["normal", "strict"], "mirror": ["normal", "strict"], "wide": ["normal", "strict"]}, }) .to_string(), ) @@ -511,3 +515,29 @@ async fn auto_start_a_refused_start_mode_names_the_host_and_the_preset_to_approv assert_eq!(reason(&r), REASON, "{r}"); assert!(message(&r).contains(&format!("cmux NEXT on {host}")), "{r}"); } + +/// Session `id`'s mode now. +async fn mode(c: &mut Rpc, id: Value) -> String { + let info = c.ok("_acpmux/info", json!({"sessionId": id})).await; + info["modes"]["currentModeId"].as_str().unwrap_or_default().to_owned() +} + +/// The start mode of a new chat (`hub/start_mode.rs`: full access by +/// default) never widens a caller session: a session in a mode that asks +/// starts its chats in the harness's own mode; one in full access starts +/// them in full access, as the person's chats start. +#[tokio::test] +async fn auto_start_the_default_full_access_never_widens_a_caller_session_that_asks() { + let d = Daemon::start("default"); + let mut app = Rpc::person(&d.socket, KEY).await; + let own = app.ok("session/new", new_params(&d, json!({"harness": "wide"}))).await; + assert_eq!(mode(&mut app, own["sessionId"].clone()).await, "full-access", "{own}"); + let (asking, token) = parent_in(&mut app, &d, "wide", "strict").await; + let mut agent = Rpc::in_session(&d.socket, &asking, &token).await; + let child = agent.ok("session/new", new_params(&d, json!({"harness": "wide"}))).await; + assert_eq!(mode(&mut agent, child["sessionId"].clone()).await, "normal", "{child}"); + let (wide, token) = parent_in(&mut app, &d, "wide", "full-access").await; + let mut agent = Rpc::in_session(&d.socket, &wide, &token).await; + let child = agent.ok("session/new", new_params(&d, json!({"harness": "wide"}))).await; + assert_eq!(mode(&mut agent, child["sessionId"].clone()).await, "full-access", "{child}"); +} From f04c4190ec45bca2653014668f12cc2bf0544056 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 07:00:40 -0700 Subject: [PATCH 058/183] fix: keep the harness mode for chats a caller that asks starts The default start mode (full access unless the person picked otherwise) applied to every unix socket session/new, so an agent in a mode that asks could start a chat wider than itself. A proven caller session in a mode that asks now starts chats in the harness's own mode, and a request that names a mode skips the default (the gate checks it). The threat model names the remaining open question: a client that is no proven session still gets the default. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/hub/person.rs | 17 ++++++++++++ cmux-tui/crates/acpmux/src/hub/start_gate.rs | 26 +++++++++++++++++++ cmux-tui/crates/acpmux/src/server/requests.rs | 4 ++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/src/hub/person.rs b/cmux-tui/crates/acpmux/src/hub/person.rs index e19fcbb0aca2..83a5c117affa 100644 --- a/cmux-tui/crates/acpmux/src/hub/person.rs +++ b/cmux-tui/crates/acpmux/src/hub/person.rs @@ -123,6 +123,17 @@ //! `decisionNonce` and `personInstance`; an answer still goes to the peer, //! which counts an allow only with its own person's proof (above). //! +//! The default start mode (`hub/start_mode.rs`, cx-wx93i) is not a request: +//! with no `mode` asked, a new chat starts in the person's latest pick, else +//! `defaultPermissionMode` / `agents.defaultPermissionMode`, else the +//! harness's full-access mode, for every non-Web origin (also the unix +//! socket CLI). It never widens a proven caller session: one in a mode +//! that asks starts its chats in the harness's own mode +//! (`Hub::keeps_harness_mode`, as a handoff target of a source that asks +//! does). A request that names a mode skips the default; (a) and (b) gate +//! it as above. Presets, folder profiles, adopted chats and profiles that +//! pin a mode in their argv keep the harness's own mode. +//! //! Also without the person: no rules change under a session policy that //! does not ask (a rule is then what keeps a tool asking), no removal of a //! stored family-default or preset policy, no write of the `handoffKey` @@ -164,6 +175,12 @@ //! (an agent host) inherits nothing until the next spawn. //! - `session/new` with a `policy` other than ask or deny-all is open to the //! unix socket (it predates this check); a mode is not. +//! - Open question (cx-wx93i): while the person's start mode is full +//! access (the default), a unix socket client that is no proven session, +//! such as `acpmux new` from a script, or an agent of a session that asks +//! that leaves out its caller token, gets a full-access chat without the +//! person. Limiting the default to the person (or LocalApp) would close +//! it; the person's pick of `standard` or `ask` closes it today. //! - DEV only: a same-uid process that starts the unsigned daemon before the //! app does gives it its own spawn key; the app then hands that daemon off //! (when its agents run under agent hosts), but the process held the diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate.rs b/cmux-tui/crates/acpmux/src/hub/start_gate.rs index 56e95d01fb26..8ea9aba99943 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_gate.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_gate.rs @@ -228,6 +228,32 @@ impl Hub { } } + /// Whether a `session/new` keeps the harness's own start mode rather + /// than the default one (`hub/start_mode.rs`): it names its own + /// (`StartMode`, set after the start), or a caller session that is not + /// the person runs in a mode that asks, so a chat it starts is never + /// wider than it ((a)). + pub(crate) async fn keeps_harness_mode( + &self, + person: bool, + caller: Option<&str>, + params: &Value, + ) -> bool { + if StartMode::of(params, &*self.config.read().await).is_some() { + return true; + } + let caller = caller.filter(|_| !person).and_then(|id| self.resolve(id).ok()); + caller.is_some_and(|s| { + let m = s.meta(); + let family = crate::web_modes::family_of(&m); + self.web_modes().config_value_asks( + &family, + None, + crate::web_modes::mode_of(&m).as_deref(), + ) + }) + } + /// A `_acpmux/presets` write without the person (module docs, (b)): no /// approval, and no `mode` that does not ask for the preset's family. pub(super) async fn preset_mode_check(&self, params: &Value) -> Result<(), RpcError> { diff --git a/cmux-tui/crates/acpmux/src/server/requests.rs b/cmux-tui/crates/acpmux/src/server/requests.rs index 5a65063cd81c..f13552a1832b 100644 --- a/cmux-tui/crates/acpmux/src/server/requests.rs +++ b/cmux-tui/crates/acpmux/src/server/requests.rs @@ -291,7 +291,9 @@ async fn dispatch_request( env: crate::session_env::parse(meta)?, // LocalApp = same-user secret, equal to the unix socket for STARTING presets; writes stay unix-socket only. remote: conn.origin.web_class(), - keep_harness_mode: false, + keep_harness_mode: hub + .keeps_harness_mode(conn.is_person(), conn.caller(), ¶ms) + .await, }; let s = hub.new_session(req).await?; if conn.origin.web_class() { From d3ba5e73082dde7b7cbac54252ee2be7c6570de2 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 07:43:56 -0700 Subject: [PATCH 059/183] test: start modes never move an adopted session and peer prompts are checked Boundary tests for the security review of #20653: an adopt that finds a running session never sets the caller's mode on it, an approved preset edited after the start leaves its no-ask mode on respawn, request overrides on an approved preset need the person, and a peer's pending prompt for a local session, in this daemon's run or for a session the peer never listed is dropped. Co-Authored-By: Claude Opus 5.5 --- .../crates/acpmux/tests/peer_integration.rs | 134 ++++++++++++++++++ .../crates/acpmux/tests/person_auto_start.rs | 86 +++++++++++ 2 files changed, 220 insertions(+) diff --git a/cmux-tui/crates/acpmux/tests/peer_integration.rs b/cmux-tui/crates/acpmux/tests/peer_integration.rs index 79aea3f9b6ec..afb430da36de 100644 --- a/cmux-tui/crates/acpmux/tests/peer_integration.rs +++ b/cmux-tui/crates/acpmux/tests/peer_integration.rs @@ -949,3 +949,137 @@ async fn opening_a_long_peer_chat_stays_within_its_payload_budget_on_the_link() assert_eq!(older["hasMore"], true); let _ = std::fs::remove_dir_all(&dir); } + +/// A peer daemon that says what it likes (`hub/person.rs` (c)): a WebSocket +/// server that answers `initialize` with `instance` as its daemon run, +/// lists `sessions` on `_acpmux/watch`, then sends each of `pending` as +/// `_acpmux/permission_pending`. Returns its port. +async fn lying_peer(instance: String, sessions: Vec, pending: Vec) -> u16 { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message as Ws; + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((tcp, _)) = l.accept().await { + let Ok(mut ws) = tokio_tungstenite::accept_async(tcp).await else { continue }; + let (instance, sessions, pending) = + (instance.clone(), sessions.clone(), pending.clone()); + tokio::spawn(async move { + while let Some(Ok(Ws::Text(text))) = ws.next().await { + let Ok(Message::Request { id, method: m, .. }) = Message::parse(&text) else { + continue; + }; + let result = match m.as_str() { + method::INITIALIZE => json!({"protocolVersion": 1, "_meta": {"acpmux": { + "version": "0", "build": "lying", "origin": "peer", + "personInstance": instance}}}), + method::MUX_WATCH => json!({"sessions": sessions}), + _ => json!({}), + }; + let reply = Message::ok(id, result).to_line(); + if ws.send(Ws::Text(reply.trim_end().to_owned().into())).await.is_err() { + return; + } + if m == method::MUX_WATCH { + // After this daemon took the session list in. + tokio::time::sleep(Duration::from_millis(500)).await; + for p in &pending { + let note = + Message::notification(method::MUX_PERMISSION_PENDING, p.clone()); + let line = note.to_line().trim_end().to_owned(); + let _ = ws.send(Ws::Text(line.into())).await; + } + } + } + }); + } + }); + port +} + +/// A prompt as a peer's `_acpmux/permission_pending` carries it. +fn peer_prompt(session: &str, permission: &str, instance: &str) -> Value { + json!({"sessionId": session, "permissionId": permission, "decisionNonce": "n-1", + "personInstance": instance, "seq": 1, + "request": {"toolCall": {"title": format!("{permission} text")}, "options": [ + {"optionId": "yes", "kind": "allow_once"}, {"optionId": "no", "kind": "reject_once"}]}}) +} + +/// A's watcher with a session of its own and the lying peer `l` that asks +/// `forged` first, then one prompt that it may relay (`legit`, for its own +/// session `far-1` in its own run `l-run`). Returns every pending prompt the +/// watcher got up to the legit one. +async fn pendings_through_a_lying_peer( + forged: impl FnOnce(&str, &str) -> Vec, +) -> Vec { + let a = hub(config(PermissionPolicy::Ask)).await; + let mut ca = plain(a.clone()).await; + let own = ca + .call(method::SESSION_NEW, json!({"cwd": std::env::temp_dir(), "mcpServers": []})) + .await + .unwrap(); + let own = own["sessionId"].as_str().unwrap().to_owned(); + let mine = a.person.instance().to_owned(); + let mut pending = forged(&own, &mine); + pending.push(peer_prompt("far-1", "legit", "l-run")); + // It lists A's own session as its own too: a claim, never a proof. + let sessions = vec![ + json!({"sessionId": "far-1", "name": "far"}), + json!({"sessionId": own, "name": "mine"}), + ]; + let port = lying_peer("l-run".to_owned(), sessions, pending).await; + let mut watcher = plain(a.clone()).await; + watcher.call(method::MUX_WATCH, json!({"enabled": true})).await.unwrap(); + a.add_peer("l", &format!("ws://127.0.0.1:{port}"), Some("tok".into()), None, true) + .await + .unwrap(); + let mut got = Vec::new(); + loop { + let p = watcher.wait(method::MUX_PERMISSION_PENDING, |_| true).await; + let legit = p["permissionId"] == "legit"; + got.push(p); + if legit { + return got; + } + } +} + +/// A peer's pending prompt for a session of this daemon is never relayed: +/// with this daemon's own nonce, the person's allow of its text would be a +/// proof for the real local prompt. +#[tokio::test] +async fn peer_pending_for_a_local_session_is_dropped() { + let got = + pendings_through_a_lying_peer(|own, _| vec![peer_prompt(own, "forged", "l-run")]).await; + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0]["peer"], "l", "{got:?}"); +} + +/// A peer's pending prompt in this daemon's own run is never relayed: the +/// app on this host holds this run's key. +#[tokio::test] +async fn peer_pending_with_this_daemons_instance_is_dropped() { + let got = + pendings_through_a_lying_peer(|_, mine| vec![peer_prompt("far-1", "forged", mine)]).await; + assert_eq!(got.len(), 1, "{got:?}"); +} + +/// A peer's pending prompt for a session it never listed is never relayed. +#[tokio::test] +async fn peer_pending_for_an_unknown_session_is_dropped() { + let got = + pendings_through_a_lying_peer(|_, _| vec![peer_prompt("nobody", "forged", "l-run")]).await; + assert_eq!(got.len(), 1, "{got:?}"); +} + +/// A peer's prompt for its own session in its own run reaches the watcher, +/// with which peer asks. +#[tokio::test] +async fn peer_pending_legit_is_relayed() { + let got = pendings_through_a_lying_peer(|_, _| vec![]).await; + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0]["sessionId"], "far-1", "{got:?}"); + assert_eq!(got[0]["decisionNonce"], "n-1", "{got:?}"); + assert_eq!(got[0]["peer"], "l", "{got:?}"); + assert_eq!(got[0]["via"], "peer", "{got:?}"); +} diff --git a/cmux-tui/crates/acpmux/tests/person_auto_start.rs b/cmux-tui/crates/acpmux/tests/person_auto_start.rs index 9ca77fa190c0..2464716ceaef 100644 --- a/cmux-tui/crates/acpmux/tests/person_auto_start.rs +++ b/cmux-tui/crates/acpmux/tests/person_auto_start.rs @@ -174,6 +174,7 @@ impl Daemon { "harnesses": { "fake": {"argv": ["python3", FAKE]}, "mirror": {"argv": ["python3", FAKE], "family": "mirror"}, + "other": {"argv": ["python3", FAKE], "family": "fake", "env": {"FAKE_SESSION_PREFIX": "other-"}}, "wide": {"argv": ["python3", FAKE], "family": "wide", "env": {"FAKE_FULL_ACCESS": "1"}}, }, "defaultHarness": "fake", @@ -541,3 +542,88 @@ async fn auto_start_the_default_full_access_never_widens_a_caller_session_that_a let child = agent.ok("session/new", new_params(&d, json!({"harness": "wide"}))).await; assert_eq!(mode(&mut agent, child["sessionId"].clone()).await, "full-access", "{child}"); } + +/// An adopt that finds a session already running never moves it to the +/// caller's mode: a caller in a no-ask mode that names another running +/// session's conversation with `ifLive: "open"` is refused, and that +/// session stays in the mode that asks (only the person widens a running +/// session, `session/set_mode`). +#[tokio::test] +async fn auto_start_an_adopt_never_moves_a_running_session_to_the_callers_mode() { + let d = Daemon::start("adopt"); + let mut app = Rpc::person(&d.socket, KEY).await; + let (parent, token) = parent_in(&mut app, &d, "fake", "bypass").await; + let (running, _) = parent_in(&mut app, &d, "other", "normal").await; + let info = app.ok("_acpmux/info", json!({"sessionId": running})).await; + let agent_session = info["agentSessionId"].as_str().expect("its conversation id").to_owned(); + let mut agent = Rpc::in_session(&d.socket, &parent, &token).await; + let r = agent + .call( + "session/new", + new_params( + &d, + json!({"harness": "fake", "mode": "bypass", + "adopt": {"agentSessionId": agent_session, "ifLive": "open"}}), + ), + ) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + assert_eq!(mode(&mut app, json!(running)).await, "normal"); +} + +/// A preset's approval holds for the contents the session runs: a session +/// started from an approved preset whose preset changed afterwards comes back +/// from a respawn in the harness's own mode, not the approved one. +#[tokio::test] +async fn auto_start_an_approved_preset_edited_then_respawned_leaves_its_no_ask_mode() { + let d = Daemon::start("respawn"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}, "approveMode": true}), + ) + .await; + let s = cli.ok("session/new", new_params(&d, json!({"preset": "lane"}))).await; + let id = s["sessionId"].as_str().unwrap().to_owned(); + assert_eq!(mode(&mut cli, json!(id)).await, "bypass"); + // Any client may change the preset; the approval no longer covers it. + cli.ok("_acpmux/presets", json!({"name": "lane", "set": {"model": "m2"}})).await; + cli.ok("_acpmux/attach", json!({"sessionId": id})).await; + let prompt = |text: &str| json!({"sessionId": id, "prompt": [{"type": "text", "text": text}]}); + cli.ok("session/prompt", prompt("answer-then-exit")).await; + tokio::time::sleep(Duration::from_millis(1500)).await; + // The next prompt respawns the harness from the changed preset. + cli.ok("session/prompt", prompt("hello")).await; + assert_eq!(mode(&mut cli, json!(id)).await, "normal"); +} + +/// An approved preset starts exactly what the person approved: a request +/// that adds its own model, effort, policy or env to it needs the person. +#[tokio::test] +async fn auto_start_an_approved_preset_with_request_overrides_needs_the_person() { + let d = Daemon::start("override"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}, "approveMode": true}), + ) + .await; + for extra in [ + json!({"model": "m2"}), + json!({"effort": "high"}), + json!({"policy": "approve-all"}), + json!({"env": {"FOO": "1"}}), + ] { + let mut meta = json!({"preset": "lane"}); + for (k, v) in extra.as_object().unwrap() { + meta[k] = v.clone(); + } + let r = cli.call("session/new", new_params(&d, meta)).await; + assert_eq!(reason(&r), REASON, "{extra}: {r}"); + } + // Its own folder is fine. + let s = cli.ok("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(current_mode(&s), "bypass", "{s}"); +} From 834e2b8c1b8a2ff9abb911abbbb0a37bd6d036d9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 08:08:43 -0700 Subject: [PATCH 060/183] fix: start modes never widen an adopted session, peer prompts are checked Security review fixes for #20653: - an adopt never takes a start mode that does not ask without the person (it may find a session already running); - a preset approval counts only when the contents the session resolved and spawned with hash to the approved digest; such a preset never takes a pooled harness, and a respawn from other contents starts in the harness's own mode instead of replaying the approved one; - request model, effort, policy or env on an approved preset needs the person; - a peer's pending prompt is relayed only for a session that peer listed, that is not local, in the run the peer named at its handshake (not ours, not another peer's), and only to unix socket and app watchers; - a Web or peer refusal never names this host; - the CLI sends its caller token only to the socket of the daemon that issued it (ACPMUX_SESSION_TOKEN_SOCKET); - threat model notes: unkeyed modeApproval, digest of the harness string, no content binding of decision proofs. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/agent.rs | 4 +- cmux-tui/crates/acpmux/src/client.rs | 13 +- cmux-tui/crates/acpmux/src/hub/fork.rs | 2 + cmux-tui/crates/acpmux/src/hub/lifecycle.rs | 6 +- cmux-tui/crates/acpmux/src/hub/peers.rs | 35 +++++- cmux-tui/crates/acpmux/src/hub/person.rs | 52 ++++++-- cmux-tui/crates/acpmux/src/hub/pool/mod.rs | 11 ++ cmux-tui/crates/acpmux/src/hub/resolve.rs | 4 + cmux-tui/crates/acpmux/src/hub/session.rs | 5 + cmux-tui/crates/acpmux/src/hub/spawn.rs | 22 +++- cmux-tui/crates/acpmux/src/hub/start_gate.rs | 100 +++++++++++---- .../acpmux/src/hub/start_gate/preset_bind.rs | 117 ++++++++++++++++++ cmux-tui/crates/acpmux/src/peer.rs | 14 +++ cmux-tui/crates/acpmux/src/server/mod.rs | 10 +- cmux-tui/crates/acpmux/src/server/pending.rs | 7 +- cmux-tui/crates/acpmux/src/store.rs | 5 + 16 files changed, 351 insertions(+), 56 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index f9daa9e476a6..6645db282876 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -242,8 +242,8 @@ pub(crate) fn harness_command( .env("ACPMUX_SESSION_ID", id) .env("ACPMUX_SESSION_NAME", sname) .env("ACPMUX_SOCKET", crate::config::socket_path()) - // Its caller token (hub/person.rs (a)); the cmux CLI's caller (beside_caller). - .env(crate::hub::person::CALLER_TOKEN_ENV, crate::hub::person::caller_token(id)) + // Its caller token and the socket it is for (hub/person.rs (a)); the cmux CLI's caller. + .envs(crate::hub::person::caller_env(id)) .env(crate::agent_tools::AGENT_SESSION_ENV, id); } cmd.args(args) diff --git a/cmux-tui/crates/acpmux/src/client.rs b/cmux-tui/crates/acpmux/src/client.rs index 9d70b707de2e..1ff481110151 100644 --- a/cmux-tui/crates/acpmux/src/client.rs +++ b/cmux-tui/crates/acpmux/src/client.rs @@ -93,11 +93,16 @@ impl Client { }); let mut hello = json!({"protocolVersion": 1, "clientCapabilities": {}, "clientInfo": {"name": "acpmux-cli", "version": crate::hub::VERSION}}); // Inside an acpmux session: say which, with the caller token the - // daemon put in that session's env (`hub/person.rs` (a)). + // daemon put in that session's env (`hub/person.rs` (a)), only to + // the socket of the daemon that issued it (never to another daemon, + // or whatever listens on a path the agent pointed the CLI at). let caller = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); - if let (Some(id), Some(token)) = - (caller("ACPMUX_SESSION_ID"), caller(crate::hub::person::CALLER_TOKEN_ENV)) - { + let issuer = caller(crate::hub::person::CALLER_SOCKET_ENV); + if let (Some(id), Some(token), true) = ( + caller("ACPMUX_SESSION_ID"), + caller(crate::hub::person::CALLER_TOKEN_ENV), + issuer.is_some_and(|socket| Path::new(&socket) == path), + ) { let mut meta = serde_json::Map::new(); meta.insert( crate::hub::person::CALLER_FIELD.to_owned(), diff --git a/cmux-tui/crates/acpmux/src/hub/fork.rs b/cmux-tui/crates/acpmux/src/hub/fork.rs index e684cb26daf3..9dde19c40e2a 100644 --- a/cmux-tui/crates/acpmux/src/hub/fork.rs +++ b/cmux-tui/crates/acpmux/src/hub/fork.rs @@ -144,6 +144,8 @@ impl Hub { adopted_cwd: None, harness_roots: vec![], composer_draft: None, + // It runs the parent's mode: a spawn from other preset contents leaves it too. + preset_mode_approval: parent_meta.preset_mode_approval.clone(), }; let new = self.make_session(meta); if is_claude && cut.is_none() { diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index 16ae00658a6d..d43613fc00e8 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -28,7 +28,7 @@ impl Hub { let harness = harness.or_else(|| adopt.as_ref().and_then(|a| a.harness.clone())); // Resolution is a lookup, never a guess: preset → head (family or // profile) → defaults chain → explicit values on top. - let Resolved { agent, profile, defaults, head, preset_name, folder_root } = { + let Resolved { agent, profile, defaults, head, preset_name, folder_root, preset_digest } = { let cfg = self.config.read().await; self.resolve_new(&cfg, harness, &preset, model.as_deref(), remote, cwd.as_deref())? }; @@ -84,8 +84,7 @@ impl Hub { Adoption::Existing(existing) => return Ok(existing), Adoption::Found { cwd, fork, file, store } => (cwd, fork, file, store), }; - // A fork resumes nothing as itself: its process forks the adopted id - // into a new conversation, so the adopted id is not this session's. + // A fork resumes nothing as itself: it forks the adopted id into a new conversation. let fork_from = adopt.as_ref().filter(|_| fork).map(|a| a.agent_session_id.clone()); // A gone recorded folder may be remapped: git runs to compare repositories. let cwd_map = adopt.as_ref().map(|a| a.cwd_map.clone()).unwrap_or_default(); @@ -188,6 +187,7 @@ impl Hub { }; self.append(&session, "mux", "adopted", adopted); } + self.note_preset_resolved(&session, preset_digest); let spawned = match self.spawn_profile(&session, &profile, &defaults.env).await { Ok(spawn) => self.ensure_child(&session, &spawn).await, Err(e) => Err(e), diff --git a/cmux-tui/crates/acpmux/src/hub/peers.rs b/cmux-tui/crates/acpmux/src/hub/peers.rs index 4af6ecfffab5..bda4736187d4 100644 --- a/cmux-tui/crates/acpmux/src/hub/peers.rs +++ b/cmux-tui/crates/acpmux/src/hub/peers.rs @@ -386,7 +386,14 @@ impl Hub { // clients derive it from the permission_request event): // the peer's decision nonce and run come with it, so the // app that holds the peer's key can answer - // (`hub/person.rs` (c)). + // (`hub/person.rs` (c)). Only the peer's own session + // in its own run, never one of this daemon's. + method::MUX_PERMISSION_PENDING + if !self.peer_pending_counts(&peer, &id, ¶ms) => + { + tracing::warn!(peer = %peer, session = %id, "a peer's pending prompt for a session or run that is not its own: dropped"); + return; + } method::MUX_PERMISSION_PENDING => EventRecord { seq: params.get("seq").and_then(Value::as_u64).unwrap_or(0), at: now_ms(), @@ -407,6 +414,32 @@ impl Hub { } } + /// Whether peer `peer`'s `_acpmux/permission_pending` for session `id` + /// may reach this daemon's watchers (`hub/person.rs` (c)): a session the + /// peer listed as its own and that is not one of this daemon's, in the + /// daemon run the peer named at its handshake, which is not this run and + /// no other peer's. A prompt with this daemon's session or run would let + /// the peer show the person other text for one of this daemon's real + /// prompts, whose allow the app proves with this daemon's key. + fn peer_pending_counts(&self, peer: &str, id: &str, params: &Value) -> bool { + let Some(instance) = params.get("personInstance").and_then(Value::as_str) else { + return false; + }; + let listed = self + .remote_sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(id) + .is_some_and(|r| r.peer == peer); + let local = self.sessions.lock().unwrap_or_else(|e| e.into_inner()).contains_key(id); + let peers: Vec<_> = + self.peers.lock().unwrap_or_else(|e| e.into_inner()).values().cloned().collect(); + let named = |p: &crate::peer::Peer| p.person_instance().as_deref() == Some(instance); + let own_run = peers.iter().any(|p| p.name == peer && named(p)); + let others = peers.iter().any(|p| p.name != peer && named(p)); + listed && !local && own_run && !others && instance != self.person.instance() + } + /// Look a key up among peer sessions: exact id, `peer/name`, or a unique name. pub fn resolve_remote(&self, key: &str) -> Option<(Arc, String, Value)> { let map = self.remote_sessions.lock().unwrap(); diff --git a/cmux-tui/crates/acpmux/src/hub/person.rs b/cmux-tui/crates/acpmux/src/hub/person.rs index 83a5c117affa..04ce4f7a4060 100644 --- a/cmux-tui/crates/acpmux/src/hub/person.rs +++ b/cmux-tui/crates/acpmux/src/hub/person.rs @@ -99,13 +99,30 @@ //! `parentSessionId` or a `callerSession` in any other request counts for //! nothing. The check reads the caller's mode at the time of the request: //! a caller the person moved back to asking inherits nothing. A peer or -//! Web connection never has a caller. +//! Web connection never has a caller. The harness also gets +//! `ACPMUX_SESSION_TOKEN_SOCKET`, this daemon's socket, and the CLI sends +//! the token only to that socket ([`caller_env`], `client.rs`). +//! - No start mode that does not ask applies to an adopt without the +//! person: an adopt may find a session already running +//! (`hub/adoption.rs`, `ifLive: "open"`), and only the person widens a +//! running session (`session/set_mode` above). Such a request is refused +//! before anything starts. //! - (b) A preset the person approved: `_acpmux/presets {name, approveMode: //! true}` from the person records `modeApproval`, the sha256 of the //! preset's contents (everything but its description and the approval //! itself, `Preset::approval_digest`). The approval counts only while the //! preset still hashes to it and only for that preset's own mode, with no -//! other harness and no adopted conversation in the request. Bound to the +//! other harness, no adopted conversation and no model, effort, policy or +//! env of the request's own (a folder is fine). The contents are read +//! three times for one start (the request check, the resolve of model, +//! effort and env, the spawn of args, env and system prompt): each read +//! records the digest of what it used, and the start counts the approval +//! only when the resolve's and the spawn's digests are the approved one +//! (`hub/start_gate/preset_bind.rs`), so an edit that is put back between +//! reads gains nothing. Such a preset never takes a pooled harness. The +//! session records the approved digest; a later spawn from other +//! contents (an edit, then a respawn) starts in the harness's own mode +//! instead of replaying the approved one. Bound to the //! contents, not cleared on a write, so every way the preset can change //! (a `_acpmux/presets` write from any client, a hand edit of config.json //! and a reload, a future write path) drops it without having to know @@ -116,12 +133,24 @@ //! - (d) The refusal ([`mode_person_required`]) is one sentence that names //! where to approve: cmux NEXT on this host ([`host_label`]), or the //! preset to approve there once; `error.data` carries `host`, `mode` and -//! `preset` so an app can show its own (localized) text. +//! `preset` so an app can show its own (localized) text. A Web or peer +//! client never gets this host's name, in the text or in `host`. //! - (c) A pending prompt reaches every app that sees the session: a peer's //! `_acpmux/permission_pending` reaches the watchers of this daemon that //! did not attach the session (`hub/peers.rs`), with the peer's //! `decisionNonce` and `personInstance`; an answer still goes to the peer, -//! which counts an allow only with its own person's proof (above). +//! which counts an allow only with its own person's proof (above). The +//! proof covers the prompt's ids, not the text the app shows, so a peer +//! that re-sends a real prompt with other text could get the person's +//! allow of that text counted for the real one. A peer's prompt is +//! therefore relayed only for a session that peer listed as its own and +//! that is not this daemon's, in the run the peer named at its handshake, +//! which is neither this daemon's run nor one another peer named; and only +//! to unix socket and app watchers, never to a Web client or on to +//! another peer daemon. The app holds the key of the daemons it enrolled, +//! so this daemon's prompts (whose proofs it can make) never arrive with +//! a peer's text. Binding the proof to the prompt's text would need the +//! app to hash what it shows; not done. //! //! The default start mode (`hub/start_mode.rs`, cx-wx93i) is not a request: //! with no `mode` asked, a new chat starts in the person's latest pick, else @@ -157,7 +186,14 @@ //! See docs/security/threat-model.md "Remote approvals (acpmux person key)". //! - Same-uid file writes: an agent that edits config.json, a profile file //! or the store directly. That needs an OS boundary between the agent and -//! the user's files. +//! the user's files. A preset's `modeApproval` is an unkeyed sha256 in +//! config.json, so such a write can approve a preset itself. +//! - A preset approval covers the preset's `harness` string, not the +//! profile it resolves to: `_acpmux/defaults` `prefer` or +//! `_acpmux/harness/add` can point that name at another program, which +//! `_acpmux/harness/add` can run anyway. +//! - The person's own pick of a no-ask mode on a preset session (not through +//! an approval) stays on respawn even when the preset changed meanwhile. //! - `_acpmux/harness/add` (a new harness profile), `_acpmux/defaults` //! `prefer` and a preset's `harness` (which profile, and so which profile //! default policy, a new session gets), a preset's `args`, @@ -189,8 +225,8 @@ use super::*; pub use super::start_gate::{ - CALLER_FIELD, CALLER_TOKEN_ENV, START_MODE_FIELD, StartMode, caller_proves, caller_token, - host_label, mode_person_required, take_caller, + CALLER_FIELD, CALLER_SOCKET_ENV, CALLER_TOKEN_ENV, START_MODE_FIELD, StartMode, caller_env, + caller_proves, caller_token, host_label, mode_person_required, take_caller, }; /// The old place of the key in `initialize` (`_meta.acpmux`): stripped, never read. @@ -759,7 +795,7 @@ impl Hub { if self.web_modes().config_value_asks(&family, id, value) { Ok(()) } else if let (None | Some("mode"), Some(mode)) = (id, value) { - Err(mode_person_required(mode, None, false)) + Err(mode_person_required(mode, None, false, starts_modes)) } else { Err(person_required("a mode that does not ask")) } diff --git a/cmux-tui/crates/acpmux/src/hub/pool/mod.rs b/cmux-tui/crates/acpmux/src/hub/pool/mod.rs index ba0f1f4c2c3e..b7279cd449f2 100644 --- a/cmux-tui/crates/acpmux/src/hub/pool/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/pool/mod.rs @@ -505,6 +505,17 @@ impl Hub { if meta.remote_origin || !self.pool_enabled().await { return None; } + // A preset with a start mode or an approval starts cold: the approval + // holds for the contents this session's own spawn reads, not the ones + // a pooled harness started with (`hub/start_gate/preset_bind.rs`). Only + // the person sets an approval, so no client can clear it for a claim + // and get it back for the start. + let approved = |p: &crate::config::Preset| p.mode.is_some() || p.mode_approval.is_some(); + if let Some(name) = &meta.preset + && self.config.read().await.presets.get(name).is_some_and(approved) + { + return None; + } let spec = self.pool_spec_for(meta, profile, defaults_env).await.ok()?; loop { let took = lock(&self.pool.pool).take(&spec.key); diff --git a/cmux-tui/crates/acpmux/src/hub/resolve.rs b/cmux-tui/crates/acpmux/src/hub/resolve.rs index 0158a482d0a3..6f9367dd273c 100644 --- a/cmux-tui/crates/acpmux/src/hub/resolve.rs +++ b/cmux-tui/crates/acpmux/src/hub/resolve.rs @@ -91,6 +91,7 @@ impl Hub { head, preset_name: preset.clone(), folder_root, + preset_digest: preset_cfg.as_ref().and_then(|p| p.approval_digest()), }) } } @@ -134,6 +135,8 @@ pub(super) struct Resolved { pub(super) preset_name: Option, /// The folder of a folder profile (H4): the session's cwd must be inside it. pub(super) folder_root: Option, + /// The approval digest of the preset contents read here (`hub/start_gate/preset_bind.rs`). + pub(super) preset_digest: Option, } /// The inputs of a new session's meta (`draft_meta`). @@ -194,5 +197,6 @@ pub(super) fn draft_meta(d: Draft<'_>) -> SessionMeta { adopted_cwd: None, harness_roots: vec![], composer_draft: None, + preset_mode_approval: None, } } diff --git a/cmux-tui/crates/acpmux/src/hub/session.rs b/cmux-tui/crates/acpmux/src/hub/session.rs index ac3408486497..d158cb7bee26 100644 --- a/cmux-tui/crates/acpmux/src/hub/session.rs +++ b/cmux-tui/crates/acpmux/src/hub/session.rs @@ -155,4 +155,9 @@ pub(crate) fn prompt_text(blocks: &[Value]) -> String { pub(super) struct Applied { pub model: Option, pub effort: Option, + /// The approval digest of the preset contents `session/new` resolved + /// with, and the one its harness last spawned with + /// (`start_gate/preset_bind.rs`). + pub preset_resolved: Option, + pub preset_spawned: Option, } diff --git a/cmux-tui/crates/acpmux/src/hub/spawn.rs b/cmux-tui/crates/acpmux/src/hub/spawn.rs index dfbfbca34f1f..443145d758a3 100644 --- a/cmux-tui/crates/acpmux/src/hub/spawn.rs +++ b/cmux-tui/crates/acpmux/src/hub/spawn.rs @@ -30,14 +30,17 @@ impl Hub { /// every env value and argv word. The preset's args (checked against the /// allowlist again) and its system prompt file (checked against its /// recorded sha256) are appended here, at every spawn; a remote-origin - /// session never gets either. + /// session never gets either. The approval digest of the preset contents + /// read here is noted on the session (`hub/start_gate/preset_bind.rs`). pub(super) async fn spawn_profile( &self, session: &Session, profile: &HarnessProfile, defaults_env: &std::collections::BTreeMap, ) -> Result { - self.spawn_profile_for(&session.meta(), profile, defaults_env).await + let (p, digest) = self.spawn_profile_read(&session.meta(), profile, defaults_env).await?; + self.note_preset_spawned(session, digest); + Ok(p) } /// `spawn_profile` for a session's meta, also before the session exists @@ -48,7 +51,19 @@ impl Hub { profile: &HarnessProfile, defaults_env: &std::collections::BTreeMap, ) -> Result { + self.spawn_profile_read(meta, profile, defaults_env).await.map(|(p, _)| p) + } + + /// `spawn_profile_for`, and the approval digest of the preset contents + /// it read (under the one config lock it read them with). + async fn spawn_profile_read( + &self, + meta: &SessionMeta, + profile: &HarnessProfile, + defaults_env: &std::collections::BTreeMap, + ) -> Result<(HarnessProfile, Option), RpcError> { let mut p = profile.clone(); + let mut digest = None; for (k, v) in defaults_env { p.env.entry(k.clone()).or_insert_with(|| v.clone()); } @@ -58,6 +73,7 @@ impl Hub { if let Some(name) = &meta.preset && let Some(preset) = cfg.presets.get(name) { + digest = preset.approval_digest(); if meta.remote_origin && preset.shapes_command() { return Err(RpcError::invalid_params(format!( "preset {name:?} now carries harness args or a system prompt, which a remote-origin session never starts with" @@ -109,7 +125,7 @@ impl Hub { p.argv.push("--system-prompt-file".into()); p.argv.push(file.to_string_lossy().into_owned()); } - Ok(p) + Ok((p, digest)) } } diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate.rs b/cmux-tui/crates/acpmux/src/hub/start_gate.rs index 8ea9aba99943..64514f8f4d1b 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_gate.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_gate.rs @@ -6,9 +6,15 @@ use super::person::{REASON, person_required, valid_key}; use super::*; +mod preset_bind; +pub(crate) use preset_bind::StartGrant; + /// The env var that carries a session's caller token to its harness (and /// so to the acpmux CLI its agent runs). pub const CALLER_TOKEN_ENV: &str = "ACPMUX_SESSION_TOKEN"; +/// The env var that names the socket of the daemon that issued the caller +/// token: the CLI sends the token to that socket only (`client.rs`). +pub const CALLER_SOCKET_ENV: &str = "ACPMUX_SESSION_TOKEN_SOCKET"; /// `_meta.acpmux` field of a first `initialize`: `{sessionId, token}`. pub const CALLER_FIELD: &str = "callerSession"; /// `_meta.acpmux` field of `session/new`: the mode the session starts in. @@ -38,6 +44,13 @@ pub fn caller_token(session_id: &str) -> String { )) } +/// The env a harness of session `session_id` gets for (a): its caller token +/// and this daemon's socket, the one place the token is sent. +pub fn caller_env(session_id: &str) -> [(&'static str, String); 2] { + let socket = crate::config::socket_path().to_string_lossy().into_owned(); + [(CALLER_TOKEN_ENV, caller_token(session_id)), (CALLER_SOCKET_ENV, socket)] +} + /// Whether `token` is the caller token of `session_id` (constant time). pub fn caller_proves(session_id: &str, token: &str) -> bool { valid_key(token) && cmux_local_auth::tokens_match(token, &caller_token(session_id)) @@ -87,9 +100,16 @@ fn read_host_name() -> Option { /// The refusal of a mode that does not ask (module docs, (d)): one /// sentence that names where the person approves it. `start`: a -/// `session/new`; else a mode change of a running session. -pub fn mode_person_required(mode: &str, preset: Option<&str>, start: bool) -> RpcError { - let host = host_label(); +/// `session/new`; else a mode change of a running session. `local`: the +/// unix socket or the local app; a Web or peer client never learns this +/// host's name (it reads "the Mac that runs it" instead, and no `host`). +pub fn mode_person_required( + mode: &str, + preset: Option<&str>, + start: bool, + local: bool, +) -> RpcError { + let host = if local { host_label() } else { "the Mac that runs it" }; let message = match (start, preset) { (true, Some(p)) => format!( "mode {mode} does not ask before it acts, so only the person starts a session in it: approve preset {p} once in cmux NEXT on {host}, or start the session there" @@ -101,8 +121,11 @@ pub fn mode_person_required(mode: &str, preset: Option<&str>, start: bool) -> Rp "mode {mode} does not ask before it acts, so only the person sets it: set it in cmux NEXT on {host}, or start the session from a preset approved there once" ), }; - RpcError::new(-32000, message) - .with_data(json!({"reason": REASON, "host": host, "mode": mode, "preset": preset})) + let mut data = json!({"reason": REASON, "mode": mode, "preset": preset}); + if local { + data["host"] = json!(host); + } + RpcError::new(-32000, message).with_data(data) } /// What a `session/new` asks to start in (module docs, (a) and (b)). @@ -112,9 +135,18 @@ pub struct StartMode { pub mode: String, /// The preset the request names, for the refusal. pub preset: Option, - /// The person approved the named preset's current contents, its mode is - /// this one, and the request keeps its harness and adopts nothing. + /// The request starts the named preset as it is: its own mode, its + /// harness, no adopted conversation and no model, effort, policy or env + /// of its own (module docs, (b)); a folder is fine. + pub preset_fits: bool, + /// `preset_fits`, and the person approved the preset's current contents. + /// The start checks it again against the contents the session resolved + /// and spawned with (`start_gate/preset_bind.rs`). pub preset_approved: bool, + /// The request adopts a conversation (`_meta.acpmux.adopt`), which may be + /// a session already running: a mode that does not ask never applies to + /// it without the person. + pub adopts: bool, } impl StartMode { @@ -134,13 +166,18 @@ impl StartMode { let mode = requested.or_else(|| preset.and_then(|p| p.mode.clone()))?; let adopts = meta.and_then(|m| m.get("adopt")).is_some_and(|v| !v.is_null()); let harness = pick("harness"); - let preset_approved = preset.is_some_and(|p| { + let given = |key: &str| meta.and_then(|m| m.get(key)).or_else(|| params.get(key)); + let overrides = ["model", "effort", "policy"].into_iter().any(|k| pick(k).is_some()) + || given("env") + .is_some_and(|e| !e.is_null() && e.as_object().is_none_or(|o| !o.is_empty())); + let preset_fits = preset.is_some_and(|p| { p.mode.as_deref() == Some(mode.as_str()) - && p.mode_approved() && harness.as_deref().is_none_or(|h| h == p.harness) && !adopts + && !overrides }); - Some(Self { mode, preset: preset_name, preset_approved }) + let preset_approved = preset_fits && preset.is_some_and(|p| p.mode_approved()); + Some(Self { mode, preset: preset_name, preset_fits, preset_approved, adopts }) } } @@ -196,23 +233,28 @@ impl Hub { Err(_) => return Ok(()), } }; - self.start_mode_check(caller, &family, &start) + self.start_mode_check(caller, &family, &start).map(|_| ()) } /// Whether a client that is not the person may start a session of - /// `family` in `start` (module docs, (a), (b)); the refusal names where - /// to approve it (d). + /// `family` in `start` (module docs, (a), (b)), and why; the refusal + /// names where to approve it (d). An adopt never takes a mode that does + /// not ask: it may find a session already running, which only the + /// person widens (`session/set_mode`). pub(crate) fn start_mode_check( &self, caller: Option<&str>, family: &str, start: &StartMode, - ) -> Result<(), RpcError> { + ) -> Result { if self.web_modes().config_value_asks(family, None, Some(start.mode.as_str())) { - return Ok(()); + return Ok(StartGrant::Asks); + } + if start.adopts { + return Err(mode_person_required(&start.mode, start.preset.as_deref(), true, true)); } if start.preset_approved { - return Ok(()); + return Ok(StartGrant::Preset); } // The caller's mode now, on the session the daemon bound it to. let inherits = @@ -222,9 +264,9 @@ impl Hub { && crate::web_modes::mode_of(&m).as_deref() == Some(start.mode.as_str()) }); if inherits { - Ok(()) + Ok(StartGrant::Inherit) } else { - Err(mode_person_required(&start.mode, start.preset.as_deref(), true)) + Err(mode_person_required(&start.mode, start.preset.as_deref(), true, true)) } } @@ -287,8 +329,10 @@ impl Hub { /// `_meta.acpmux.mode`, else its preset's. The request check ran /// against the harness the request resolved to; this checks again /// against the session as it started (`person`: the connection is the - /// person), then sets the mode. A refused or failed mode ends a session - /// this request created (never one an adopt found already running). + /// person), with a preset's approval held against the contents the + /// session resolved and spawned with, then sets the mode. A refused or + /// failed mode ends a session this request created (never one an adopt + /// found already running). pub(crate) async fn start_in_mode( self: &Arc, person: bool, @@ -297,18 +341,20 @@ impl Hub { s: &Arc, adopting: bool, ) -> Result<(), RpcError> { - let start = { - let cfg = self.config.read().await; - StartMode::of(params, &cfg) - }; - let Some(start) = start else { return Ok(()) }; + let Some((start, bound)) = self.bound_start(params, s).await else { return Ok(()) }; let checked = if person { - Ok(()) + Ok(StartGrant::Person) } else { self.start_mode_check(caller, &crate::web_modes::family_of(&s.meta()), &start) }; let applied = match checked { - Ok(()) => self.set_mode(s, &start.mode).await.map(|_| ()), + Ok(grant) => match self.set_mode(s, &start.mode).await { + Ok(_) => { + self.note_start_grant(s, grant, bound); + Ok(()) + } + Err(e) => Err(e), + }, Err(e) => Err(e), }; if let Err(e) = applied { diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs b/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs new file mode 100644 index 000000000000..f448fb3d0b38 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs @@ -0,0 +1,117 @@ +//! A preset's approval bound to what a session actually runs (`hub/person.rs` +//! (b)). The approval is checked when a request comes in, but the contents +//! are read again when the session resolves (model, effort, env) and at +//! every spawn (args, env, system prompt): each read records the +//! [`crate::config::Preset::approval_digest`] of the contents it used, and +//! the start counts the approval only when every read matches it. A later +//! respawn from contents the person did not approve does not get the mode +//! the approval gave back. + +use super::*; + +/// Why a `session/new` may start in its mode (`Hub::start_mode_check`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StartGrant { + /// The connection is the person. + Person, + /// The mode asks. + Asks, + /// A preset the person approved, as the session runs it. + Preset, + /// The caller session's own mode. + Inherit, +} + +impl Hub { + /// The approval digest of the preset contents `session/new` resolved + /// with (model, effort, policy, env), before its first spawn. + pub(in crate::hub) fn note_preset_resolved(&self, s: &Session, digest: Option) { + s.applied.lock().unwrap_or_else(|e| e.into_inner()).preset_resolved = digest; + } + + /// At a spawn: the approval digest of the preset contents this spawn + /// used (`spawn.rs`, read under the same config lock as the contents). + /// A session that got its mode from a preset approval and now spawns + /// from other contents leaves that mode for the harness's own (module + /// docs): the saved mode is not replayed. + pub(in crate::hub) fn note_preset_spawned(&self, s: &Session, digest: Option) { + s.applied.lock().unwrap_or_else(|e| e.into_inner()).preset_spawned = digest.clone(); + let dropped = { + let mut m = s.meta.lock().unwrap_or_else(|e| e.into_inner()); + let kept = match (m.preset_mode_approval.as_deref(), digest.as_deref()) { + (None, _) => true, + (Some(approved), Some(now)) => cmux_local_auth::tokens_match(now, approved), + (Some(_), None) => false, + }; + if !kept { + m.preset_mode_approval = None; + let family = crate::web_modes::family_of(&m); + let mode = crate::web_modes::mode_of(&m); + if !self.web_modes().config_value_asks(&family, None, mode.as_deref()) { + forget_mode(&mut m); + } + } + !kept + }; + if dropped { + self.append(s, "mux", "preset_mode_dropped", json!({"preset": s.meta().preset})); + self.save_meta(s); + } + } + + /// `session/new`'s start mode, with the preset approval held against + /// the contents session `s` resolved and spawned with: the approved + /// digest when both match it (`StartMode::preset_approved` is then + /// `preset_fits` and that). None when the request asks for no mode. + pub(in crate::hub) async fn bound_start( + &self, + params: &Value, + s: &Session, + ) -> Option<(StartMode, Option)> { + let cfg = self.config.read().await; + let mut start = StartMode::of(params, &cfg)?; + let approval = start + .preset + .as_deref() + .and_then(|n| cfg.presets.get(n)) + .filter(|p| p.mode.is_some()) + .and_then(|p| p.mode_approval.clone()); + let bound = { + let a = s.applied.lock().unwrap_or_else(|e| e.into_inner()); + let same = |d: &Option, approved: &str| { + d.as_deref().is_some_and(|d| cmux_local_auth::tokens_match(d, approved)) + }; + approval.filter(|d| same(&a.preset_resolved, d) && same(&a.preset_spawned, d)) + }; + start.preset_approved = start.preset_fits && bound.is_some(); + Some((start, bound)) + } + + /// After the start mode is set: a mode a preset approval gave is + /// recorded with the approved digest, so a respawn from other contents + /// leaves it (`note_preset_spawned`). + pub(in crate::hub) fn note_start_grant( + &self, + s: &Session, + grant: StartGrant, + bound: Option, + ) { + if grant == StartGrant::Preset { + s.meta.lock().unwrap_or_else(|e| e.into_inner()).preset_mode_approval = bound; + self.save_meta(s); + } + } +} + +/// Forget a session's saved mode (its ACP mode and its `mode` option), so a +/// spawn starts in the harness's own and replays none. +fn forget_mode(m: &mut crate::store::SessionMeta) { + if let Some(modes) = m.modes.as_mut().and_then(Value::as_object_mut) { + modes.remove("currentModeId"); + } + if let Some(opts) = m.config_options.as_mut().and_then(Value::as_array_mut) { + for o in opts.iter_mut().filter(|o| o.get("id").and_then(Value::as_str) == Some("mode")) { + o["currentValue"] = Value::Null; + } + } +} diff --git a/cmux-tui/crates/acpmux/src/peer.rs b/cmux-tui/crates/acpmux/src/peer.rs index ea8fe0f2d2e5..ee1acfa5ebb1 100644 --- a/cmux-tui/crates/acpmux/src/peer.rs +++ b/cmux-tui/crates/acpmux/src/peer.rs @@ -63,6 +63,10 @@ pub struct Peer { /// The origin the peer serves this daemon as (its initialize reply): /// `peer` when it accepted the peer token, `remote` (Web) otherwise. served_as: StdMutex>, + /// The daemon run the peer named in its initialize reply + /// (`personInstance`): the only run its pending prompts may carry + /// (`hub/person.rs` (c)). A claim, not a proof. + person_instance: StdMutex>, /// Whether the peer advertised the folder-trust gate in initialize. trust_gate: AtomicBool, /// The peer token was held back: the transport is plain `ws://` to a @@ -102,6 +106,7 @@ impl Peer { last_error: StdMutex::new(None), remote_version: StdMutex::new(None), served_as: StdMutex::new(None), + person_instance: StdMutex::new(None), trust_gate: AtomicBool::new(false), withheld: AtomicBool::new(false), attached: StdMutex::new(HashSet::new()), @@ -199,6 +204,11 @@ impl Peer { self.remote_version.lock().unwrap().as_ref().map(|(_, b)| b.clone()) } + /// The daemon run the peer named at its last handshake. + pub fn person_instance(&self) -> Option { + self.person_instance.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + pub fn supports_trust_gate(&self) -> bool { self.trust_gate.load(Ordering::SeqCst) } @@ -375,6 +385,10 @@ impl Peer { *me.remote_version.lock().unwrap() = Some((v, b)); *me.served_as.lock().unwrap_or_else(|e| e.into_inner()) = init.pointer("/_meta/acpmux/origin").and_then(Value::as_str).map(str::to_owned); + *me.person_instance.lock().unwrap_or_else(|e| e.into_inner()) = init + .pointer("/_meta/acpmux/personInstance") + .and_then(Value::as_str) + .map(str::to_owned); let advertised = init.pointer("/_meta/acpmux/features").and_then(Value::as_array).is_some_and( |features| features.iter().any(|f| f.as_str() == Some("trustGate")), diff --git a/cmux-tui/crates/acpmux/src/server/mod.rs b/cmux-tui/crates/acpmux/src/server/mod.rs index e9c10048e0dd..5c2d547a7d7c 100644 --- a/cmux-tui/crates/acpmux/src/server/mod.rs +++ b/cmux-tui/crates/acpmux/src/server/mod.rs @@ -867,14 +867,14 @@ fn deliver(hub: &Hub, conn: &Conn, ev: HubEvent) { } if watching { if let Some(remote) = &ev.remote { - // A peer's prompt the person must answer, for a watcher that did - // not attach the session (`hub/person.rs` (c)): the peer's own - // notification, with which peer asks. + // A peer's prompt the person must answer, for a unix socket or app + // watcher that did not attach the session (`hub/person.rs` (c)): + // never relayed on to a Web client or another peer daemon. if rec.kind == crate::hub::PEER_PERMISSION_PENDING { - if !attached { + if !attached && !conn.origin.web_class() { conn.send(&Message::notification( method::MUX_PERMISSION_PENDING, - peer_permission_pending(rec, &remote.peer), + peer_permission_pending(&ev.session_id, rec, &remote.peer), )); } return; diff --git a/cmux-tui/crates/acpmux/src/server/pending.rs b/cmux-tui/crates/acpmux/src/server/pending.rs index c99804082ded..a0ade60277c2 100644 --- a/cmux-tui/crates/acpmux/src/server/pending.rs +++ b/cmux-tui/crates/acpmux/src/server/pending.rs @@ -24,10 +24,11 @@ pub(super) fn permission_pending(session_id: &str, rec: &EventRecord, via: &str) /// A peer's `_acpmux/permission_pending` for a watcher of this daemon that /// did not attach the session (`hub/person.rs` (c)): the peer's own -/// notification (its `decisionNonce` and `personInstance` included), with -/// which peer asks. -pub(super) fn peer_permission_pending(rec: &EventRecord, peer: &str) -> Value { +/// notification (its `decisionNonce` and `personInstance` included), for the +/// session it was checked for (`hub/peers.rs`), with which peer asks. +pub(super) fn peer_permission_pending(session_id: &str, rec: &EventRecord, peer: &str) -> Value { let mut p = rec.msg.clone(); + p["sessionId"] = Value::String(session_id.to_owned()); p["peer"] = Value::String(peer.to_owned()); p["via"] = Value::String("peer".into()); p diff --git a/cmux-tui/crates/acpmux/src/store.rs b/cmux-tui/crates/acpmux/src/store.rs index 0678a7c3b376..7a2cebdc969c 100644 --- a/cmux-tui/crates/acpmux/src/store.rs +++ b/cmux-tui/crates/acpmux/src/store.rs @@ -175,6 +175,11 @@ pub struct SessionMeta { /// Unsent composer text owned by this acpmux session. #[serde(default, skip_serializing_if = "Option::is_none")] pub composer_draft: Option, + /// The session took its mode from the person's approval of its preset + /// (`hub/person.rs` (b)): the approved contents' digest. A spawn from + /// other contents leaves that mode (`hub/start_gate/preset_bind.rs`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preset_mode_approval: Option, } impl SessionMeta { From 55d19b048a08abf8f543e5dae230459a0fd99d88 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 08:23:18 -0700 Subject: [PATCH 061/183] debug: show why an approved preset start is refused (temporary) Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/hub/start_gate.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate.rs b/cmux-tui/crates/acpmux/src/hub/start_gate.rs index 64514f8f4d1b..0cf3c5ff6fa4 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_gate.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_gate.rs @@ -233,7 +233,12 @@ impl Hub { Err(_) => return Ok(()), } }; - self.start_mode_check(caller, &family, &start).map(|_| ()) + self.start_mode_check(caller, &family, &start).map(|_| ()).map_err(|mut e| { + if let Some(d) = e.data.as_mut() { + d["debug"] = json!({"where": "pre", "fits": start.preset_fits, "approved": start.preset_approved}); + } + e + }) } /// Whether a client that is not the person may start a session of @@ -347,6 +352,14 @@ impl Hub { } else { self.start_mode_check(caller, &crate::web_modes::family_of(&s.meta()), &start) }; + // DEBUG(temporary): why a fitting preset start is refused. + let checked = checked.map_err(|mut e| { + let a = s.applied.lock().unwrap_or_else(|e| e.into_inner()); + let dbg = json!({"fits": start.preset_fits, "approved": start.preset_approved, + "bound": bound, "resolved": a.preset_resolved, "spawned": a.preset_spawned}); + if let Some(d) = e.data.as_mut() { d["debug"] = dbg; } + e + }); let applied = match checked { Ok(grant) => match self.set_mode(s, &start.mode).await { Ok(_) => { From 140d23d800e59161767c957375887d95117cfbb3 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 08:27:32 -0700 Subject: [PATCH 062/183] Revert "debug: show why an approved preset start is refused (temporary)" This reverts commit 55d19b048a08abf8f543e5dae230459a0fd99d88. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/hub/start_gate.rs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate.rs b/cmux-tui/crates/acpmux/src/hub/start_gate.rs index 0cf3c5ff6fa4..64514f8f4d1b 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_gate.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_gate.rs @@ -233,12 +233,7 @@ impl Hub { Err(_) => return Ok(()), } }; - self.start_mode_check(caller, &family, &start).map(|_| ()).map_err(|mut e| { - if let Some(d) = e.data.as_mut() { - d["debug"] = json!({"where": "pre", "fits": start.preset_fits, "approved": start.preset_approved}); - } - e - }) + self.start_mode_check(caller, &family, &start).map(|_| ()) } /// Whether a client that is not the person may start a session of @@ -352,14 +347,6 @@ impl Hub { } else { self.start_mode_check(caller, &crate::web_modes::family_of(&s.meta()), &start) }; - // DEBUG(temporary): why a fitting preset start is refused. - let checked = checked.map_err(|mut e| { - let a = s.applied.lock().unwrap_or_else(|e| e.into_inner()); - let dbg = json!({"fits": start.preset_fits, "approved": start.preset_approved, - "bound": bound, "resolved": a.preset_resolved, "spawned": a.preset_spawned}); - if let Some(d) = e.data.as_mut() { d["debug"] = dbg; } - e - }); let applied = match checked { Ok(grant) => match self.set_mode(s, &start.mode).await { Ok(_) => { From b05cf156bafd2421338dc8c3407918278dbd3f64 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 08:29:32 -0700 Subject: [PATCH 063/183] fix: an approved preset's digests survive the fresh agent's reset ensure_child cleared Session::applied for every fresh agent, right after spawn_profile had noted the preset digests of that spawn, so the start never matched an approval and every approved-preset start was refused. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/hub/lifecycle.rs | 3 +-- cmux-tui/crates/acpmux/src/hub/start_gate.rs | 1 + .../crates/acpmux/src/hub/start_gate/preset_bind.rs | 12 ++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index d43613fc00e8..9f1d4a3e6042 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -339,8 +339,7 @@ impl Hub { self.record_launch_roots(session, profile).await; // A fresh agent: what acpmux set on the last one is replayed, not assumed. - *session.applied.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = - Default::default(); + super::start_gate::fresh_applied(session); // A pooled session claimed for this id (`pool/`): its host already // runs with the harness initialized and its session created. if let Some(pooled) = self.pool_take_claimed(&session.id) { diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate.rs b/cmux-tui/crates/acpmux/src/hub/start_gate.rs index 64514f8f4d1b..772cfbe35026 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_gate.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_gate.rs @@ -8,6 +8,7 @@ use super::*; mod preset_bind; pub(crate) use preset_bind::StartGrant; +pub(in crate::hub) use preset_bind::fresh_applied; /// The env var that carries a session's caller token to its harness (and /// so to the acpmux CLI its agent runs). diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs b/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs index f448fb3d0b38..827409abd677 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs @@ -103,6 +103,18 @@ impl Hub { } } +/// A fresh agent for `s` (`Hub::ensure_child`): nothing acpmux set on the last +/// one is assumed, but the preset digests stay, since `spawn_profile` noted +/// them for this spawn's contents just before. +pub(in crate::hub) fn fresh_applied(s: &Session) { + let mut a = s.applied.lock().unwrap_or_else(|e| e.into_inner()); + *a = crate::hub::session::Applied { + preset_resolved: a.preset_resolved.take(), + preset_spawned: a.preset_spawned.take(), + ..Default::default() + }; +} + /// Forget a session's saved mode (its ACP mode and its `mode` option), so a /// spawn starts in the harness's own and replays none. fn forget_mode(m: &mut crate::store::SessionMeta) { From 0f34db09e1a704291b0a1f71be3d74348e923ee4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 08:48:58 -0700 Subject: [PATCH 064/183] test: a respawned preset session leaves the approved mode, whatever the fake reports The fake's session/load reports no modes, so after the drop acpmux knows no mode at all; the boundary is that the approved no-ask mode is not replayed. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/tests/person_auto_start.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/tests/person_auto_start.rs b/cmux-tui/crates/acpmux/tests/person_auto_start.rs index 2464716ceaef..7a114bc30a0a 100644 --- a/cmux-tui/crates/acpmux/tests/person_auto_start.rs +++ b/cmux-tui/crates/acpmux/tests/person_auto_start.rs @@ -595,7 +595,9 @@ async fn auto_start_an_approved_preset_edited_then_respawned_leaves_its_no_ask_m tokio::time::sleep(Duration::from_millis(1500)).await; // The next prompt respawns the harness from the changed preset. cli.ok("session/prompt", prompt("hello")).await; - assert_eq!(mode(&mut cli, json!(id)).await, "normal"); + // The harness's own mode, not the approved one replayed (the fake's + // session/load reports none, so acpmux knows no mode). + assert_ne!(mode(&mut cli, json!(id)).await, "bypass"); } /// An approved preset starts exactly what the person approved: a request From 321d7d4458abb4005584f8459cf98ade967b38ad Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 09:36:06 -0700 Subject: [PATCH 065/183] Keep the palette upgrade command available before the account is known (#20636) The command palette hid Upgrade whenever its context had no offer, so a context built before the account loads (CloudFeatureFlagTests) lost it. Write "no offer" as an explicit value and read a missing value as the signed-out Pro offer. Co-authored-by: Claude Opus 5.5 --- Sources/ContentView+ProCommandPalette.swift | 18 +++++++++++++----- Sources/ContentView.swift | 2 +- cmuxTests/AccountUpgradeOfferTests.swift | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView+ProCommandPalette.swift b/Sources/ContentView+ProCommandPalette.swift index 2aa335919e3a..59d5839e6888 100644 --- a/Sources/ContentView+ProCommandPalette.swift +++ b/Sources/ContentView+ProCommandPalette.swift @@ -8,16 +8,24 @@ extension ContentView { static let commandPaletteProWelcomeChecklistCommandId = "palette.pro.welcomeChecklist" /// Encodes an upgrade offer for ``CommandPaletteContextKeys/accountUpgradeOffer``. - nonisolated static func commandPaletteUpgradeOfferValue(_ tier: CmuxPlanTier) -> String { - tier == .max ? "max" : "pro" + /// No offer is written as `none`, so it stays distinct from a context + /// that has not been filled in yet. + nonisolated static func commandPaletteUpgradeOfferValue(_ tier: CmuxPlanTier?) -> String { + switch tier { + case .max: "max" + case nil: "none" + case .free, .go, .pro: "pro" + } } - /// Decodes ``CommandPaletteContextKeys/accountUpgradeOffer``; nil hides the command. + /// Decodes ``CommandPaletteContextKeys/accountUpgradeOffer``; nil hides the + /// command. A context without the key (before the account is known) offers + /// Pro, as a signed-out account does. nonisolated static func commandPaletteUpgradeOffer(_ context: CommandPaletteContextSnapshot) -> CmuxPlanTier? { switch context.string(CommandPaletteContextKeys.accountUpgradeOffer) { case "max": .max - case "pro": .pro - default: nil + case "none": nil + default: .pro } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bb4da4b8627d..14b22d663638 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7336,7 +7336,7 @@ struct ContentView: View { } snapshot.setString( CommandPaletteContextKeys.accountUpgradeOffer, - ProUpgradePresenter.currentUpgradeOffer.map(Self.commandPaletteUpgradeOfferValue) + Self.commandPaletteUpgradeOfferValue(ProUpgradePresenter.currentUpgradeOffer) ) let focusedDock = AppDelegate.shared?.focusedDockStoreForShortcut( diff --git a/cmuxTests/AccountUpgradeOfferTests.swift b/cmuxTests/AccountUpgradeOfferTests.swift index 404bbdf34d9e..dd8c82f6862e 100644 --- a/cmuxTests/AccountUpgradeOfferTests.swift +++ b/cmuxTests/AccountUpgradeOfferTests.swift @@ -37,7 +37,7 @@ struct AccountUpgradeOfferTests { let offer = CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: tier) context.setString( CommandPaletteContextKeys.accountUpgradeOffer, - offer.map(ContentView.commandPaletteUpgradeOfferValue) + ContentView.commandPaletteUpgradeOfferValue(offer) ) #expect(command.when(context) == (expected != nil)) if let expected { @@ -52,7 +52,7 @@ struct AccountUpgradeOfferTests { var context = CommandPaletteContextSnapshot() context.setString( CommandPaletteContextKeys.accountUpgradeOffer, - CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: nil).map(ContentView.commandPaletteUpgradeOfferValue) + ContentView.commandPaletteUpgradeOfferValue(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: nil)) ) #expect(!command.when(context)) } From 14f2ddb85d17b6286152984ad94a7a74f5831a94 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 10:58:14 -0700 Subject: [PATCH 066/183] test: a request during an approved preset's start never rebinds the approval A harness held at its start gate keeps the session's spawn; a client edits the preset and asks the session for something meanwhile. The start that spawned the approved contents must keep its mode: only the cold spawn records what it spawned. Co-Authored-By: Claude Opus 5.5 --- .../crates/acpmux/tests/person_auto_start.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/cmux-tui/crates/acpmux/tests/person_auto_start.rs b/cmux-tui/crates/acpmux/tests/person_auto_start.rs index 7a114bc30a0a..d9a887293d5c 100644 --- a/cmux-tui/crates/acpmux/tests/person_auto_start.rs +++ b/cmux-tui/crates/acpmux/tests/person_auto_start.rs @@ -175,6 +175,7 @@ impl Daemon { "fake": {"argv": ["python3", FAKE]}, "mirror": {"argv": ["python3", FAKE], "family": "mirror"}, "other": {"argv": ["python3", FAKE], "family": "fake", "env": {"FAKE_SESSION_PREFIX": "other-"}}, + "gated": {"argv": ["python3", FAKE], "family": "fake", "env": {"FAKE_START_GATE": dir.join("gate")}}, "wide": {"argv": ["python3", FAKE], "family": "wide", "env": {"FAKE_FULL_ACCESS": "1"}}, }, "defaultHarness": "fake", @@ -629,3 +630,49 @@ async fn auto_start_an_approved_preset_with_request_overrides_needs_the_person() let s = cli.ok("session/new", new_params(&d, json!({"preset": "lane"}))).await; assert_eq!(current_mode(&s), "bypass", "{s}"); } + +/// An approved preset's start counts only the contents its own harness was +/// spawned with. Another request on the session while that harness starts +/// (after any client edited the preset) spawns nothing and records nothing, +/// so the start, which spawned the approved contents, keeps its mode. +#[tokio::test] +async fn auto_start_a_request_during_the_start_never_rebinds_the_approval() { + let d = Daemon::start("race"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "gated", "mode": "bypass"}, "approveMode": true}), + ) + .await; + // The harness waits at its gate, so the start holds the session's spawn. + let mut starter = Rpc::plain(&d.socket).await; + let rid = starter + .send("session/new", new_params(&d, json!({"preset": "lane", "name": "racing"}))) + .await; + let id = loop { + let list = cli.ok("_acpmux/sessions", json!({})).await; + let found = list["sessions"] + .as_array() + .and_then(|a| a.iter().find(|s| s["name"] == "racing").map(|s| s["sessionId"].clone())); + if let Some(id) = found { + break id; + } + tokio::time::sleep(Duration::from_millis(50)).await; + }; + tokio::time::sleep(Duration::from_millis(500)).await; + // Meanwhile a client edits the preset and asks the starting session for something. + cli.ok("_acpmux/presets", json!({"name": "lane", "set": {"model": "m2"}})).await; + let mut other = Rpc::plain(&d.socket).await; + other + .send( + "session/set_config_option", + json!({"sessionId": id, "configId": "effort", "value": "high"}), + ) + .await; + tokio::time::sleep(Duration::from_millis(500)).await; + std::fs::write(d.dir.join("gate"), "").unwrap(); + let s = starter.reply(rid).await; + assert!(s.get("error").is_none(), "{s}"); + assert_eq!(current_mode(&s["result"]), "bypass", "{s}"); +} From 68914f28844a3c2d274f9d33469e10a4ef2de287 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 11:52:02 -0700 Subject: [PATCH 067/183] fix(acpmux): record the preset spawn digest only on a cold start The spawned digest was written by spawn_profile before spawn_lock, so a request that raced an approved preset's start could rebind it, and a live or re-adopted child could clear the saved mode while its harness kept the no-ask mode. ensure_child now records the digest it spawned with, under spawn_lock, only on the cold path; the saved mode is dropped only there. forget_mode clears every mode field, ACPMUX_SOCKET is made absolute, and person.rs names the third-daemon personInstance residual (cx-i4rte). Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/config.rs | 19 +++++- cmux-tui/crates/acpmux/src/hub/lifecycle.rs | 4 +- cmux-tui/crates/acpmux/src/hub/person.rs | 15 ++++- cmux-tui/crates/acpmux/src/hub/spawn.rs | 26 ++++++-- cmux-tui/crates/acpmux/src/hub/start_gate.rs | 1 - .../acpmux/src/hub/start_gate/preset_bind.rs | 62 ++++++++++--------- 6 files changed, 85 insertions(+), 42 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/config.rs b/cmux-tui/crates/acpmux/src/config.rs index 52a08f0e71d8..7237f1d21c59 100644 --- a/cmux-tui/crates/acpmux/src/config.rs +++ b/cmux-tui/crates/acpmux/src/config.rs @@ -44,9 +44,22 @@ pub fn socket_path() -> PathBuf { // socket there. The long path fails to bind with a clear error instead. let dir = PathBuf::from(format!("/tmp/acpmux-{uid}")); if resolved.parent() == Some(dir.as_path()) && !private_dir(&dir, uid) { - return configured.map(PathBuf::from).unwrap_or_else(|| home().join("acpmux.sock")); + return absolute( + configured.map(PathBuf::from).unwrap_or_else(|| home().join("acpmux.sock")), + ); + } + absolute(resolved) +} + +/// A relative `ACPMUX_SOCKET` against this process's folder: the daemon +/// hands the path to harnesses that run in a project folder +/// (`hub/person.rs` `caller_env`), where a relative one names another file. +fn absolute(path: PathBuf) -> PathBuf { + if path.is_relative() { + std::env::current_dir().map(|d| d.join(&path)).unwrap_or(path) + } else { + path } - resolved } /// Windows: `ACPMUX_SOCKET`, else `\\acpmux.sock`; a long home (AF_UNIX @@ -56,7 +69,7 @@ pub fn socket_path() -> PathBuf { #[cfg(windows)] pub fn socket_path() -> PathBuf { if let Some(v) = std::env::var("ACPMUX_SOCKET").ok().filter(|v| !v.is_empty()) { - return PathBuf::from(v); + return absolute(PathBuf::from(v)); } let preferred = home().join("acpmux.sock"); if preferred.as_os_str().len() < 96 { diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index 9f1d4a3e6042..4eaacb006c3d 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -303,7 +303,7 @@ impl Hub { pub(super) async fn ensure_child( self: &Arc, session: &Arc, - profile: &HarnessProfile, + profile: &super::spawn::Spawn, ) -> Result, RpcError> { // One spawn at a time per session; a caller that waited here finds // the child the previous holder started. @@ -339,7 +339,7 @@ impl Hub { self.record_launch_roots(session, profile).await; // A fresh agent: what acpmux set on the last one is replayed, not assumed. - super::start_gate::fresh_applied(session); + self.fresh_agent(session, profile.preset_digest.clone()); // A pooled session claimed for this id (`pool/`): its host already // runs with the harness initialized and its session created. if let Some(pooled) = self.pool_take_claimed(&session.id) { diff --git a/cmux-tui/crates/acpmux/src/hub/person.rs b/cmux-tui/crates/acpmux/src/hub/person.rs index 04ce4f7a4060..7fcb6f739538 100644 --- a/cmux-tui/crates/acpmux/src/hub/person.rs +++ b/cmux-tui/crates/acpmux/src/hub/person.rs @@ -119,10 +119,14 @@ //! records the digest of what it used, and the start counts the approval //! only when the resolve's and the spawn's digests are the approved one //! (`hub/start_gate/preset_bind.rs`), so an edit that is put back between -//! reads gains nothing. Such a preset never takes a pooled harness. The -//! session records the approved digest; a later spawn from other +//! reads gains nothing. The spawn's digest is recorded only by the cold +//! start of the agent, under the session's spawn lock, for the profile it +//! actually started; a request that finds the agent running or re-adopts +//! its host records nothing. Such a preset never takes a pooled harness. +//! The session records the approved digest; a later cold start from other //! contents (an edit, then a respawn) starts in the harness's own mode -//! instead of replaying the approved one. Bound to the +//! instead of replaying the approved one, and a running agent keeps the +//! mode it runs, which its saved mode keeps saying. Bound to the //! contents, not cleared on a write, so every way the preset can change //! (a `_acpmux/presets` write from any client, a hand edit of config.json //! and a reload, a future write path) drops it without having to know @@ -151,6 +155,11 @@ //! so this daemon's prompts (whose proofs it can make) never arrive with //! a peer's text. Binding the proof to the prompt's text would need the //! app to hash what it shows; not done. +//! Residual: the run a peer names at its handshake is a claim. A peer can +//! name a third daemon's run (one the app enrolled directly, say over +//! ssh) and re-send that daemon's prompt with other text; this daemon +//! cannot tell. The app has to refuse to prove a `via: "peer"` prompt whose +//! `personInstance` is a daemon it enrolled directly (an app follow-up). //! //! The default start mode (`hub/start_mode.rs`, cx-wx93i) is not a request: //! with no `mode` asked, a new chat starts in the person's latest pick, else diff --git a/cmux-tui/crates/acpmux/src/hub/spawn.rs b/cmux-tui/crates/acpmux/src/hub/spawn.rs index 443145d758a3..16348a3e5d04 100644 --- a/cmux-tui/crates/acpmux/src/hub/spawn.rs +++ b/cmux-tui/crates/acpmux/src/hub/spawn.rs @@ -31,16 +31,18 @@ impl Hub { /// allowlist again) and its system prompt file (checked against its /// recorded sha256) are appended here, at every spawn; a remote-origin /// session never gets either. The approval digest of the preset contents - /// read here is noted on the session (`hub/start_gate/preset_bind.rs`). + /// read here goes with the profile, and only a cold start records it + /// (`Hub::ensure_child`, `hub/start_gate/preset_bind.rs`): a request that + /// finds the agent running spawns nothing and records nothing. pub(super) async fn spawn_profile( &self, session: &Session, profile: &HarnessProfile, defaults_env: &std::collections::BTreeMap, - ) -> Result { - let (p, digest) = self.spawn_profile_read(&session.meta(), profile, defaults_env).await?; - self.note_preset_spawned(session, digest); - Ok(p) + ) -> Result { + let (profile, preset_digest) = + self.spawn_profile_read(&session.meta(), profile, defaults_env).await?; + Ok(Spawn { profile, preset_digest }) } /// `spawn_profile` for a session's meta, also before the session exists @@ -129,6 +131,20 @@ impl Hub { } } +/// A session's spawn: the profile as it is spawned, and the approval digest +/// of the preset contents that profile was built from (under one config read). +pub(super) struct Spawn { + pub profile: HarnessProfile, + pub preset_digest: Option, +} + +impl std::ops::Deref for Spawn { + type Target = HarnessProfile; + fn deref(&self) -> &HarnessProfile { + &self.profile + } +} + /// `${cwd}`, `${home}`, `${model}` and a leading `~/` in a profile env value or argv word. pub fn expand_env_value( value: &str, diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate.rs b/cmux-tui/crates/acpmux/src/hub/start_gate.rs index 772cfbe35026..64514f8f4d1b 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_gate.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_gate.rs @@ -8,7 +8,6 @@ use super::*; mod preset_bind; pub(crate) use preset_bind::StartGrant; -pub(in crate::hub) use preset_bind::fresh_applied; /// The env var that carries a session's caller token to its harness (and /// so to the acpmux CLI its agent runs). diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs b/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs index 827409abd677..a6d384aed806 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs @@ -1,11 +1,12 @@ //! A preset's approval bound to what a session actually runs (`hub/person.rs` //! (b)). The approval is checked when a request comes in, but the contents //! are read again when the session resolves (model, effort, env) and at -//! every spawn (args, env, system prompt): each read records the -//! [`crate::config::Preset::approval_digest`] of the contents it used, and -//! the start counts the approval only when every read matches it. A later -//! respawn from contents the person did not approve does not get the mode -//! the approval gave back. +//! every spawn (args, env, system prompt). The resolve and each cold start +//! of the agent record the [`crate::config::Preset::approval_digest`] of the +//! contents they used (a cold start under the session's spawn lock, with the +//! digest the profile was built from), and the start counts the approval +//! only when both match it. A later cold start from contents the person did +//! not approve does not get the mode the approval gave back. use super::*; @@ -29,13 +30,24 @@ impl Hub { s.applied.lock().unwrap_or_else(|e| e.into_inner()).preset_resolved = digest; } - /// At a spawn: the approval digest of the preset contents this spawn - /// used (`spawn.rs`, read under the same config lock as the contents). - /// A session that got its mode from a preset approval and now spawns - /// from other contents leaves that mode for the harness's own (module - /// docs): the saved mode is not replayed. - pub(in crate::hub) fn note_preset_spawned(&self, s: &Session, digest: Option) { - s.applied.lock().unwrap_or_else(|e| e.into_inner()).preset_spawned = digest.clone(); + /// A cold start of `s`'s agent (`Hub::ensure_child`, under the session's + /// spawn lock, after a live or re-adopted agent was ruled out): nothing + /// acpmux set on the last agent is assumed, and `digest` is the approval + /// digest of the preset contents this agent is spawned from. A session + /// that got its mode from a preset approval and now spawns from other + /// contents leaves that mode for the harness's own (module docs): the + /// saved mode is not replayed. Only here: a request that finds the agent + /// running (or re-adopts its host) spawns nothing, so it changes neither + /// the record nor the saved mode, which stays what that agent runs. + pub(in crate::hub) fn fresh_agent(&self, s: &Session, digest: Option) { + { + let mut a = s.applied.lock().unwrap_or_else(|e| e.into_inner()); + *a = crate::hub::session::Applied { + preset_resolved: a.preset_resolved.take(), + preset_spawned: digest.clone(), + ..Default::default() + }; + } let dropped = { let mut m = s.meta.lock().unwrap_or_else(|e| e.into_inner()); let kept = match (m.preset_mode_approval.as_deref(), digest.as_deref()) { @@ -89,7 +101,7 @@ impl Hub { /// After the start mode is set: a mode a preset approval gave is /// recorded with the approved digest, so a respawn from other contents - /// leaves it (`note_preset_spawned`). + /// leaves it (`fresh_agent`). pub(in crate::hub) fn note_start_grant( &self, s: &Session, @@ -103,26 +115,20 @@ impl Hub { } } -/// A fresh agent for `s` (`Hub::ensure_child`): nothing acpmux set on the last -/// one is assumed, but the preset digests stay, since `spawn_profile` noted -/// them for this spawn's contents just before. -pub(in crate::hub) fn fresh_applied(s: &Session) { - let mut a = s.applied.lock().unwrap_or_else(|e| e.into_inner()); - *a = crate::hub::session::Applied { - preset_resolved: a.preset_resolved.take(), - preset_spawned: a.preset_spawned.take(), - ..Default::default() - }; -} - -/// Forget a session's saved mode (its ACP mode and its `mode` option), so a -/// spawn starts in the harness's own and replays none. +/// Forget a session's saved mode, so a spawn starts in the harness's own and +/// replays none: its ACP mode and every config option that is a mode, an +/// approval policy or a sandbox (`web_modes::MODE_FIELDS`). fn forget_mode(m: &mut crate::store::SessionMeta) { if let Some(modes) = m.modes.as_mut().and_then(Value::as_object_mut) { modes.remove("currentModeId"); } + let mode_like = |o: &Value| { + o.get("id") + .and_then(Value::as_str) + .is_some_and(|id| id != "modeId" && crate::web_modes::MODE_FIELDS.contains(&id)) + }; if let Some(opts) = m.config_options.as_mut().and_then(Value::as_array_mut) { - for o in opts.iter_mut().filter(|o| o.get("id").and_then(Value::as_str) == Some("mode")) { + for o in opts.iter_mut().filter(|o| mode_like(o)) { o["currentValue"] = Value::Null; } } From 9d01f956c51cd895e10e7d56b6dd7b5f6fcce625 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 14 Oct 2026 12:07:52 -0700 Subject: [PATCH 068/183] ci(release): wait up to 4h for the Computer Use helper notarization (#20704) * ci(release): wait 75m for the Computer Use helper notarization Apple held the helper submission past the 25m default twice while releasing v0.65.2. Stable has no pending-notarization resume, so each timeout restarts the whole build job. Co-Authored-By: Claude Opus 5.5 * ci(release): cap each thin-variant helper wait at 15m Co-Authored-By: Claude Opus 5.5 * ci(release): wait up to 4h for the helper notarization while Apple is backed up Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebc99c6a6c16..82497bd63226 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,7 +114,15 @@ jobs: # no headroom. Keep at least 25 percent above the measured duration. The thin # arm64 and x86_64 variants add two more helper and DMG notarizations, which # run serially after the universal one. - timeout-minutes: 150 + timeout-minutes: 355 + env: + # Apple held the helper submission past the script's 25m default three + # times on 2026-10-14 (v0.65.2) while other team submissions sat In + # Progress for over five hours; 0.65.1 took 3m. Stable has no + # pending-notarization resume, so a timeout restarts the whole job. Wait up + # to 4h, inside a job limit just under Blacksmith's 6h; the thin-variant + # step keeps a shorter wait because its helpers process alongside this one. + CMUX_HELPER_WAIT_TIMEOUT: 240m steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash @@ -705,6 +713,10 @@ jobs: ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} CMUX_CHANNEL: stable + # The thin helpers were submitted next to the universal one and have + # been processing through its wait, so a short wait each keeps the two + # serial variants inside the job limit. + CMUX_HELPER_WAIT_TIMEOUT: 30m run: | set -euo pipefail for arch in arm64 x86_64; do From 6bdb32100dce21ea9a63ffd22ff479c0a821bb41 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:29:45 -0700 Subject: [PATCH 069/183] diag(cmux-next): page calls in flight in debug.page, slow page replies and Chief profile relay steps in the log; click proof script (cx-ebm.59.5) --- .../CmuxNextApp/Pages/DebugPages.swift | 4 +- .../ChiefProfilePageRelay.swift | 32 +- .../Sources/CmuxNextPages/PageRouter.swift | 20 +- .../Sources/CmuxNextPages/PageWebView.swift | 13 + scripts/cmux-next/chief-create-click-proof.py | 438 ++++++++++++++++++ 5 files changed, 494 insertions(+), 13 deletions(-) create mode 100644 scripts/cmux-next/chief-create-click-proof.py diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/DebugPages.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/DebugPages.swift index 8b88ec968ac4..807b63811a9f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/DebugPages.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/DebugPages.swift @@ -15,7 +15,8 @@ import Foundation // html/body backgrounds (the one-backdrop check), `painted` (the document's first frame, with // `painted_uptime` in host systemUptime seconds and `painted_ms` on the page clock), `instance`, // `parked`, `window_number` and `claim` (how a pooled host took its last claim: `path` -// acknowledged / refused / timedOut / loaded, `ms`); +// acknowledged / refused / timedOut / loaded, `ms`) and `in_flight` (the page's calls still +// running: id, op, age_ms); // - `snapshot` (`path`, default /tmp/cmux-page-.png): the page as WebKit rendered it; // - `command` (`command`, `text`): a dispatcher command (`find`, `focusSearch`, `back`, `forward`, // `reset`) on the page's command stream, as the key dispatcher sends it; @@ -64,6 +65,7 @@ enum DebugPages { if case .object(var members) = state { members.merge(identity(page)) { _, new in new } members["subscriptions"] = .number(Double(page.router.subscriptionCount)) + members["in_flight"] = .array(page.router.inFlightCalls()) // The first frame of this document (preflights wait on it with a deadline). members["painted"] = .bool(page.hasPainted) members["painted_uptime"] = page.paintedUptime.map { .number($0) } ?? .null diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift index b5f9fce04d34..5ccf0be70264 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift @@ -1,5 +1,6 @@ public import CmuxNextDaemon public import Foundation +import os /// The home-channels page's Chief profile calls (`cmux.chief.profile.*`, hq-ec's New Chief, edit /// and archive sheets; chief-profile-ws-v1) go to this build's Chief OWNER, where the Chiefs live, @@ -39,6 +40,7 @@ public final class ChiefProfilePageRelay { private weak var owner: ChiefConversationOwner? private let identity = PageRelayIdentity(page: "home-channels") private let wake = RetryWake(owner: "ChiefProfilePageRelay") + private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "chief-profile-relay") private var relay: (connection: DaemonConnection, socket: String)? private var starting: (task: Task, socket: String)? /// The event uptime of the last gesture a call spent. @@ -62,16 +64,28 @@ public final class ChiefProfilePageRelay { public func send(operation: String, params: [String: JSONValue], idempotencyKey: String?, gestureEvent: TimeInterval?) async throws -> JSONValue { guard Self.operations.contains(operation) else { throw DaemonError.command(cmd: operation, message: "not a Chief profile operation", code: "unknown_op") } - guard let owner, let main = owner.connection, let socket = await owner.socketPath() else { throw DaemonError.notConnected } - let relay = try await relayConnection(socket: socket) - var origin = JSONValue.object(["claim": .string("page")]) - if Self.needsGesture(operation, params: params), let event = gestureEvent, event != spentGesture { - spentGesture = event - let token = try await confirmationToken(main: main, operation: operation, params: params) - origin = .object(["claim": .string("user"), "confirmation": .string(token)]) + let started = ContinuousClock.now + let ms = { Int((ContinuousClock.now - started) / .milliseconds(1)) } + logger.info("\(operation, privacy: .public): start gesture=\(gestureEvent != nil)") + do { + guard let owner, let main = owner.connection, let socket = await owner.socketPath() else { throw DaemonError.notConnected } + let relay = try await relayConnection(socket: socket) + logger.info("\(operation, privacy: .public): relay ready at \(ms()) ms") + var origin = JSONValue.object(["claim": .string("page")]) + if Self.needsGesture(operation, params: params), let event = gestureEvent, event != spentGesture { + spentGesture = event + let token = try await confirmationToken(main: main, operation: operation, params: params) + logger.info("\(operation, privacy: .public): confirmation at \(ms()) ms") + origin = .object(["claim": .string("user"), "confirmation": .string(token)]) + } + let result = try await ResourceRelayClient(connection: relay).send( + operation: operation, params: params, idempotencyKey: idempotencyKey, origin: origin) + logger.info("\(operation, privacy: .public): answered at \(ms()) ms") + return result + } catch { + logger.info("\(operation, privacy: .public): failed at \(ms()) ms: \(String(describing: error), privacy: .public)") + throw error } - return try await ResourceRelayClient(connection: relay).send( - operation: operation, params: params, idempotencyKey: idempotencyKey, origin: origin) } /// The page relay connection to the owner at `socket`; a new one when the owner moved. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageRouter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageRouter.swift index f4220622785f..0e3bb242eb4d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageRouter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageRouter.swift @@ -75,7 +75,7 @@ public final class PageRouter { opid = text } let params = message["params"] ?? .object([:]) - let value = try await cancellable(id: id) { [self] in try await call(op, params: params, opid: opid) } + let value = try await cancellable(id: id, op: op) { [self] in try await call(op, params: params, opid: opid) } return ["t": "ok", "id": .number(Double(id)), "value": value] } catch let error as PageError { return Self.error(id: id, error) @@ -138,8 +138,8 @@ public final class PageRouter { /// Runs `work` as page call `id`, answering `cmux.op.cancelled` at once when the call is /// cancelled; the work's task is cancelled too, so the owner stops (a relay cancels its op). - private func cancellable(id: UInt64, _ work: @escaping @MainActor () async throws -> JSONValue) async throws -> JSONValue { - let call = PageCallInFlight() + private func cancellable(id: UInt64, op: String, _ work: @escaping @MainActor () async throws -> JSONValue) async throws -> JSONValue { + let call = PageCallInFlight(op: op) inFlight[id] = call defer { if inFlight[id] === call { inFlight[id] = nil } } return try await withCheckedThrowingContinuation { continuation in @@ -289,6 +289,13 @@ public final class PageRouter { public var subscriptionCount: Int { subscriptions.count } + /// The page calls still running (`debug.page` state): page call id, op and age in ms, oldest first. + public func inFlightCalls(now: TimeInterval = ProcessInfo.processInfo.systemUptime) -> [JSONValue] { + inFlight.sorted { $0.key < $1.key }.map { id, call in + ["id": .number(Double(id)), "op": .string(call.op), "age_ms": .number(((now - call.started) * 1000).rounded())] + } + } + static func error(id: UInt64, _ error: PageError) -> JSONValue { var envelope: [String: JSONValue] = [ "t": "err", "id": .number(Double(id)), "code": .string(error.code), "message": .string(error.message), @@ -303,10 +310,17 @@ public final class PageRouter { /// the work's end and a cancel answers it (once); a cancel also cancels the task. @MainActor final class PageCallInFlight { + let op: String + /// When the call arrived (`systemUptime`). + let started = ProcessInfo.processInfo.systemUptime private var continuation: CheckedContinuation? private var task: Task? private var cancelled = false + init(op: String) { + self.op = op + } + func start(_ continuation: CheckedContinuation, _ work: @escaping @MainActor () async throws -> JSONValue) { self.continuation = continuation guard !cancelled else { return finish(.failure(PageError.opCancelled)) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageWebView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageWebView.swift index ff0cfd94ed7d..1c7266f374fe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageWebView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageWebView.swift @@ -346,7 +346,10 @@ public final class PageWebView: NSView, PageSurface, WKNavigationDelegate { // The message conversion stays here: `handle` must start in the order the page posted, and // `message.body` is a non-Sendable WebKit object graph (page messages are small calls). guard let body = JSONValue(foundation: message.body) else { return nil } + let started = ProcessInfo.processInfo.systemUptime let reply = await router.handle(body) + let handled = ProcessInfo.processInfo.systemUptime + defer { Self.noteSlowReply(body, page: descriptor.id, started: started, handled: handled, logger: logger) } // The reply reaches the page after the events sent before it and before the events sent // after it (a subscribe's reply names the sub its first events use). A gate holds the // script FIFO here while those earlier events drain and a large reply converts off main. @@ -359,6 +362,16 @@ public final class PageWebView: NSView, PageSurface, WKNavigationDelegate { return await PageReplyObject.convert(reply).object } + /// A page message whose reply took over a second: its op, the router's time and the time the + /// reply then waited for the page's script FIFO (a reply the page never gets looks the same + /// from the page as a call the host never answered). + private static func noteSlowReply(_ body: JSONValue, page: String, started: TimeInterval, handled: TimeInterval, logger: Logger) { + let done = ProcessInfo.processInfo.systemUptime + guard done - started > 1 else { return } + let op = body["op"]?.stringValue ?? body["t"]?.stringValue ?? "?" + logger.info("page \(page, privacy: .public) \(op, privacy: .public): handled in \(Int((handled - started) * 1000)) ms, replied after \(Int((done - started) * 1000)) ms") + } + // MARK: Theme // The page's colors follow this view's theme scope (room, workspace), resolved in the hooks diff --git a/scripts/cmux-next/chief-create-click-proof.py b/scripts/cmux-next/chief-create-click-proof.py new file mode 100644 index 000000000000..ea6982538ab9 --- /dev/null +++ b/scripts/cmux-next/chief-create-click-proof.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""hq-6d P1 owner install key (cx-ebm.59.5) live proof on cmux-lawrence-2 (nx-remote --needs input --app-from-fleet, +own tag, own socket, isolated HOME and a per-build Chief home under /tmp via CMUX_NEXT_CHIEF_ROOT; run under `zsh -lic`). +Built from s7-proof-v3.py. + +0. Before the app runs, `cmux chief list --chief-home ` starts the Chief owner WITHOUT an install key + (as an older build or the CLI did). The owner does not verify the DEV app. +1. The app launches (unsigned DEV): its owner connection is not verified and the owner is idle, so the app restarts the + owner once with the install key (debug.home chief_owner.daemon_pid changes, connected again). +2. Page Chief create WITHOUT a gesture: refused (gesture_required). +3. New Chief through the page with a REAL pointer click on Create Chief: the Chief is created on the owner (chief list), + the page shows no "needs a verified cmux app connection". +Ends with quitEndSessions, endAgents, acpmux daemon shutdown + router shutdown, chief shutdown. +Reads NX_APP, NX_APP_TAG, NX_APP_STOP, NX_APP_CLI, NX_CAPTURE, NX_ARTIFACTS.""" +import json, os, shutil, socket, subprocess, sys, tempfile, time + +APP, TAG, OUT = os.environ["NX_APP"], os.environ["NX_APP_TAG"], os.environ["NX_ARTIFACTS"] +CAPTURE, STOP, CLI = os.environ["NX_CAPTURE"], os.environ["NX_APP_STOP"], os.environ["NX_APP_CLI"] +ACPMUX = os.path.join(APP, "Contents/Resources/bin/acpmux") +SOCK = "/tmp/cmux-debug-%s.sock" % TAG +REPLY_WAIT = float(os.environ.get("S7_REPLY_WAIT", "300")) +os.makedirs(OUT, exist_ok=True) +LOG = open(os.path.join(OUT, "transcript.jsonl"), "w") +R = {"tag": TAG, "checks": {}} +# Short paths: the brain's sockets must fit sun_path. +SCRATCH = tempfile.mkdtemp(prefix="h6s7-", dir="/tmp") +HOME = os.path.join(SCRATCH, "home") +CHIEF_ROOT = os.path.join(SCRATCH, "croot") + + +def chief_component(raw): + out = "" + for ch in raw: + if ch.isascii() and (ch.isalnum() or ch in "._"): + out += ch + elif not out.endswith("-"): + out += "-" + return out.strip("-.") + + +import plistlib +BUNDLE_ID = plistlib.load(open(os.path.join(APP, "Contents/Info.plist"), "rb"))["CFBundleIdentifier"] +# ChiefHome rule 2 (CMUX_NEXT_CHIEF_ROOT): this build's own home, /, buildID = bundle id. +CHIEF = os.path.join(CHIEF_ROOT, chief_component(BUNDLE_ID)) + + +def owner_session(root): + h = 0x811c9dc5 + for b in root.encode(): + h ^= b + h = (h * 0x01000193) & 0xffffffff + return "cmux-chief-%08x" % h +MARK = "hq6d-s7-%s-%d" % (TAG.replace("-", ""), int(time.time()) % 100000) +LINK = "https://example.com/?hq6d=%s" % MARK +BROWSE = "https://example.org/" + + +def say(*parts): + print(*parts, flush=True) + + +def rpc(method, params=None, timeout=60): + t0 = time.time() + try: + c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); c.settimeout(timeout); c.connect(SOCK) + c.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + ch = c.recv(1 << 22) + if not ch: + break + buf += ch + c.close(); r = json.loads(buf) + except (OSError, ValueError) as e: + r = {"ok": False, "error": str(e)} + LOG.write(json.dumps({"t": round(t0, 3), "ms": int((time.time() - t0) * 1000), "method": method, + "params": params, "reply": r})[:200000] + "\n"); LOG.flush() + return r + + +def res(r): + return r.get("result") if isinstance(r, dict) and "result" in r else r + + +def until(fn, seconds, step=0.5): + end = time.time() + seconds + value = fn() + while not value and time.time() < end: # proof harness poll, bounded + time.sleep(step) + value = fn() + return value + + +def check(name, ok, detail): + R["checks"][name] = {"pass": bool(ok), "detail": detail} + say(("PASS " if ok else "FAIL ") + name + ": " + json.dumps(detail)[:800]) + return ok + + +def shot(name, pid): + path = os.path.join(OUT, name + ".png") + p = subprocess.run(["bash", CAPTURE, "shot", "--pid", str(pid), "--out", path], capture_output=True, text=True, timeout=60) + R.setdefault("shots", {})[name] = {"exit": p.returncode, "out": (p.stdout + p.stderr)[-400:]} + # The app's own 2x window snapshot (Retina scale); terminal Metal content shows in the helper shot. + R["shots"][name + "-2x"] = rpc("debug.window_snapshot", {"kind": "main", "path": os.path.join(OUT, name + "-2x.png")}, 60) + + +def env_base(): + keep = ("USER", "LOGNAME", "TMPDIR", "SHELL", "LANG", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CONFIG_DIR") + env = {k: v for k, v in os.environ.items() if k in keep} + env.update({"HOME": HOME, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}) + return env + + +def tui(*args, timeout=60, extra=None): + p = subprocess.run([CLI, "--session", "cmux-app-" + TAG, *args], capture_output=True, text=True, timeout=timeout, + env={**env_base(), **(extra or {})}) + R.setdefault("cli", []).append({"args": args, "exit": p.returncode, "out": (p.stdout + p.stderr)[-3000:]}) + return p + + +def tui_json(*args): + p = tui("--json", *args) + try: + return json.loads(p.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError): + return {} + + +def chief(text): + """One Chief message through `cmux chief -p` (the same conversation Home shows); its reply.""" + p = subprocess.run([CLI, "chief", "--chief-home", CHIEF, "--timeout", str(int(REPLY_WAIT)), "-p", text], + capture_output=True, text=True, timeout=REPLY_WAIT + 60, env={**env_base(), "NO_COLOR": "1"}) + R.setdefault("chief", []).append({"prompt": text, "exit": p.returncode, "out": p.stdout[-4000:], "err": p.stderr[-2000:]}) + return p.stdout + + +def panes(): + windows = (res(rpc("debug.pane_chrome")) or {}).get("windows") or [] + return windows[0] if windows else {} + + +def columns(): + rows = sorted(((p.get("cell") or [0, 0, 0, 0], p) for p in panes().get("panes") or []), key=lambda c: c[0][0]) + return rows + + +def page_state(): + return res(rpc("debug.page", {"page": "cmux.home-channels", "action": "state"}, 30)) or {} + + +def home_snapshot(): + """Workspaces, tabs and terminals of the tag's session through the public CLI (one object).""" + return {"workspaces": tui_json("workspace", "list"), "tabs": tui_json("tab", "list"), "terminals": tui_json("terminal", "list"), + "browsers": tui_json("browser", "list"), "debug_browsers": res(rpc("debug.browser.page_status")) or {}} + + +def find(obj, pred, out=None): + out = [] if out is None else out + if isinstance(obj, dict): + if pred(obj): + out.append(obj) + for v in obj.values(): + find(v, pred, out) + elif isinstance(obj, list): + for v in obj: + find(v, pred, out) + return out + + +CUA = os.path.expanduser("~/.local/bin/cua-driver") + + +def cua(tool, args): + p = subprocess.run([CUA, "call", tool, json.dumps(args)], capture_output=True, text=True, timeout=90) + R.setdefault("cua", []).append({"tool": tool, "exit": p.returncode, "out": (p.stdout + p.stderr)[:6000]}) + try: + return json.loads(p.stdout) + except ValueError: + return {} + + +def post_click(x, y): + """One real left click at screen point (x, y) (top-left origin): CGEvents at the HID tap, the + window server's own pointer path (ctypes CoreGraphics; the SSH session has the Accessibility grant).""" + import ctypes, ctypes.util + cg = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreGraphics")) + + class P(ctypes.Structure): + _fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)] + cg.CGEventCreateMouseEvent.restype = ctypes.c_void_p + cg.CGEventCreateMouseEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, P, ctypes.c_uint32] + cg.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p] + cg.CFRelease.argtypes = [ctypes.c_void_p] + for kind in (5, 1, 2): # mouseMoved, leftMouseDown, leftMouseUp + e = cg.CGEventCreateMouseEvent(None, kind, P(x, y), 0) + cg.CGEventPost(0, e) + cg.CFRelease(e) + time.sleep(0.08) # human-like press interval for the window server, not app synchronization + + +def post_key(code): + """One real key press (virtual key `code`) at the HID tap: the frontmost window's first responder gets it.""" + import ctypes, ctypes.util + cg = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreGraphics")) + cg.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p + cg.CGEventCreateKeyboardEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint16, ctypes.c_bool] + cg.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p] + cg.CFRelease.argtypes = [ctypes.c_void_p] + for down in (True, False): + e = cg.CGEventCreateKeyboardEvent(None, code, down) + cg.CGEventPost(0, e) + cg.CFRelease(e) + time.sleep(0.05) # human-like press interval for the window server, not app synchronization + + +def links_state(): + """The Chief home's app links (ChiefAppLinks): where they point and whether each is a socket now.""" + import stat + out = {} + for name in ("app.sock", "app-daemon.sock"): + path = os.path.join(CHIEF, "state", name) + try: + target = os.readlink(path) + except OSError as e: + out[name] = {"error": str(e)} + continue + try: + is_sock = stat.S_ISSOCK(os.stat(path).st_mode) + except OSError as e: + is_sock = "dangling: %s" % e + out[name] = {"target": target, "socket": is_sock} + return out + + +def chief_list(): + p = subprocess.run([CLI, "chief", "list", "--chief-home", CHIEF, "--json"], capture_output=True, text=True, + timeout=60, env=env_base()) + R.setdefault("chief_list", []).append({"exit": p.returncode, "out": p.stdout[-3000:], "err": p.stderr[-800:]}) + return p.stdout + + +def find_elements(obj, out): + if isinstance(obj, dict): + if "frame" in obj and ("role" in obj or "label" in obj): + out.append(obj) + for v in obj.values(): + find_elements(v, out) + elif isinstance(obj, list): + for v in obj: + find_elements(v, out) + return out + + +def real_click_link(pid, mark, text, role="link"): + """Finds the link (AXLink whose label or value names `text`/`mark`) in the app's accessibility + tree and clicks its center with a real pointer event. Returns what it did.""" + wins = cua("list_windows", {"pid": pid}) + rows = find(wins, lambda o: isinstance(o, dict) and "window_id" in o and o.get("pid") == pid) + rows = [w for w in rows if (w.get("bounds") or {}).get("width", 0) > 600] + if not rows: + return {"error": "no window", "windows": str(wins)[:800]} + win = max(rows, key=lambda w: (w.get("z_index") or 0)) + # The input job has the GUI session alone: the window comes forward so the click is not a first-mouse activation. + R["front"] = cua("bring_to_front", {"pid": pid, "window_id": win["window_id"]}) + # Web content sits deep in the AX tree (WKWebView): past the default depth 25. + state = cua("get_window_state", {"pid": pid, "window_id": win["window_id"], "include_screenshot": False, "query": text, + "max_depth": 80, "max_elements": 8000}) + named = lambda e: text in str(e.get("label", "")) + str(e.get("value", "")) or mark in json.dumps(e) + els = [e for e in find_elements(state, []) if role in str(e.get("role", "")).lower() and named(e)] + if not els: + # The query projection can miss web content: walk the whole tree once. + full = cua("get_window_state", {"pid": pid, "window_id": win["window_id"], "include_screenshot": False, + "max_depth": 80, "max_elements": 8000}) + R.setdefault("ax_full_counts", []).append({k: full.get(k) for k in ("element_count", "returned_element_count", + "total_element_count", "elements_complete")}) + state = full + els = [e for e in find_elements(state, []) if role in str(e.get("role", "")).lower() and named(e)] + if not els and role == "link": + # WebKit exposes an anchor's text as an AXStaticText child; a click on that text is a click on the link. + els = [e for e in find_elements(state, []) if "statictext" in str(e.get("role", "")).lower() + and (str(e.get("label", "")).strip() == text or str(e.get("value", "")).strip() == text)] + if not els: + return {"error": "no %s element" % role, "state": str(state)[:1500]} + f = els[-1]["frame"] + b = win["bounds"] + x, y = f["x"] + f["w"] / 2, f["y"] + f["h"] / 2 + # AX frames are screen points; a window-local frame lies inside (0..width). + if not (b["x"] <= x <= b["x"] + b["width"] and b["y"] <= y <= b["y"] + b["height"]): + x, y = x + b["x"], y + b["y"] + post_click(x, y) + return {"window": win["window_id"], "bounds": b, "frame": f, "point": [x, y], "role": els[-1].get("role"), "label": els[-1].get("label")} + + +def shutdown_all(app): + R["quit"] = rpc("action.run", {"id": "quitEndSessions"}, 10) + try: + app.wait(timeout=60) + except subprocess.TimeoutExpired: + say("app pid %d did not quit within 60 s" % app.pid) + R["end_agents"] = rpc("action.run", {"id": "endAgents"}, 10) + env = {k: v for k, v in env_base().items()} + for acp in (os.path.join(CHIEF, "acpmux"), os.path.join(HOME, ".acpmux")): + if os.path.isdir(acp): + R.setdefault("acpmux_shutdown", []).append(subprocess.run( + [ACPMUX, "daemon", "shutdown"], env={**env, "ACPMUX_HOME": acp}, capture_output=True, text=True, timeout=30).returncode) + try: + c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); c.settimeout(5) + c.connect(os.path.join(acp, "router", "router.sock")); c.sendall(b'{"op":"shutdown"}\n'); c.recv(4096); c.close() + except OSError: + pass + R["chief_shutdown"] = subprocess.run([CLI, "chief", "shutdown", "--chief-home", CHIEF], env=env, + capture_output=True, text=True, timeout=90).returncode + R["stop"] = subprocess.run([STOP], capture_output=True, text=True, timeout=180).returncode + + +os.makedirs(os.path.join(CHIEF, "optchat"), exist_ok=True) +os.makedirs(HOME, exist_ok=True) +open(os.path.join(CHIEF, "optchat", "settings.json"), "w").write('{"backup": {"remote": false}}\n') +open(os.path.join(SCRATCH, "cmux.json"), "w").write("{}") +open(os.path.join(SCRATCH, "ghostty"), "w").write("") +R["bundle_id"], R["chief_home"], R["owner_session"] = BUNDLE_ID, CHIEF, owner_session(CHIEF) + + +# The app's owner runtime base is the per-user temp dir (DaemonLauncher.userTemporaryDirectory), not the job's TMPDIR. +USER_TMP = subprocess.run(["/usr/bin/getconf", "DARWIN_USER_TEMP_DIR"], capture_output=True, text=True).stdout.strip() +OWNER_ENV = {"CMUX_TUI_STATE_DIR": os.path.join(CHIEF, "tui"), "TMPDIR": USER_TMP} +R["user_tmp"] = USER_TMP + + +def owner_status(): + p = subprocess.run([CLI, "--session", owner_session(CHIEF), "--json", "server", "status"], capture_output=True, text=True, + timeout=60, env={**env_base(), **OWNER_ENV}) + R.setdefault("owner_status", []).append({"exit": p.returncode, "out": p.stdout[-800:], "err": p.stderr[-400:]}) + try: + return json.loads(p.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError): + return {} + + +# 0. An owner of this build's Chief home started WITHOUT an install key (as an older build or the CLI did): +# `server ensure` for the home's owner session and state directory, no --install-key-stdin. +pre = subprocess.run([CLI, "--session", owner_session(CHIEF), "--json", "server", "ensure"], capture_output=True, text=True, + timeout=120, env={**env_base(), **OWNER_ENV}, stdin=subprocess.DEVNULL) +R["cli_start"] = {"exit": pre.returncode, "out": pre.stdout[-800:], "err": pre.stderr[-400:]} +before = owner_status() +pre_pid = before.get("pid") +R["key_file_before"] = os.path.exists(os.path.join(CHIEF, "tui", "frontend-install-key")) +check("keyless_owner_running_before_app", before.get("status") == "running" and bool(pre_pid), + {"status": before, "cli_err": (R.get("chief_list") or [{}])[-1].get("err", "")[-300:]}) + +env = env_base() +env.update({"CMUX_NEXT_NO_ACTIVATE": "1", "CMUX_NEXT_SOCKET_MODE": "automation", "CMUX_NEXT_TEST_WINDOW_SCREEN": "last", + "CMUX_NEXT_CONFIG_FILE": os.path.join(SCRATCH, "cmux.json"), "CMUX_NEXT_GHOSTTY_CONFIG": os.path.join(SCRATCH, "ghostty"), + "CMUX_NEXT_TEST_WINDOW_FRAME": "40,40,1600,980", "CMUX_NEXT_ONBOARDING_COMPUTER_USE": "mock", + "CMUX_NEXT_CHIEF_ROOT": CHIEF_ROOT}) +app = subprocess.Popen([os.path.join(APP, "Contents/MacOS/cmux DEV")], env=env, stdin=subprocess.DEVNULL, + stdout=open(os.path.join(OUT, "app.log"), "a"), stderr=subprocess.STDOUT) +try: + until(lambda: os.path.exists(SOCK) and (res(rpc("debug.windows")) or {}).get("windows"), 240) + R["home_show"] = rpc("action.run", {"id": "home.show", "focus": True, "wait": True}, 30) + + # 1. The app restarted the keyless owner once, with its key. + def owner(): + o = (res(rpc("debug.home", {}, 30)) or {}).get("chief_owner") or {} + return o if o.get("connected") and o.get("daemon_pid") and o.get("daemon_pid") != pre_pid else None + o = until(owner, 120) or (res(rpc("debug.home", {}, 30)) or {}).get("chief_owner") + after = owner_status() + R["key_file_mode"] = oct(os.stat(os.path.join(CHIEF, "tui", "frontend-install-key")).st_mode & 0o777) \ + if os.path.exists(os.path.join(CHIEF, "tui", "frontend-install-key")) else None + check("app_restarted_keyless_owner_with_key", bool(o) and o.get("home") == CHIEF and after.get("pid") == o.get("daemon_pid"), + {"pre_pid": pre_pid, "owner": o, "status_after": after, "key_file_mode": R["key_file_mode"]}) + + page = until(lambda: (lambda s: s if s.get("painted") else None)(page_state()), 120) or page_state() + R["page_home"] = {k: page.get(k) for k in ("painted", "window_number", "instance", "url", "text")} + until(lambda: None, 3) # proof pacing: the page draws its list before the shot + shot("1-home", app.pid) + + R["host_list"] = rpc("debug.page", {"page": "cmux.home-channels", "action": "call", "op": "cmux.chief.profile.list", + "params": {"machine": "current", "session": "current"}}, 30) + # 2. A Chief create from the page WITHOUT a gesture is refused. + nog = rpc("debug.page", {"page": "cmux.home-channels", "action": "call", "op": "cmux.chief.profile.create", + "params": {"machine": "current", "session": "current", "display_name": "No Gesture %s" % MARK[-5:], + "role": "", "personality": "", "instructions": "", "idempotency_key": "nog-" + MARK}}, 30) + R["no_gesture_create"] = nog + text = json.dumps(nog) + check("profile_create_without_gesture_refused", "gesture_required" in text and "No Gesture" not in chief_list(), + {"reply": text[:600]}) + + # 3. New Chief through the page with the person's real click. + name = "Proof Chief %s" % MARK[-5:] + R["new_chief_action"] = rpc("action.run", {"id": "home.newChief", "focus": True, "wait": True}, 30) + until(lambda: (lambda s: s if "Create Chief" in json.dumps(s) else None)(page_state()), 30) + R["type_name"] = rpc("debug.page", {"page": "cmux.home-channels", "action": "type", "selector": ".lg-chief-names input", + "text": name}, 30) + until(lambda: None, 1) # proof pacing: React applies the typed name before the click + # A real click into the Name field first (the window gets real input before the gesture click, as in v3). + R["name_click"] = real_click_link(app.pid, name, name, role="textfield") + until(lambda: None, 1) # proof pacing: the first click's activation settles before the gesture click + R["create_click"] = real_click_link(app.pid, "Create Chief", "Create Chief", role="button") + # What the host holds right after the click: the page's calls still running (id, op, age). + R["in_flight_after_click"] = [(lambda s: {"t": round(time.time(), 3), "in_flight": s.get("in_flight"), "hash": s.get("hash")})(page_state()) + for _ in range(1) ] + made = until(lambda: name in chief_list(), 30) + R["in_flight_after_wait"] = (lambda s: {"in_flight": s.get("in_flight"), "hash": s.get("hash")})(page_state()) + R["host_list_after_click"] = rpc("debug.page", {"page": "cmux.home-channels", "action": "call", "op": "cmux.chief.profile.list", + "params": {"machine": "current", "session": "current"}}, 30) + R["hangs"] = res(rpc("debug.hangs", {}, 30)) + R["debug_home_after_click"] = res(rpc("debug.home", {}, 30)) + until(lambda: None, 3) # proof pacing: the page shows the new Chief before the shot + after_page = page_state() + R["page_after_create"] = json.dumps(after_page)[-3000:] + shot("2-new-chief", app.pid) + refused_text = "needs a verified cmux app connection" + check("new_chief_created_owner_did_not_refuse", bool(made) and refused_text not in json.dumps(after_page) + and name in json.dumps(after_page), + {"click": R["create_click"], "list": (R.get("chief_list") or [{}])[-1], "page_tail": json.dumps(after_page)[-600:]}) +finally: + app_pid = app.pid + shutdown_all(app) + # The app's own unified log lines (its Logger messages; no key is ever logged). + lg = subprocess.run(["/usr/bin/log", "show", "--info", "--debug", "--last", "20m", "--style", "compact", "--predicate", + "processID == %d" % app_pid], capture_output=True, text=True, timeout=180) + open(os.path.join(OUT, "app-unified.log"), "w").write(lg.stdout[-400000:]) + # The app's own lines only (the WebKit lines push them out of the tail above). + lg = subprocess.run(["/usr/bin/log", "show", "--info", "--debug", "--last", "20m", "--style", "compact", "--predicate", + 'processID == %d AND subsystem == "com.cmuxterm.app.next"' % app_pid], capture_output=True, text=True, timeout=180) + open(os.path.join(OUT, "app-cmux.log"), "w").write(lg.stdout[-400000:]) + json.dump(R, open(os.path.join(OUT, "result.json"), "w"), indent=1, default=str) + for log in ("host.log",): + try: + shutil.copy(os.path.join(CHIEF, log), os.path.join(OUT, "chief-" + log)) + except OSError: + pass + shutil.rmtree(SCRATCH, ignore_errors=True) +ok = all(c["pass"] for c in R["checks"].values()) +say("RESULT", "PASS" if ok else "FAIL") +sys.exit(0 if ok else 1) From 03fa34c098156209659ad92d5f871afcad900fbc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:30:36 -0700 Subject: [PATCH 070/183] test(cmux-next): live click proof restarts the Chief owner with the page open; a relay call answers and a real click makes a Chief on the new owner (red, cx-ebm.59.5) --- scripts/cmux-next/chief-create-click-proof.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/scripts/cmux-next/chief-create-click-proof.py b/scripts/cmux-next/chief-create-click-proof.py index ea6982538ab9..a70c08e14efc 100644 --- a/scripts/cmux-next/chief-create-click-proof.py +++ b/scripts/cmux-next/chief-create-click-proof.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""hq-6d P1 owner install key (cx-ebm.59.5) live proof on cmux-lawrence-2 (nx-remote --needs input --app-from-fleet, +"""Chief create by a real click, and the page relay across a Chief owner restart (cx-ebm.59.5). Live proof on cmux-lawrence-2 (nx-remote --needs input --app-from-fleet, own tag, own socket, isolated HOME and a per-build Chief home under /tmp via CMUX_NEXT_CHIEF_ROOT; run under `zsh -lic`). Built from s7-proof-v3.py. @@ -10,6 +10,8 @@ 2. Page Chief create WITHOUT a gesture: refused (gesture_required). 3. New Chief through the page with a REAL pointer click on Create Chief: the Chief is created on the owner (chief list), the page shows no "needs a verified cmux app connection". +4. The owner restarts while the page is open: a page relay call answers within the relay deadline, and a second real + click on Create Chief makes a Chief on the new owner (no page call left in flight). Ends with quitEndSessions, endAgents, acpmux daemon shutdown + router shutdown, chief shutdown. Reads NX_APP, NX_APP_TAG, NX_APP_STOP, NX_APP_CLI, NX_CAPTURE, NX_ARTIFACTS.""" import json, os, shutil, socket, subprocess, sys, tempfile, time @@ -415,6 +417,41 @@ def owner(): check("new_chief_created_owner_did_not_refuse", bool(made) and refused_text not in json.dumps(after_page) and name in json.dumps(after_page), {"click": R["create_click"], "list": (R.get("chief_list") or [{}])[-1], "page_tail": json.dumps(after_page)[-600:]}) + + # 4. The Chief owner restarts while the page is open (the app's main connection ensures a new owner process on + # the same socket). A page relay call then answers within the relay's deadline (10 s) plus margin, and a real + # click on Create Chief makes a Chief on the NEW owner: its gesture token is bound to a relay connection on the + # new owner, so a relay still on the old owner (or a token bound to another connection) cannot pass. + before_restart = owner_status() + R["owner_stop"] = subprocess.run([CLI, "--session", owner_session(CHIEF), "--json", "server", "stop", "--force"], + capture_output=True, text=True, timeout=60, env={**env_base(), **OWNER_ENV}).returncode + def new_owner(): + o = (res(rpc("debug.home", {}, 30)) or {}).get("chief_owner") or {} + return o if o.get("connected") and o.get("daemon_pid") and o.get("daemon_pid") != before_restart.get("pid") else None + restarted = until(new_owner, 120) + t0 = time.time() + relay_after = rpc("debug.page", {"page": "cmux.home-channels", "action": "call", "op": "cmux.chief.profile.list", + "params": {"machine": "current", "session": "current"}}, 30) + relay_ms = int((time.time() - t0) * 1000) + check("relay_call_answers_after_owner_restart", bool(restarted) and relay_ms <= 12000 + and (res(relay_after) or {}).get("t") == "ok", + {"old_pid": before_restart.get("pid"), "owner": restarted, "ms": relay_ms, "reply": json.dumps(relay_after)[:600]}) + name2 = "Second Chief %s" % MARK[-5:] + R["new_chief_action_2"] = rpc("action.run", {"id": "home.newChief", "focus": True, "wait": True}, 30) + until(lambda: (lambda s: s if "Create Chief" in json.dumps(s) else None)(page_state()), 30) + R["type_name_2"] = rpc("debug.page", {"page": "cmux.home-channels", "action": "type", "selector": ".lg-chief-names input", + "text": name2}, 30) + until(lambda: None, 1) # proof pacing: React applies the typed name before the click + R["name_click_2"] = real_click_link(app.pid, name2, name2, role="textfield") + until(lambda: None, 1) # proof pacing: the first click's activation settles before the gesture click + R["create_click_2"] = real_click_link(app.pid, "Create Chief", "Create Chief", role="button") + made2 = until(lambda: name2 in chief_list(), 30) + after2 = page_state() + R["in_flight_after_restart_click"] = after2.get("in_flight") + shot("3-after-owner-restart", app.pid) + check("new_chief_created_on_restarted_owner", bool(made2) and not after2.get("in_flight"), + {"click": R["create_click_2"], "list": (R.get("chief_list") or [{}])[-1], "in_flight": after2.get("in_flight"), + "hash": after2.get("hash"), "page_tail": json.dumps(after2)[-600:]}) finally: app_pid = app.pid shutdown_all(app) From d8c1fa300f9b48183c0894aa044d8f63e66fd865 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:44:49 -0700 Subject: [PATCH 071/183] cmux-next motion: hq-4e review fixes on the merged head (newTab exports true, gesture-safe grow, NaN-safe springs, clock re-arm) 1. Exports regenerated for ui.animations.newTab default true on the merged head (settings schema, docs/mdm json/plist/md, schemas/defaults/cmux-next.json, settings page strings; action surfaces/actions.md were stale on the tip too). 2. TabStripView+Sync: added/closed cells never animate during a tab or group drag or a drop settling, and only a sync that adds a tab grows it in, so a reorder or drop no longer re-tokens the strip to tabOpen. 3. TabStripView+Layout: x and width leave tabOpen separately once each settles (the new tab's width kept tabOpen forever). 4. cmux-motion: SpringValue::step refuses a NaN dt; solve replaces a non-finite response/damping with move's (finite_or), so a NaN never makes an undamped spring that keeps the clock awake. The Swift solver copy takes the same guard. 5. MotionClock: an animator that calls run(self) during its own step stays on the clock even when that step reports rest, so its new targets animate. 6. Stale "all off by default" comments (cmux_motion_ffi.h, cmux-motion lib.rs, tokens.rs): newTab is on by default. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxNextDesign/Motion/MotionClock.swift | 15 +- .../Motion/SpringParameters.swift | 10 +- .../CmuxNextTabs/TabStripView+Layout.swift | 3 +- .../CmuxNextTabs/TabStripView+Sync.swift | 5 +- .../cmux-app-ffi/include/cmux_motion_ffi.h | 2 +- cmux-tui/crates/cmux-motion/src/lib.rs | 2 +- cmux-tui/crates/cmux-motion/src/spring.rs | 22 +- cmux-tui/crates/cmux-motion/src/tokens.rs | 4 +- docs/mdm/com.manaflow.cmux.json | 5 +- docs/mdm/com.manaflow.cmux.plist | 5 +- docs/mdm/managed-preferences.md | 4 +- plans/cmux-next/action-surfaces.json | 383 +++++++++++++++++- plans/cmux-next/actions.md | 28 +- schemas/defaults/cmux-next.json | 2 +- schemas/settings/settings-schema.json | 26 +- .../src/pages/settings/generated/strings.json | 42 +- 16 files changed, 497 insertions(+), 61 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift index c5919ca91429..9dad465b84ba 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift @@ -32,6 +32,10 @@ public final class MotionClock { } /// The window whose scheduler the clock ticks on (nil: the app clock). private weak var window: NSWindow? + /// The animator whose `motionStep` runs now, and whether it called `run` on itself + /// meanwhile (new targets): it stays on the clock even when that step reports rest. + private var steppingID: ObjectIdentifier? + private var steppingRearmed = false /// Frames delivered while something moved (debug). public private(set) var frames: UInt64 = 0 @@ -64,7 +68,10 @@ public final class MotionClock { entries.removeAll() order.removeAll() } - guard entries[id] == nil else { return } + guard entries[id] == nil else { + if id == steppingID { steppingRearmed = true } + return + } entries[id] = Entry(animator: animator, trace: trace) if !order.contains(id) { order.append(id) } MotionTrace.begin(trace, id: id) @@ -99,7 +106,11 @@ public final class MotionClock { // Animators started during this frame step from the next one. for id in order { guard let entry = entries[id] else { continue } - guard let animator = entry.animator, MotionTrace.measure(entry.trace, { animator.motionStep(dt) }) else { + (steppingID, steppingRearmed) = (id, false) + let moving = entry.animator.map { animator in MotionTrace.measure(entry.trace) { animator.motionStep(dt) } } + steppingID = nil + // Rest, unless the step re-armed itself (`run` during its own step): its new targets move next frame. + guard moving == true || (moving != nil && steppingRearmed) else { entries[id] = nil MotionTrace.end(entry.trace, id: id) continue diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift index 56c6a836b019..4780bef0bfaa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift @@ -107,7 +107,15 @@ public nonisolated struct SpringValue: Hashable, Sendable { /// Displacement from the target and velocity `time` seconds after a /// state, for `parameters` (mass 1). public static func solve(displacement x0: Double, velocity v0: Double, time t: Double, - parameters: SpringParameters) -> (displacement: Double, velocity: Double) { + parameters given: SpringParameters) -> (displacement: Double, velocity: Double) { + // A non-finite tuning takes `move`'s (cmux-motion `finite_or`): NaN damping would never settle. + var parameters = given + if !(given.response.isFinite && given.response > 0 && given.dampingFraction.isFinite) { + let fallback = MotionSpring.move.base + parameters = SpringParameters( + response: given.response.isFinite && given.response > 0 ? given.response : fallback.response, + dampingFraction: given.dampingFraction.isFinite ? given.dampingFraction : fallback.dampingFraction) + } let w0 = parameters.omega let zeta = max(parameters.dampingFraction, 0) if abs(zeta - 1) < 1e-4 { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift index 8f3b51422ecb..5a250149ec4d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift @@ -74,7 +74,8 @@ extension TabStripView { // A tab growing in and the tabs right of it making room move on one spring (`tabOpen`), // so the strip never overlaps; later moves go back to `move`. if grows, !added.contains(slot.id) { (m.x.token, m.width.token) = (.tabOpen, .tabOpen) } - else if !grows, m.x.token == .tabOpen, m.x.isSettled { (m.x.token, m.width.token) = (.move, .move) } + if !grows, m.x.token == .tabOpen, m.x.isSettled { m.x.token = .move } + if !grows, m.width.token == .tabOpen, m.width.isSettled { m.width.token = .move } if drag?.id != slot.id { m.x.target = groupDragX(for: slot) ?? slot.x } if groups.isDragged(slot.id) { m.x.snap() } m.width.target = slot.width diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift index 6cc608f2a38a..bc37ea01a636 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift @@ -53,7 +53,8 @@ extension TabStripView { let structureAnimates = !(adding || removing) || Motion.animates(.newTab) let inGesture = drag != nil || groups.drag != nil || pendingDrop != nil let animated = hasSynced && !reduceMotion && animating && (structureAnimates || inGesture) - let cellsAnimate = animated && structureAnimates + // Added/closed cells move only outside a gesture; a sync that adds no tab never re-tokens the strip. + let cellsAnimate = animated && structureAnimates && !inGesture if MotionTrace.isEnabled { MotionTrace.note("tabs.sync fromModel=\(fromModel) animating=\(animating) animated=\(animated)" + " added=\(ordered.filter { cells[$0.id] == nil }.map(\.id.rawValue)) removed=\(cells.keys.filter { !ids.contains($0) }.map(\.rawValue))") @@ -104,7 +105,7 @@ extension TabStripView { needsLayout = true } - relayout(animated: animated || (styleChanged && !reduceMotion), added: added, growsAdded: cellsAnimate) + relayout(animated: animated || (styleChanged && !reduceMotion), added: added, growsAdded: cellsAnimate && adding) let selected = model.selectedID if let selected, selected != lastSelectedID || added.contains(selected) { diff --git a/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h b/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h index 2c3ebc98a013..944d9892d397 100644 --- a/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h +++ b/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h @@ -37,7 +37,7 @@ bool cmux_motion_spring_base(uint8_t token, CmuxMotionSpring *out); bool cmux_motion_spring_is_structural(uint8_t token); CmuxMotionSpring cmux_motion_resolve(CmuxMotionSpring base, bool structural, CmuxMotionPolicy policy); bool cmux_motion_step(CmuxMotionState *state, double dt, CmuxMotionSpring spring, double epsilon); -/* kind: 0 paneAnimations, 1 newTab, 2 newWorkspace (all off by default). */ +/* kind: 0 paneAnimations, 1 newTab, 2 newWorkspace (newTab on by default, the others off). */ bool cmux_motion_animates(uint8_t kind, CmuxMotionPolicy policy); double cmux_motion_fade_seconds(uint8_t fade, CmuxMotionPolicy policy); diff --git a/cmux-tui/crates/cmux-motion/src/lib.rs b/cmux-tui/crates/cmux-motion/src/lib.rs index 68b9a639ca90..0d4446d056c2 100644 --- a/cmux-tui/crates/cmux-motion/src/lib.rs +++ b/cmux-tui/crates/cmux-motion/src/lib.rs @@ -28,7 +28,7 @@ //! Structural springs (`MotionSpring::is_structural`) follow //! `ui.animations.bounce`; `ui.paneAnimations`, `ui.animations.newTab` and //! `ui.animations.newWorkspace` turn one kind on (`MotionPolicy::animates`; -//! all off by default). The host passes these settings +//! newTab on by default, the others off). The host passes these settings //! in (`set_animation_switches` or `MotionPolicy::with_animations`). mod clock; diff --git a/cmux-tui/crates/cmux-motion/src/spring.rs b/cmux-tui/crates/cmux-motion/src/spring.rs index b4f325c08143..5c0b312763c3 100644 --- a/cmux-tui/crates/cmux-motion/src/spring.rs +++ b/cmux-tui/crates/cmux-motion/src/spring.rs @@ -48,6 +48,24 @@ impl SpringParameters { 2. * self.damping_fraction * self.omega() } + /// This tuning with a non-finite or non-positive response, or a + /// non-finite damping fraction, replaced by `fallback`'s. A NaN damping + /// would otherwise become 0 (an undamped spring that never settles, so + /// the clock never idles). + pub fn finite_or(self, fallback: SpringParameters) -> Self { + let response = if self.response.is_finite() && self.response > 0. { + self.response + } else { + fallback.response + }; + let damping_fraction = if self.damping_fraction.is_finite() { + self.damping_fraction + } else { + fallback.damping_fraction + }; + Self::new(response, damping_fraction) + } + /// The same spring with every time constant multiplied by `scale`. pub fn scaled(&self, scale: f64) -> Self { Self::new(self.response * scale, self.damping_fraction) @@ -185,7 +203,8 @@ impl SpringValue { /// do. `dt` is capped at 0.1 s so a stalled frame never teleports. pub fn step(&mut self, dt: f64, p: SpringParameters) { let t = dt.clamp(0., MAX_FRAME); - if t <= 0. { + // A NaN dt stays NaN through clamp: refuse it too. + if t.is_nan() || t <= 0. { return; } let target = f64::from(self.target); @@ -199,6 +218,7 @@ impl SpringValue { /// The one spring solution every port uses (macOS calls it through /// cmux-app-ffi). pub fn solve(x0: f64, v0: f64, time: f64, p: SpringParameters) -> (f64, f64) { + let p = p.finite_or(MotionSpring::Move.base()); let w0 = p.omega(); let zeta = p.damping_fraction.max(0.); let t = time; diff --git a/cmux-tui/crates/cmux-motion/src/tokens.rs b/cmux-tui/crates/cmux-motion/src/tokens.rs index 9df6ad45d23f..faf73601acae 100644 --- a/cmux-tui/crates/cmux-motion/src/tokens.rs +++ b/cmux-tui/crates/cmux-motion/src/tokens.rs @@ -278,8 +278,8 @@ impl MotionBounce { /// The structural changes a user turns animation on for one by one, under /// `ui.animationSpeed`, one per cmux.json key (`MotionKind::config_path`): /// `ui.paneAnimations`, `ui.animations.newTab`, `ui.animations.newWorkspace`. -/// All off by default (design-tokens.json `motion.kinds`): the change -/// applies in one frame. +/// newTab on by default, the others off (design-tokens.json `motion.kinds`); +/// an off kind applies the change in one frame. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[repr(u8)] pub enum MotionKind { diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index af58fb819651..f13f51d80603 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -2085,9 +2085,10 @@ }, "sidebar.density" : { "default" : "compact", - "description" : "Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer.", + "description" : "Comfortable rows keep one line on taller rows with more space around them. Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer.", "enum" : [ "compact", + "comfortable", "expanded" ], "title" : "Sidebar Density", @@ -2852,7 +2853,7 @@ "type" : "string" }, "ui.animations.newTab" : { - "default" : false, + "default" : true, "description" : "A new tab grows into its place in the tab bar. Off shows it there at once.", "title" : "Animate New Tabs", "type" : "boolean" diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index 6b86b4474d45..ee9dc0374827 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -1855,7 +1855,7 @@ pfm_default - + pfm_description A new tab grows into its place in the tab bar. Off shows it there at once. pfm_name @@ -2751,12 +2751,13 @@ pfm_default compact pfm_description - Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer. + Comfortable rows keep one line on taller rows with more space around them. Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer. pfm_name sidebar.density pfm_range_list compact + comfortable expanded pfm_title diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index 056fb998eb54..d9be4e464ec9 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -105,7 +105,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `ui.animationSpeed` | string | `"fast"` | `fast`, `normal`, `off` | Animations | | `appearance.chromeStyle` | string | `"default"` | `default`, `card`, `card-outlined`, `flat`, `flat-sidebar-line`, `bare`, `panes`, `two-tone` | Window Style. The look of the window: whether panes sit in one rounded card, and where lines are drawn. Separation, Sidebar Background and the sidebar border still win when you set them. | | `ui.paneAnimations` | boolean | `false` | | Animate Panes. Splits, closes, moves, zooms, column scrolls and screen switches slide into place. Off: they change at once. | -| `ui.animations.newTab` | boolean | `false` | | Animate New Tabs. A new tab grows into its place in the tab bar. Off shows it there at once. | +| `ui.animations.newTab` | boolean | `true` | | Animate New Tabs. A new tab grows into its place in the tab bar. Off shows it there at once. | | `ui.animations.newWorkspace` | boolean | `false` | | Animate New Workspaces. A new workspace row opens in the sidebar. Off shows it there at once. | | `ui.animations.bounce` | string | `"none"` | `none`, `subtle` | Animation Bounce. None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce. | | `layout.paneSeparation` | string | | `none`, `dividers`, `borders`, `cards` | Separation. How panes are told apart. None draws no border or divider at all; dragging between panes still resizes them. Default: dividers inside the curved main pane, borders otherwise. | @@ -181,7 +181,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `sidebar.showSpaces` | boolean | `true` | | Show Spaces | | `sidebar.showProfileControl` | boolean | `true` | | Show Profile Button | | `sidebar.minimal` | boolean | `false` | | Minimal Mode. Workspace rows show only their icon, name and unread mark, and section titles become thin lines. | -| `sidebar.density` | string | `"compact"` | `compact`, `expanded` | Sidebar Density. Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer. | +| `sidebar.density` | string | `"compact"` | `compact`, `comfortable`, `expanded` | Sidebar Density. Comfortable rows keep one line on taller rows with more space around them. Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer. | | `sidebar.minimalMode` | string | `"off"` | `off`, `bottom`, `top`, `both` | Hide Bands Until Hover. Hides the chosen sections until the pointer is over the sidebar. | | `sidebar.cards.tips` | boolean | `true` | | Show Tips. A "Did you know" card above the account button shows one cmux feature a day that you have not used yet. Each tip shows only once. | | `sidebar.side` | string | `"left"` | `left`, `right` | Sidebar Side. The window edge the sidebar sits on. On the right, the window buttons sit over the tab bar. | diff --git a/plans/cmux-next/action-surfaces.json b/plans/cmux-next/action-surfaces.json index f2c493bbe7dc..23e0e1cb44d7 100644 --- a/plans/cmux-next/action-surfaces.json +++ b/plans/cmux-next/action-surfaces.json @@ -18003,6 +18003,33 @@ "title_key" : "action.palette.addHarness", "title_table" : "ActionCatalog" }, + { + "cli" : "guiOnly", + "cli_name" : "action stats-show", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "stats.show", + "mcp" : "guiOnly", + "palette" : "offered", + "palette_section" : { + "id" : "category.agents", + "order" : 109, + "title" : "Agents", + "title_key" : "category.agents", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Show Agent Stats", + "title_key" : "action.stats.show", + "title_table" : "ActionCatalog" + }, { "cli" : "liveInput", "cli_name" : "agent toggle-dictation", @@ -19131,6 +19158,35 @@ "title_key" : "action.computerUseStop", "title_table" : "ActionCatalog" }, + { + "cli" : "clipboard", + "cli_name" : "action agent-chat-copy-identifier", + "context_menu" : "offered", + "context_menus" : [ + "tab", + "agentChat", + "workspaceRow" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "agentChat.copyIdentifier", + "mcp" : "clipboard", + "palette" : "offered", + "palette_section" : { + "id" : "category.agents", + "order" : 109, + "title" : "Agents", + "title_key" : "category.agents", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Copy Agent Identifier", + "title_key" : "action.agentChat.copyIdentifier", + "title_table" : "ActionCatalog" + }, { "cli" : "offered", "cli_name" : "agent add-harness", @@ -20874,6 +20930,33 @@ "title_key" : "action.remote.install", "title_table" : "RemoteActions" }, + { + "cli" : "offered", + "cli_name" : "workspace set-remote-localhost", + "context_menu" : "offered", + "context_menus" : [ + "workspaceRow" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "workspace.setRemoteLocalhost", + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.remote", + "order" : 111, + "title" : "Remote Machines", + "title_key" : "category.remote", + "title_table" : "RemoteActions" + }, + "palette_suggested" : null, + "title" : "Set Workspace Localhost…", + "title_key" : "action.workspace.setRemoteLocalhost", + "title_table" : "RemoteActions" + }, { "cli" : "clipboard", "cli_name" : "action remote-copy-error", @@ -24769,6 +24852,60 @@ "title_key" : "action.layout.undo", "title_table" : "HistoryActions" }, + { + "cli" : "guiOnly", + "cli_name" : "action music-open", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "music.open", + "mcp" : "guiOnly", + "palette" : "offered", + "palette_section" : { + "id" : "category.window", + "order" : 100, + "title" : "Window", + "title_key" : "category.window", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Play Music…", + "title_key" : "action.music.open", + "title_table" : "MusicActions" + }, + { + "cli" : "guiOnly", + "cli_name" : "action music-stop", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "music.stop", + "mcp" : "guiOnly", + "palette" : "offered", + "palette_section" : { + "id" : "category.window", + "order" : 100, + "title" : "Window", + "title_key" : "category.window", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Stop Music", + "title_key" : "action.music.stop", + "title_table" : "MusicActions" + }, { "cli" : "offered", "cli_name" : "bookmark add-page", @@ -26296,6 +26433,33 @@ "title_key" : "action.sidebar.density.compact", "title_table" : "SidebarSectionActions" }, + { + "cli" : "guiOnly", + "cli_name" : "action sidebar-density-comfortable", + "context_menu" : "offered", + "context_menus" : [ + "sidebarBackground" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "sidebar.density.comfortable", + "mcp" : "guiOnly", + "palette" : "offered", + "palette_section" : { + "id" : "category.sidebar", + "order" : 107, + "title" : "Sidebar", + "title_key" : "category.sidebar", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Sidebar Density: Comfortable", + "title_key" : "action.sidebar.density.comfortable", + "title_table" : "SidebarSectionActions" + }, { "cli" : "guiOnly", "cli_name" : "action sidebar-density-expanded", @@ -27738,6 +27902,24 @@ "title_table" : null, "value" : "Vesper" }, + { + "title" : "Soft Blue", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue" + }, + { + "title" : "Clay Orange", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange" + }, + { + "title" : "Olive Grey", + "title_key" : null, + "title_table" : null, + "value" : "Olive Grey" + }, { "title" : "Rose Pine Dawn", "title_key" : null, @@ -27755,6 +27937,18 @@ "title_key" : null, "title_table" : null, "value" : "GitHub Light Default" + }, + { + "title" : "Soft Blue Light", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue Light" + }, + { + "title" : "Clay Orange Light", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange Light" } ] }, @@ -27879,6 +28073,19 @@ "feature" : null, "requires" : [ + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "agentChat.copyIdentifier", + "kind" : "action", + "order" : 13, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + ] } } @@ -30832,6 +31039,24 @@ "title_table" : null, "value" : "Vesper" }, + { + "title" : "Soft Blue", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue" + }, + { + "title" : "Clay Orange", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange" + }, + { + "title" : "Olive Grey", + "title_key" : null, + "title_table" : null, + "value" : "Olive Grey" + }, { "title" : "Rose Pine Dawn", "title_key" : null, @@ -30849,6 +31074,18 @@ "title_key" : null, "title_table" : null, "value" : "GitHub Light Default" + }, + { + "title" : "Soft Blue Light", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue Light" + }, + { + "title" : "Clay Orange Light", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange Light" } ] }, @@ -32136,7 +32373,7 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.density.expanded", + "id" : "sidebar.density.comfortable", "kind" : "action", "order" : 4, "visible_when" : { @@ -32147,15 +32384,28 @@ ] } }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.density.expanded", + "kind" : "action", + "order" : 5, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, { "kind" : "separator", - "order" : 5 + "order" : 6 }, { "enabled_when" : "can_perform", "id" : "sidebar.layout.reset", "kind" : "action", - "order" : 6, + "order" : 7, "visible_when" : { "debug_only" : false, "feature" : null, @@ -33268,6 +33518,24 @@ "title_table" : null, "value" : "Vesper" }, + { + "title" : "Soft Blue", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue" + }, + { + "title" : "Clay Orange", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange" + }, + { + "title" : "Olive Grey", + "title_key" : null, + "title_table" : null, + "value" : "Olive Grey" + }, { "title" : "Rose Pine Dawn", "title_key" : null, @@ -33285,6 +33553,18 @@ "title_key" : null, "title_table" : null, "value" : "GitHub Light Default" + }, + { + "title" : "Soft Blue Light", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue Light" + }, + { + "title" : "Clay Orange Light", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange Light" } ] }, @@ -33750,6 +34030,19 @@ "feature" : null, "requires" : [ + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "agentChat.copyIdentifier", + "kind" : "action", + "order" : 5, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + ] } } @@ -35484,6 +35777,24 @@ "title_table" : null, "value" : "Vesper" }, + { + "title" : "Soft Blue", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue" + }, + { + "title" : "Clay Orange", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange" + }, + { + "title" : "Olive Grey", + "title_key" : null, + "title_table" : null, + "value" : "Olive Grey" + }, { "title" : "Rose Pine Dawn", "title_key" : null, @@ -35501,6 +35812,18 @@ "title_key" : null, "title_table" : null, "value" : "GitHub Light Default" + }, + { + "title" : "Soft Blue Light", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue Light" + }, + { + "title" : "Clay Orange Light", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange Light" } ] }, @@ -35742,15 +36065,52 @@ ] } }, + { + "choices" : { + "argument" : "localhost", + "more_opens_palette" : false, + "values" : [ + { + "title" : "The Workspace's Machine", + "title_key" : null, + "title_table" : null, + "value" : "machine" + }, + { + "title" : "This Mac", + "title_key" : null, + "title_table" : null, + "value" : "thisMac" + }, + { + "title" : "Follow Settings", + "title_key" : null, + "title_table" : null, + "value" : "setting" + } + ] + }, + "enabled_when" : "can_perform", + "id" : "workspace.setRemoteLocalhost", + "kind" : "choices", + "order" : 17, + "visible_when" : { + "debug_only" : false, + "feature" : "remoteHosts", + "requires" : [ + + ] + } + }, { "kind" : "separator", - "order" : 17 + "order" : 18 }, { "enabled_when" : "can_perform", "id" : "sidebar.workspaceTabs.toggle", "kind" : "action", - "order" : 18, + "order" : 19, "visible_when" : { "debug_only" : false, "feature" : null, @@ -36006,6 +36366,19 @@ "feature" : null, "requires" : [ + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "agentChat.copyIdentifier", + "kind" : "action", + "order" : 5, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + ] } } diff --git a/plans/cmux-next/actions.md b/plans/cmux-next/actions.md index b470ec22c5f7..a6dc532ba1ef 100644 --- a/plans/cmux-next/actions.md +++ b/plans/cmux-next/actions.md @@ -35,34 +35,34 @@ Declare its placements (menu, group, rank) and whether the CLI offers it, or nam ## Catalog counts -- `window`: 72 +- `window`: 74 - `workspace`: 150 - `pane`: 80 - `screen`: 65 - `tab`: 90 - `terminal`: 36 - `browser`: 168 -- `sidebar`: 74 +- `sidebar`: 75 - `notifications`: 21 -- `agents`: 58 +- `agents`: 60 - `cloud`: 52 -- `remote`: 10 +- `remote`: 11 - `settings`: 83 -## Counts (959 actions) +## Counts (965 actions) -Palette 923, CLI verbs 522, right-click 496, MCP tools 449. +Palette 929, CLI verbs 523, right-click 499, MCP tools 449. ## Menus -- **tab**: tab.restart renameTab palette.toggleTabPin newTab.toRight duplicateTab | tabGroup.create tabGroup.addTab tabGroup.removeTab | [more] > ([new] > (newTab.ofKind newSurface openBrowser.chromium) | [openIn] > (browser.openInChromium browserProfile.moveTab browserProfile.duplicateTab) | browser.resetPinnedTab [options] > (reloadTab | palette.clearTabName palette.toggleTabUnread toggleTabAudioMute terminal.setTheme[choices] agentChat.toggleNotificationMute terminal.clearTheme tab.setIcon tab.clearIcon terminal.keep | palette.toggleFullWidthTab | hibernateTab wakeTab disconnectRemoteTab | tab.showResources) | | [move] > (cloudBringBack cloudMove | moveSurfaceToPaneLeft moveSurfaceToPaneRight moveSurfaceToPaneUp moveSurfaceToPaneDown tab.moveToNewSplit tab.moveToNewColumn palette.moveTabToNewWorkspace tab.moveToNewWindow tab.moveToNewDockColumn moveSurfaceLeft moveSurfaceRight moveSurfaceToPreviousPane moveSurfaceToNextPane tab.moveToWorkspace) | [copy] > (palette.copySurfaceID palette.copySurfaceLink palette.copyIdentifiers palette.copyPaneID palette.copyPaneLink) | [close] > (closeTabsToLeft closeTabsToRight closeOtherTabsInPane)) | closeTab +- **tab**: tab.restart renameTab palette.toggleTabPin newTab.toRight duplicateTab | tabGroup.create tabGroup.addTab tabGroup.removeTab | [more] > ([new] > (newTab.ofKind newSurface openBrowser.chromium) | [openIn] > (browser.openInChromium browserProfile.moveTab browserProfile.duplicateTab) | browser.resetPinnedTab [options] > (reloadTab | palette.clearTabName palette.toggleTabUnread toggleTabAudioMute terminal.setTheme[choices] agentChat.toggleNotificationMute terminal.clearTheme tab.setIcon tab.clearIcon terminal.keep | palette.toggleFullWidthTab | hibernateTab wakeTab disconnectRemoteTab | tab.showResources) | | [move] > (cloudBringBack cloudMove | moveSurfaceToPaneLeft moveSurfaceToPaneRight moveSurfaceToPaneUp moveSurfaceToPaneDown tab.moveToNewSplit tab.moveToNewColumn palette.moveTabToNewWorkspace tab.moveToNewWindow tab.moveToNewDockColumn moveSurfaceLeft moveSurfaceRight moveSurfaceToPreviousPane moveSurfaceToNextPane tab.moveToWorkspace) | [copy] > (palette.copySurfaceID palette.copySurfaceLink palette.copyIdentifiers palette.copyPaneID palette.copyPaneLink agentChat.copyIdentifier) | [close] > (closeTabsToLeft closeTabsToRight closeOtherTabsInPane)) | closeTab - **tabGroup**: tabGroup.newTab | tabGroup.rename [appearance] > (tabGroup.setColor > (tabGroup.color.grey tabGroup.color.blue tabGroup.color.red tabGroup.color.yellow tabGroup.color.green tabGroup.color.pink tabGroup.color.purple tabGroup.color.cyan tabGroup.color.orange) tabGroup.setIcon tabGroup.clearIcon) | tabGroup.toggleCollapsed [group] > (tabGroup.save tabGroup.unsave) | [move] > (tabGroup.moveLeft tabGroup.moveRight tabGroup.moveToNewSplit tabGroup.moveToNewColumn tabGroup.moveToNewWorkspace tabGroup.moveToWorkspace tabGroup.moveToNewWindow) | tabGroup.ungroup tabGroup.close - **screen**: screen.new [new] > (screen.duplicate screen.newWith) | screen.rename screen.clearName screen.togglePin [appearance] > (screen.setColor > (screen.color.grey screen.color.blue screen.color.red screen.color.yellow screen.color.green screen.color.pink screen.color.purple screen.color.cyan screen.color.orange screen.clearColor) screen.setIcon screen.clearIcon) | [group] > (screenGroup.create screenGroup.addScreen screenGroup.removeScreen) | [move] > (screen.moveLeft screen.moveRight screen.moveToWorkspace screen.moveToNewWorkspace screen.moveToNewWindow) | screen.close [close] > (screen.closeOthers screen.closeToRight screen.closeToLeft) - **screenGroup**: screenGroup.newScreen | screenGroup.rename [appearance] > (screenGroup.setColor > (screenGroup.color.grey screenGroup.color.blue screenGroup.color.red screenGroup.color.yellow screenGroup.color.green screenGroup.color.pink screenGroup.color.purple screenGroup.color.cyan screenGroup.color.orange) screenGroup.setIcon screenGroup.clearIcon) | screenGroup.toggleCollapsed [group] > (screenGroup.save screenGroup.unsave) | [move] > (screenGroup.moveLeft screenGroup.moveRight screenGroup.moveToWorkspace screenGroup.moveToNewWorkspace screenGroup.moveToNewWindow) | screenGroup.ungroup screenGroup.close - **pane**: splitRight splitDown toggleSplitZoom renamePane [more] > ([new] > (newColumn newRow splitLeft splitUp splitBrowserRight splitBrowserDown remote.openTerminalHere newPaneAutoLayout | palette.newSimulatorPane) | | [move] > (pane.moveToNewWorkspace swapPaneLeft swapPaneRight swapPaneUp swapPaneDown column.moveLeft column.moveRight) | [layout] > (equalizeSplits toggleCanvasLayout pane.toggleTabBar | column.dock column.undock column.float column.dockLeft column.dockRight column.dockTop column.dockBottom | column.widthOneThird column.widthHalf column.widthTwoThirds column.widthFull column.center agentPaneZoomIn agentPaneZoomOut agentPaneZoomReset) | [connection] > (palette.swapWithSession reconnectPane) | triggerFlash [copy] > (palette.copyPaneID palette.copyPaneLink) | pane.closeOthers) | closePane -- **workspaceRow**: renameWorkspace palette.toggleWorkspacePin palette.workspaceColor[choices] workspace.newBelow | moveWorkspaceToGroup[choices] groupSelectedWorkspaces removeWorkspaceFromGroup | [more] > ([new] > (workspace.newLikeThis workspace.newAbove workspace.newInNewGroup workspace.duplicate workspace.duplicateTerminalsOnly | palette.openFolderInVSCodeInline palette.openWorkspaceTodoPane palette.layout.open) | [appearance] > (palette.resetWorkspaceColor workspace.setIcon workspace.clearIcon workspace.setTheme[choices] workspace.clearTheme) [options] > (palette.clearWorkspaceName editWorkspaceDescription palette.clearWorkspaceDescription palette.workspaceStatus markWorkspaceDone palette.markWorkspaceRead notifications.dismissHighlight palette.markWorkspaceUnread notifications.toggleWorkspaceMute clearWorkspaceNotifications palette.addWorkspaceChecklistItem | browserProfile.setWorkspaceDefault browserProfile.clearWorkspaceDefault | reconnectWorkspace disconnectWorkspace | sidebar.workspaceTabs.toggle) | [group] > (saveLayoutTemplate) | [move] > (moveWorkspaceUp moveWorkspaceDown palette.moveWorkspaceToTop workspace.moveToBottom moveWorkspaceToWindow moveWorkspaceToNewWindow workspace.toggleTop workspace.moveToSpace workspace.duplicateToSpace workspace.mergeInto) | [copy] > (workspace.copyPath palette.copyWorkspaceID palette.copyWorkspaceLink palette.copyWorkspaceIDAndRef copyWorkspaceSSHError) [tools] > (workspace.showResources revealWorkspaceInFinder palette.openWorkspacePullRequests) | [close] > (palette.closeOtherWorkspaces workspace.closeOthersInGroup palette.closeWorkspacesBelow palette.closeWorkspacesAbove workspace.closeAll)) | closeWorkspace +- **workspaceRow**: renameWorkspace palette.toggleWorkspacePin palette.workspaceColor[choices] workspace.newBelow | moveWorkspaceToGroup[choices] groupSelectedWorkspaces removeWorkspaceFromGroup | [more] > ([new] > (workspace.newLikeThis workspace.newAbove workspace.newInNewGroup workspace.duplicate workspace.duplicateTerminalsOnly | palette.openFolderInVSCodeInline palette.openWorkspaceTodoPane palette.layout.open) | [appearance] > (palette.resetWorkspaceColor workspace.setIcon workspace.clearIcon workspace.setTheme[choices] workspace.clearTheme) [options] > (palette.clearWorkspaceName editWorkspaceDescription palette.clearWorkspaceDescription palette.workspaceStatus markWorkspaceDone palette.markWorkspaceRead notifications.dismissHighlight palette.markWorkspaceUnread notifications.toggleWorkspaceMute clearWorkspaceNotifications palette.addWorkspaceChecklistItem | browserProfile.setWorkspaceDefault browserProfile.clearWorkspaceDefault | reconnectWorkspace disconnectWorkspace workspace.setRemoteLocalhost[choices] | sidebar.workspaceTabs.toggle) | [group] > (saveLayoutTemplate) | [move] > (moveWorkspaceUp moveWorkspaceDown palette.moveWorkspaceToTop workspace.moveToBottom moveWorkspaceToWindow moveWorkspaceToNewWindow workspace.toggleTop workspace.moveToSpace workspace.duplicateToSpace workspace.mergeInto) | [copy] > (workspace.copyPath palette.copyWorkspaceID palette.copyWorkspaceLink palette.copyWorkspaceIDAndRef copyWorkspaceSSHError agentChat.copyIdentifier) [tools] > (workspace.showResources revealWorkspaceInFinder palette.openWorkspacePullRequests) | [close] > (palette.closeOtherWorkspaces workspace.closeOthersInGroup palette.closeWorkspacesBelow palette.closeWorkspacesAbove workspace.closeAll)) | closeWorkspace - **workspaceGroup**: workspaceGroup.newWorkspace workspaceGroup.duplicate | workspaceGroup.rename workspaceGroup.togglePin [appearance] > (workspaceGroup.setColor > (workspaceGroup.color.grey workspaceGroup.color.blue workspaceGroup.color.red workspaceGroup.color.yellow workspaceGroup.color.green workspaceGroup.color.pink workspaceGroup.color.purple workspaceGroup.color.cyan workspaceGroup.color.orange) workspaceGroup.setIcon workspaceGroup.clearIcon) [options] > (workspaceGroup.markRead workspaceGroup.markUnread workspaceGroup.clearNotifications) | toggleFocusedWorkspaceGroupCollapsed [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll) | [move] > (workspaceGroup.moveUp workspaceGroup.moveDown workspaceGroup.moveToNewWindow workspaceGroup.moveToWindow workspaceGroup.moveToSpace workspaceGroup.addToSection) | workspaceGroup.editConfig workspaceGroup.copyID | [close] > (workspaceGroup.ungroup workspaceGroup.closeWorkspaces workspaceGroup.delete workspaceGroup.closeOthers) -- **sidebarBackground**: newTab newBrowserWorkspace openFolder newWorkspaceGroup [new] > (workspace.newAtTop workspace.newAtBottom workspace.newOnMachine space.new reopenClosedWorkspace reopenPreviousSession | newCloudWorkspace remote.connect | sidebar.home.add sidebar.item.add sidebar.section.add) | [options] > (sidebar.workspaceTabs.toggle palette.enableMinimalMode palette.disableMinimalMode sidebar.density.compact sidebar.density.expanded | sidebar.layout.reset) | [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll sidebar.groupBy.none sidebar.groupBy.folder) | [move] > (workspace.sortByName workspace.sortByLastUsed workspace.sortByDirectory) | sidebar.projects.hide sidebar.sections.showHidden | toggleSidebar appStore.show +- **sidebarBackground**: newTab newBrowserWorkspace openFolder newWorkspaceGroup [new] > (workspace.newAtTop workspace.newAtBottom workspace.newOnMachine space.new reopenClosedWorkspace reopenPreviousSession | newCloudWorkspace remote.connect | sidebar.home.add sidebar.item.add sidebar.section.add) | [options] > (sidebar.workspaceTabs.toggle palette.enableMinimalMode palette.disableMinimalMode sidebar.density.compact sidebar.density.comfortable sidebar.density.expanded | sidebar.layout.reset) | [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll sidebar.groupBy.none sidebar.groupBy.folder) | [move] > (workspace.sortByName workspace.sortByLastUsed workspace.sortByDirectory) | sidebar.projects.hide sidebar.sections.showHidden | toggleSidebar appStore.show - **terminalSelection**: terminalCopy terminalPaste terminal.selectAll useSelectionForFind terminal.sendText | [split] > (splitRight splitDown splitLeft splitUp) [agent] > (palette.forkAgentConversationRight palette.forkAgentConversationNewTab palette.forkAgentConversationLeft palette.forkAgentConversationTop palette.forkAgentConversationBottom palette.forkAgentConversationNewWorkspace) | [options] > (clearScreenKeepScrollback resetTerminal terminal.clear) | toggleSplitZoom | palette.terminalOpenDirectory - **browserPage**: browserBack browserForward browserReload browserHardReload browser.copyURL browser.openLocally | browserTheme[choices] [options] > (toggleBrowserFocusMode toggleBrowserDesignMode) | browserPrint | bookmark.addPage | toggleBrowserDeveloperTools [tools] > (palette.browserOpenDefault browserScreenshotPage browserScreenshotSection browserDevice | browser.pageInfo > (browser.pageInfo browser.pageInfo.connection browser.pageInfo.certificate browser.pageInfo.reenableCertificateWarnings | browser.pageInfo.setPermission browser.pageInfo.resetPermissions | browser.pageInfo.cookies browser.pageInfo.manageSiteData browser.pageInfo.deleteSiteData | browser.pageInfo.siteSettings browser.pageInfo.aboutThisPage) showBrowserJavaScriptConsole inspectBrowserElement | browser.extensions.menu browser.extensions.manage browser.extensions.webStore browser.extensions.loadUnpacked) - **browserLink**: browser.link.openInNewTab browser.link.openInNewWindow browser.link.openInNewSpace browser.link.openInNewWorkspace browser.link.openInSplit openLinkInDefaultBrowser | browser.link.openInIncognitoWindow | browser.link.saveAs browser.link.copy browser.link.copyText @@ -81,7 +81,7 @@ Palette 923, CLI verbs 522, right-click 496, MCP tools 449. - **sidebarItem**: sidebar.item.toggleLabel | sidebar.profileControl.hide | sidebar.item.removeEverywhere sidebar.item.remove sidebar.item.hideApp - **sidebarFooter**: sidebar.spaces.show sidebar.profileControl.show - **sidebarSection**: sidebar.section.add sidebar.section.newWorkspace sidebar.item.add | sidebar.section.rename [appearance] > (sidebar.section.useBuiltInLook sidebar.section.useListLook sidebar.section.toggleTitle sidebar.section.layoutList sidebar.section.layoutInline sidebar.section.layoutGrid sidebar.section.setAlignment sidebar.section.setGap sidebar.section.setColumns) [options] > (sidebar.section.toggleSpaceScope sidebar.section.setMaxRows) | sidebar.section.toggleCollapsed | [move] > (sidebar.section.moveToTop sidebar.section.moveToScrolling sidebar.section.moveToBottom) | sidebar.section.hide | sidebar.section.remove sidebar.section.closeWorkspaces sidebar.item.hideApp -- **agentChat**: appearance.changeBackground workspace.setTheme[choices] | cloudMove cloudBringBack | agentPaneZoomIn agentPaneZoomOut agentPaneZoomReset | find | agentPane.toggleInspector +- **agentChat**: appearance.changeBackground workspace.setTheme[choices] | cloudMove cloudBringBack | agentPaneZoomIn agentPaneZoomOut agentPaneZoomReset | find | agentPane.toggleInspector agentChat.copyIdentifier - **windowBackground**: appearance.changeBackground ## Exemptions: palette @@ -112,9 +112,9 @@ Palette 923, CLI verbs 522, right-click 496, MCP tools 449. **liveInput** (96): `taskManager.killProcess`, `toggleChecklistItemComplete`, `groupSelectedWorkspaces`, `canvasAlignLeft`, `canvasAlignRight`, `canvasAlignTop`, `canvasAlignBottom`, `canvasEqualizeWidths`, `canvasEqualizeHeights`, `canvasDistributeHorizontally`, `canvasDistributeVertically`, `simulatorHome`, `simulatorRotateLeft`, `simulatorRotateRight`, `simulatorToggleAppearance`, `simulatorToggleSoftwareKeyboard`, `toggleTerminalCopyMode`, `palette.terminalToggleTextBoxInput`, `cycleTextBoxSubmitAction`, `attachTextBoxFile`, `sendCtrlFToTerminal`, `pasteLastScreenshot`, `find`, `findInDirectory`, `findNext`, `findPrevious`, `hideFind`, `useSelectionForFind`, `terminalCopy`, `terminalPaste`, `browserScreenshotSection`, `saveFilePreview`, `toggleFileEditorWordWrap`, `diffViewerNextLine`, `diffViewerPreviousLine`, `diffViewerHalfPageDown`, `diffViewerHalfPageUp`, `diffViewerNextHunk`, `diffViewerPreviousHunk`, `diffViewerGoToBottom`, `diffViewerGoToTop`, `diffViewerSearch`, `diffViewerNextFile`, `diffViewerPreviousFile`, `omnibar.openInBackgroundTab`, `omnibar.openInForegroundTab`, `browser.prompt.allow`, `browser.prompt.block`, `fileEditorGotoLine`, `fileEditorReplace`, `fileEditorAction`, `fileExplorerOpenSelection`, `fileExplorerOpenSelectionFinderAlias`, `fileExplorerOpenInCmux`, `fileExplorerReveal`, `fileExplorerCopyPath`, `fileExplorerCopyRelativePath`, `fileExplorerOpenWith`, `vaultOpenSession`, `vaultResumeInNewWorkspace`, `vaultCopyResumeCommand`, `vaultOpenPullRequest`, `checklistEditItem`, `checklistMarkInProgress`, `checklistCompleteItem`, `checklistRemoveItem`, `checklistOpenAsPane`, `checklistAttachImages`, `toggleUnread`, `notificationCopy`, `notificationToggleRead`, `notificationDismiss`, `palette.toggleDictation`, `terminal.selectAll`, `terminal.scrollToSelection`, `markdownSave`, `markdownLink`, `markdownBack`, `markdownForward`, `list.next`, `list.previous`, `paletteKey.firstItem`, `paletteKey.lastItem`, `paletteKey.pageUp`, `paletteKey.pageDown`, `paletteKey.submit`, `paletteKey.submitAlternate`, `paletteKey.openActions`, `paletteKey.closeActions`, `paletteKey.toggleActions`, `paletteKey.escape`, `paletteKey.enterRow`, `paletteKey.leaveLevel`, `paletteKey.back`, `paletteKey.filterDeleteBackward`, `paletteKey.closeItem` -**guiOnly** (143): `openSettings`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `globalSearch`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `about`, `revealWorkspaceInFinder`, `workspaceGroup.editConfig`, `manageLayouts`, `palette.openWorkspacePullRequests`, `palette.findWork`, `toggleSplitZoom`, `canvasOverview`, `hints.show`, `palette.toggleFullWidthTab`, `tab.showResources`, `workspace.showResources`, `home.newMessage`, `toggleBrowserDeveloperTools`, `showBrowserJavaScriptConsole`, `inspectBrowserElement`, `toggleBrowserFocusMode`, `toggleBrowserDesignMode`, `toggleReactGrab`, `browserLinkHints`, `browserLinkHintsNewSplit`, `palette.browserToggleOmnibar`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `openLinkInDefaultBrowser`, `browserPrint`, `filePreviewOpenWith`, `filePreviewOpenExternally`, `filePreviewRevealInFinder`, `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `browser.media.show`, `browser.downloads.show`, `browser.profile.choose`, `browser.overflow.menu`, `browser.openLocally`, `browser.openOnMachine`, `browser.download.openBlockedSiteSettings`, `browser.link.saveAs`, `browser.image.saveAs`, `browser.selection.lookUp`, `browser.pageInfo`, `browser.pageInfo.connection`, `browser.pageInfo.certificate`, `browser.pageInfo.cookies`, `browser.pageInfo.manageSiteData`, `browser.pageInfo.siteSettings`, `browser.pageInfo.aboutThisPage`, `browser.certificateWarning.proceed`, `browser.certificateWarning.goBack`, `browser.extensions.menu`, `browser.extensions.manage`, `browser.extensions.webStore`, `browser.extension.options`, `browserProfile.delete`, `browserProfile.manageExtensions`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleSidebar`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `feed.show`, `showNotifications`, `notifications.dismissHighlight`, `palette.addHarness`, `agentPane.continueIn`, `agentPane.switchModel`, `agentPane.createCheckpoint`, `agentPane.searchFiles`, `agentPane.copyLastCodeBlock`, `agentPane.searchChats`, `agentPane.previousTurn`, `agentPane.nextTurn`, `agentPane.permission.allowOnce`, `agentPane.permission.allowChat`, `agentPane.permission.deny`, `agentPane.permission.expand`, `agentPane.permission.retry`, `agentPane.permission.revoke`, `agentPane.permission.refresh`, `palette.openTerminalChatView`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `home.toggleChiefSettings`, `home.stopChief`, `agent.harness.addFromRegistry`, `agent.harness.updateAll`, `palette.cloud.tools`, `cloudOpenMachine`, `openTeamPicker`, `palette.mobileConnect`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.searchShortcuts`, `palette.pro.upgrade`, `sendFeedback`, `help.featureFlags`, `help.documentation`, `appearance.customize`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `agentHistory.show`, `browserShowHistory`, `history.search`, `bookmark.toggleBar`, `bookmark.manager`, `sidebar.section.hide`, `sidebar.projects.hide`, `sidebar.sections.showHidden`, `sidebar.spaces.hide`, `sidebar.spaces.show`, `sidebar.profileControl.hide`, `sidebar.profileControl.show`, `sidebar.groupBy.none`, `sidebar.groupBy.folder`, `sidebar.density.compact`, `sidebar.density.expanded`, `sidebar.workspaceTabs.toggle`, `appStore.showInstalled`, `tasks.new`, `app.shareCmux`, `server.showPanel` +**guiOnly** (147): `openSettings`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `globalSearch`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `about`, `revealWorkspaceInFinder`, `workspaceGroup.editConfig`, `manageLayouts`, `palette.openWorkspacePullRequests`, `palette.findWork`, `toggleSplitZoom`, `canvasOverview`, `hints.show`, `palette.toggleFullWidthTab`, `tab.showResources`, `workspace.showResources`, `home.newMessage`, `toggleBrowserDeveloperTools`, `showBrowserJavaScriptConsole`, `inspectBrowserElement`, `toggleBrowserFocusMode`, `toggleBrowserDesignMode`, `toggleReactGrab`, `browserLinkHints`, `browserLinkHintsNewSplit`, `palette.browserToggleOmnibar`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `openLinkInDefaultBrowser`, `browserPrint`, `filePreviewOpenWith`, `filePreviewOpenExternally`, `filePreviewRevealInFinder`, `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `browser.media.show`, `browser.downloads.show`, `browser.profile.choose`, `browser.overflow.menu`, `browser.openLocally`, `browser.openOnMachine`, `browser.download.openBlockedSiteSettings`, `browser.link.saveAs`, `browser.image.saveAs`, `browser.selection.lookUp`, `browser.pageInfo`, `browser.pageInfo.connection`, `browser.pageInfo.certificate`, `browser.pageInfo.cookies`, `browser.pageInfo.manageSiteData`, `browser.pageInfo.siteSettings`, `browser.pageInfo.aboutThisPage`, `browser.certificateWarning.proceed`, `browser.certificateWarning.goBack`, `browser.extensions.menu`, `browser.extensions.manage`, `browser.extensions.webStore`, `browser.extension.options`, `browserProfile.delete`, `browserProfile.manageExtensions`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleSidebar`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `feed.show`, `showNotifications`, `notifications.dismissHighlight`, `palette.addHarness`, `stats.show`, `agentPane.continueIn`, `agentPane.switchModel`, `agentPane.createCheckpoint`, `agentPane.searchFiles`, `agentPane.copyLastCodeBlock`, `agentPane.searchChats`, `agentPane.previousTurn`, `agentPane.nextTurn`, `agentPane.permission.allowOnce`, `agentPane.permission.allowChat`, `agentPane.permission.deny`, `agentPane.permission.expand`, `agentPane.permission.retry`, `agentPane.permission.revoke`, `agentPane.permission.refresh`, `palette.openTerminalChatView`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `home.toggleChiefSettings`, `home.stopChief`, `agent.harness.addFromRegistry`, `agent.harness.updateAll`, `palette.cloud.tools`, `cloudOpenMachine`, `openTeamPicker`, `palette.mobileConnect`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.searchShortcuts`, `palette.pro.upgrade`, `sendFeedback`, `help.featureFlags`, `help.documentation`, `appearance.customize`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `agentHistory.show`, `browserShowHistory`, `history.search`, `music.open`, `music.stop`, `bookmark.toggleBar`, `bookmark.manager`, `sidebar.section.hide`, `sidebar.projects.hide`, `sidebar.sections.showHidden`, `sidebar.spaces.hide`, `sidebar.spaces.show`, `sidebar.profileControl.hide`, `sidebar.profileControl.show`, `sidebar.groupBy.none`, `sidebar.groupBy.folder`, `sidebar.density.compact`, `sidebar.density.comfortable`, `sidebar.density.expanded`, `sidebar.workspaceTabs.toggle`, `appStore.showInstalled`, `tasks.new`, `app.shareCmux`, `server.showPanel` -**clipboard** (22): `copyWorkspaceSSHError`, `palette.copyWorkspaceID`, `palette.copyWorkspaceIDAndRef`, `palette.copyWorkspaceLink`, `workspace.copyPath`, `workspaceGroup.copyID`, `palette.copyIdentifiers`, `palette.copyPaneID`, `palette.copyPaneLink`, `palette.copySurfaceID`, `palette.copySurfaceLink`, `browser.copyURL`, `browser.link.copy`, `browser.link.copyText`, `browser.image.copy`, `browser.image.copyAddress`, `browser.selection.copy`, `cloudCopyLink`, `cloudCopyPort`, `cloudCopyMachineID`, `remote.copyError`, `bookmark.copyLink` +**clipboard** (23): `copyWorkspaceSSHError`, `palette.copyWorkspaceID`, `palette.copyWorkspaceIDAndRef`, `palette.copyWorkspaceLink`, `workspace.copyPath`, `workspaceGroup.copyID`, `palette.copyIdentifiers`, `palette.copyPaneID`, `palette.copyPaneLink`, `palette.copySurfaceID`, `palette.copySurfaceLink`, `browser.copyURL`, `browser.link.copy`, `browser.link.copyText`, `browser.image.copy`, `browser.image.copyAddress`, `browser.selection.copy`, `agentChat.copyIdentifier`, `cloudCopyLink`, `cloudCopyPort`, `cloudCopyMachineID`, `remote.copyError`, `bookmark.copyLink` **familyMember** (53): `cycleWorkspaceStatus`, `toggleFocusedWorkspaceGroupCollapsed`, `workspaceGroup.color.grey`, `workspaceGroup.color.blue`, `workspaceGroup.color.red`, `workspaceGroup.color.yellow`, `workspaceGroup.color.green`, `workspaceGroup.color.pink`, `workspaceGroup.color.purple`, `workspaceGroup.color.cyan`, `workspaceGroup.color.orange`, `space.color.grey`, `space.color.blue`, `space.color.red`, `space.color.yellow`, `space.color.green`, `space.color.pink`, `space.color.purple`, `space.color.cyan`, `space.color.orange`, `tabGroup.toggleCollapsed`, `tabGroup.color.grey`, `tabGroup.color.blue`, `tabGroup.color.red`, `tabGroup.color.yellow`, `tabGroup.color.green`, `tabGroup.color.pink`, `tabGroup.color.purple`, `tabGroup.color.cyan`, `tabGroup.color.orange`, `screen.color.grey`, `screen.color.blue`, `screen.color.red`, `screen.color.yellow`, `screen.color.green`, `screen.color.pink`, `screen.color.purple`, `screen.color.cyan`, `screen.color.orange`, `screenGroup.toggleCollapsed`, `screenGroup.color.grey`, `screenGroup.color.blue`, `screenGroup.color.red`, `screenGroup.color.yellow`, `screenGroup.color.green`, `screenGroup.color.pink`, `screenGroup.color.purple`, `screenGroup.color.cyan`, `screenGroup.color.orange`, `resumeCommandEdit`, `browser.findPrevious`, `palette.startAgentFromAnyApp`, `palette.attemptUpdate` @@ -138,7 +138,7 @@ Palette 923, CLI verbs 522, right-click 496, MCP tools 449. **guiOnly** (20): `browser.media.show`, `browser.downloads.show`, `browser.profile.choose`, `browser.overflow.menu`, `browser.download.openBlockedSiteSettings`, `agentPane.continueIn`, `agentPane.switchModel`, `agentPane.createCheckpoint`, `agentPane.searchFiles`, `agentPane.copyLastCodeBlock`, `agentPane.archiveChat`, `agentPane.previousTurn`, `agentPane.nextTurn`, `agentPane.permission.allowOnce`, `agentPane.permission.allowChat`, `agentPane.permission.deny`, `agentPane.permission.expand`, `agentPane.permission.retry`, `agentPane.permission.revoke`, `agentPane.permission.refresh` -**noObject** (191): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `reopenClosedBrowserPanel`, `home.show`, `home.tab`, `home.channels`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.saveFlightRecording`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `agentActivity.open`, `computerUseStop`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.customize`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `bookmark.add`, `bookmark.import`, `bookmark.importFromBrowser`, `bookmark.export`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth` +**noObject** (194): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `reopenClosedBrowserPanel`, `home.show`, `home.tab`, `home.channels`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.saveFlightRecording`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `stats.show`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `agentActivity.open`, `computerUseStop`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.customize`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `music.open`, `music.stop`, `bookmark.add`, `bookmark.import`, `bookmark.importFromBrowser`, `bookmark.export`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth` **noTargetSurface** (81): `taskManager.killProcess`, `toggleChecklistItemComplete`, `canvasOverview`, `canvasTidy`, `canvasAlignLeft`, `canvasAlignRight`, `canvasAlignTop`, `canvasAlignBottom`, `canvasEqualizeWidths`, `canvasEqualizeHeights`, `canvasDistributeHorizontally`, `canvasDistributeVertically`, `simulatorHome`, `simulatorRotateLeft`, `simulatorRotateRight`, `simulatorToggleAppearance`, `simulatorToggleSoftwareKeyboard`, `file.open`, `home.archiveChief`, `tabGroup.deleteSaved`, `tabGroup.reopenSaved`, `screenGroup.reopenSaved`, `screenGroup.deleteSaved`, `resumeCommandSet`, `resumeCommandEdit`, `resumeCommandClear`, `saveFilePreview`, `toggleFileEditorWordWrap`, `filePreviewOpenWith`, `filePreviewOpenExternally`, `filePreviewRevealInFinder`, `diffViewerNextLine`, `diffViewerPreviousLine`, `diffViewerHalfPageDown`, `diffViewerHalfPageUp`, `diffViewerNextHunk`, `diffViewerPreviousHunk`, `diffViewerGoToBottom`, `diffViewerGoToTop`, `diffViewerSearch`, `diffViewerNextFile`, `diffViewerPreviousFile`, `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart`, `browser.openOnMachine`, `openDiffViewer`, `palette.openDirectoryDiffViewer`, `openMarkdownFile`, `fileEditorGotoLine`, `fileEditorReplace`, `fileEditorAction`, `browser.certificateWarning.proceed`, `browser.certificateWarning.goBack`, `browserProfile.openLink`, `fileExplorerOpenSelection`, `fileExplorerOpenSelectionFinderAlias`, `fileExplorerOpenInCmux`, `fileExplorerReveal`, `fileExplorerCopyPath`, `fileExplorerCopyRelativePath`, `fileExplorerOpenWith`, `vaultOpenSession`, `vaultResumeInNewWorkspace`, `vaultCopyResumeCommand`, `vaultOpenPullRequest`, `checklistEditItem`, `checklistMarkInProgress`, `checklistCompleteItem`, `checklistRemoveItem`, `checklistOpenAsPane`, `checklistAttachImages`, `agentPane.git.commit`, `agentPane.git.push`, `agentPane.searchChats`, `cloudExec`, `cloudDomainList`, `cloudPublicationList`, `markdownSave`, `markdownLink`, `markdownBack`, `markdownForward` @@ -160,7 +160,7 @@ Palette 923, CLI verbs 522, right-click 496, MCP tools 449. **endsApp** (4): `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything` -**systemChange** (62): `keepMacAwake`, `workspace.importClassic`, `palette.toggleMatchTerminalBackground`, `palette.enableMinimalMode`, `palette.disableMinimalMode`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `agentPaneZoomIn`, `agentPaneZoomOut`, `agentPaneZoomReset`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `help.showCrashLogs`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar` +**systemChange** (63): `keepMacAwake`, `workspace.importClassic`, `palette.toggleMatchTerminalBackground`, `palette.enableMinimalMode`, `palette.disableMinimalMode`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `agentPaneZoomIn`, `agentPaneZoomOut`, `agentPaneZoomReset`, `workspace.setRemoteLocalhost`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `help.showCrashLogs`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar` Every action without a CLI verb is also no MCP tool, for the CLI's reason. diff --git a/schemas/defaults/cmux-next.json b/schemas/defaults/cmux-next.json index eebefe76c6d9..4c235766fcbb 100644 --- a/schemas/defaults/cmux-next.json +++ b/schemas/defaults/cmux-next.json @@ -535,7 +535,7 @@ "setting.terminal.fontSize": null, "setting.ui.animationSpeed": "fast", "setting.ui.animations.bounce": "none", - "setting.ui.animations.newTab": false, + "setting.ui.animations.newTab": true, "setting.ui.animations.newWorkspace": false, "setting.ui.paneAnimations": false, "setting.updates.checkAutomatically": true, diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index 1fc5c73c1a4a..f0f379bd844e 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -493,6 +493,16 @@ "key" : "settings.group.computerUse", "text" : "Computer Use" } + }, + { + "key" : "settings.group.freeModel", + "rows" : [ + "freeModel.enabled" + ], + "title" : { + "key" : "settings.group.freeModel", + "text" : "AI Models" + } } ], "id" : "agents", @@ -10139,7 +10149,7 @@ "consumers" : [ "cmux-next" ], - "default" : false, + "default" : true, "default_label" : null, "group" : { "key" : "settings.group.densityMotion", @@ -14304,6 +14314,7 @@ { "accepts" : [ "compact", + "comfortable", "expanded" ], "agent_refusal" : null, @@ -14316,6 +14327,13 @@ }, "value" : "compact" }, + { + "title" : { + "key" : "settings.choice.comfortable", + "text" : "Comfortable" + }, + "value" : "comfortable" + }, { "title" : { "key" : "settings.choice.expanded", @@ -14335,7 +14353,7 @@ }, "help" : { "key" : "settings.sidebar.density.help", - "text" : "Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer." + "text" : "Comfortable rows keep one line on taller rows with more space around them. Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer." }, "kept_on_reset_all" : false, "key" : "sidebar.density", @@ -14344,9 +14362,11 @@ "density", "expanded", "compact", + "comfortable", "rows", "preview", "two lines", + "taller", "t3", "codex" ], @@ -23693,7 +23713,7 @@ "validation" : "portable" } ], - "schema_hash" : "1a4739f5cb5bd56d387252123bd73d5db4b6067ea56683f2310c89e280b47c53", + "schema_hash" : "733bc132e1aeecdbd50471c07d0510954641fa7f2a10ec59eb6746f42615ad23", "sections" : [ { "id" : "general", diff --git a/webviews/src/pages/settings/generated/strings.json b/webviews/src/pages/settings/generated/strings.json index b79eb30efa36..aa82e713dc08 100644 --- a/webviews/src/pages/settings/generated/strings.json +++ b/webviews/src/pages/settings/generated/strings.json @@ -576,7 +576,7 @@ "settings.sidebar.cmd9.last": "Last Item", "settings.sidebar.cmd9.ninth": "Ninth Item", "settings.sidebar.density": "Sidebar Density", - "settings.sidebar.density.help": "Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer.", + "settings.sidebar.density.help": "Comfortable rows keep one line on taller rows with more space around them. Expanded rows show each workspace's status or folder under its name, how long ago it changed, and its pull request and computer.", "settings.sidebar.groupBy": "Group Projects By", "settings.sidebar.groupByComputer": "Group Workspaces by Computer", "settings.sidebar.groupByComputer.help": "Off: one list of workspaces, and a workspace on another computer names it under its title. On: a section per computer.", @@ -1593,7 +1593,7 @@ "settings.sidebar.cmd9.last": "العنصر الأخير", "settings.sidebar.cmd9.ninth": "العنصر التاسع", "settings.sidebar.density": "كثافة الشريط الجانبي", - "settings.sidebar.density.help": "تعرض الصفوف الموسَّعة حالة كل مساحة عمل أو مجلدها تحت اسمها، ومتى تغيّرت آخر مرة، وطلب السحب والكمبيوتر.", + "settings.sidebar.density.help": "تبقى الصفوف المريحة في سطر واحد، لكنها أطول وحولها مساحة أكبر. تعرض الصفوف الموسَّعة حالة كل مساحة عمل أو مجلدها تحت اسمها، ومتى تغيّرت آخر مرة، وطلب السحب والكمبيوتر.", "settings.sidebar.groupBy": "تجميع المشاريع حسب", "settings.sidebar.groupByComputer": "تجميع مساحات العمل حسب الكمبيوتر", "settings.sidebar.groupByComputer.help": "إيقاف: قائمة واحدة لمساحات العمل، وتعرض مساحة العمل الموجودة على كمبيوتر آخر اسمه تحت عنوانها. تشغيل: قسم لكل كمبيوتر.", @@ -2610,7 +2610,7 @@ "settings.sidebar.cmd9.last": "Posljednja stavka", "settings.sidebar.cmd9.ninth": "Deveta stavka", "settings.sidebar.density": "Gustoća bočne trake", - "settings.sidebar.density.help": "Prošireni redovi prikazuju status ili mapu svakog radnog prostora ispod naziva, prije koliko vremena se promijenio te pull request i računar.", + "settings.sidebar.density.help": "Prostrani redovi ostaju u jednom redu, ali su viši i imaju više prostora oko sebe. Prošireni redovi prikazuju status ili mapu svakog radnog prostora ispod naziva, prije koliko vremena se promijenio te pull request i računar.", "settings.sidebar.groupBy": "Grupiši projekte po", "settings.sidebar.groupByComputer": "Grupiši radne prostore po računaru", "settings.sidebar.groupByComputer.help": "Isključeno: jedna lista radnih prostora, a radni prostor na drugom računaru navodi ga ispod naslova. Uključeno: odjeljak po računaru.", @@ -3627,7 +3627,7 @@ "settings.sidebar.cmd9.last": "Sidste element", "settings.sidebar.cmd9.ninth": "Niende element", "settings.sidebar.density": "Sidepanelets tæthed", - "settings.sidebar.density.help": "Udvidede rækker viser hvert arbejdsområdes status eller mappe under navnet, hvor længe siden det blev ændret, samt pull request og computer.", + "settings.sidebar.density.help": "Luftige rækker holder sig på én linje, men er højere og har mere plads omkring sig. Udvidede rækker viser hvert arbejdsområdes status eller mappe under navnet, hvor længe siden det blev ændret, samt pull request og computer.", "settings.sidebar.groupBy": "Gruppér projekter efter", "settings.sidebar.groupByComputer": "Gruppér arbejdsområder efter computer", "settings.sidebar.groupByComputer.help": "Fra: én liste med arbejdsområder, og et arbejdsområde på en anden computer viser dens navn under titlen. Til: en sektion pr. computer.", @@ -4644,7 +4644,7 @@ "settings.sidebar.cmd9.last": "Letzter Eintrag", "settings.sidebar.cmd9.ninth": "Neunter Eintrag", "settings.sidebar.density": "Seitenleistendichte", - "settings.sidebar.density.help": "Erweiterte Zeilen zeigen unter dem Namen den Status oder Ordner jedes Arbeitsbereichs, wie lange die letzte Änderung her ist, sowie Pull Request und Computer.", + "settings.sidebar.density.help": "Großzügige Zeilen bleiben einzeilig, sind aber höher und haben mehr Abstand. Erweiterte Zeilen zeigen unter dem Namen den Status oder Ordner jedes Arbeitsbereichs, wie lange die letzte Änderung her ist, sowie Pull Request und Computer.", "settings.sidebar.groupBy": "Projekte gruppieren nach", "settings.sidebar.groupByComputer": "Arbeitsbereiche nach Computer gruppieren", "settings.sidebar.groupByComputer.help": "Aus: eine Liste der Arbeitsbereiche; ein Arbeitsbereich auf einem anderen Computer nennt ihn unter seinem Titel. An: ein Abschnitt pro Computer.", @@ -5661,7 +5661,7 @@ "settings.sidebar.cmd9.last": "Último elemento", "settings.sidebar.cmd9.ninth": "Noveno elemento", "settings.sidebar.density": "Densidad de la barra lateral", - "settings.sidebar.density.help": "Las filas ampliadas muestran bajo el nombre el estado o la carpeta de cada espacio de trabajo, cuánto hace que cambió, y su pull request y ordenador.", + "settings.sidebar.density.help": "Las filas cómodas siguen en una línea, pero son más altas y tienen más espacio alrededor. Las filas ampliadas muestran bajo el nombre el estado o la carpeta de cada espacio de trabajo, cuánto hace que cambió, y su pull request y ordenador.", "settings.sidebar.groupBy": "Agrupar proyectos por", "settings.sidebar.groupByComputer": "Agrupar espacios de trabajo por ordenador", "settings.sidebar.groupByComputer.help": "Desactivado: una sola lista de espacios de trabajo; uno en otro ordenador muestra su nombre bajo el título. Activado: una sección por ordenador.", @@ -6678,7 +6678,7 @@ "settings.sidebar.cmd9.last": "Dernier élément", "settings.sidebar.cmd9.ninth": "Neuvième élément", "settings.sidebar.density": "Densité de la barre latérale", - "settings.sidebar.density.help": "Les lignes étendues affichent sous le nom l’état ou le dossier de chaque espace de travail, depuis quand il a changé, ainsi que sa pull request et son ordinateur.", + "settings.sidebar.density.help": "Les lignes confortables restent sur une ligne, mais sont plus hautes et plus aérées. Les lignes étendues affichent sous le nom l’état ou le dossier de chaque espace de travail, depuis quand il a changé, ainsi que sa pull request et son ordinateur.", "settings.sidebar.groupBy": "Regrouper les projets par", "settings.sidebar.groupByComputer": "Grouper les espaces de travail par ordinateur", "settings.sidebar.groupByComputer.help": "Désactivé : une seule liste d'espaces de travail ; un espace sur un autre ordinateur le nomme sous son titre. Activé : une section par ordinateur.", @@ -7695,7 +7695,7 @@ "settings.sidebar.cmd9.last": "Ultimo elemento", "settings.sidebar.cmd9.ninth": "Nono elemento", "settings.sidebar.density": "Densità della barra laterale", - "settings.sidebar.density.help": "Le righe estese mostrano sotto il nome lo stato o la cartella di ogni area di lavoro, da quanto tempo è cambiata, la pull request e il computer.", + "settings.sidebar.density.help": "Le righe comode restano su una riga, ma sono più alte e con più spazio intorno. Le righe estese mostrano sotto il nome lo stato o la cartella di ogni area di lavoro, da quanto tempo è cambiata, la pull request e il computer.", "settings.sidebar.groupBy": "Raggruppa progetti per", "settings.sidebar.groupByComputer": "Raggruppa gli spazi di lavoro per computer", "settings.sidebar.groupByComputer.help": "Disattivato: un solo elenco di spazi di lavoro; uno su un altro computer ne mostra il nome sotto il titolo. Attivato: una sezione per computer.", @@ -8712,7 +8712,7 @@ "settings.sidebar.cmd9.last": "最後の項目", "settings.sidebar.cmd9.ninth": "9 番目の項目", "settings.sidebar.density": "サイドバーの密度", - "settings.sidebar.density.help": "拡張表示では、各ワークスペースの名前の下にステータスまたはフォルダ、最終変更からの経過時間、プルリクエストとコンピュータを表示します。", + "settings.sidebar.density.help": "ゆったり表示では、1 行のまま行の高さと余白を広げます。拡張表示では、各ワークスペースの名前の下にステータスまたはフォルダ、最終変更からの経過時間、プルリクエストとコンピュータを表示します。", "settings.sidebar.groupBy": "プロジェクトのグループ化", "settings.sidebar.groupByComputer": "ワークスペースをコンピュータごとにまとめる", "settings.sidebar.groupByComputer.help": "オフ: ワークスペースを 1 つのリストで表示し、別のコンピュータのワークスペースはタイトルの下にそのコンピュータ名を表示します。オン: コンピュータごとにセクションを分けます。", @@ -9729,7 +9729,7 @@ "settings.sidebar.cmd9.last": "ធាតុចុងក្រោយ", "settings.sidebar.cmd9.ninth": "ធាតុទីប្រាំបួន", "settings.sidebar.density": "ដង់ស៊ីតេរបារចំហៀង", - "settings.sidebar.density.help": "ជួរដែលពង្រីកបង្ហាញស្ថានភាព ឬថតនៃកន្លែងធ្វើការនីមួយៗនៅក្រោមឈ្មោះ រយៈពេលតាំងពីវាផ្លាស់ប្តូរ និង pull request និងកុំព្យូទ័រ។", + "settings.sidebar.density.help": "ជួរធូរនៅតែមួយបន្ទាត់ ប៉ុន្តែខ្ពស់ជាង និងមានចន្លោះច្រើនជាងនៅជុំវិញ។ ជួរដែលពង្រីកបង្ហាញស្ថានភាព ឬថតនៃកន្លែងធ្វើការនីមួយៗនៅក្រោមឈ្មោះ រយៈពេលតាំងពីវាផ្លាស់ប្តូរ និង pull request និងកុំព្យូទ័រ។", "settings.sidebar.groupBy": "ដាក់ក្រុមគម្រោងតាម", "settings.sidebar.groupByComputer": "ដាក់កន្លែងធ្វើការជាក្រុមតាមកុំព្យូទ័រ", "settings.sidebar.groupByComputer.help": "បិទ៖ បញ្ជីកន្លែងធ្វើការតែមួយ ហើយកន្លែងធ្វើការនៅលើកុំព្យូទ័រផ្សេងបង្ហាញឈ្មោះវានៅក្រោមចំណងជើង។ បើក៖ ផ្នែកមួយសម្រាប់កុំព្យូទ័រនីមួយៗ។", @@ -10746,7 +10746,7 @@ "settings.sidebar.cmd9.last": "마지막 항목", "settings.sidebar.cmd9.ninth": "아홉 번째 항목", "settings.sidebar.density": "사이드바 밀도", - "settings.sidebar.density.help": "확장 행은 각 작업 공간의 이름 아래에 상태 또는 폴더, 마지막 변경 이후 경과 시간, 풀 리퀘스트와 컴퓨터를 표시합니다.", + "settings.sidebar.density.help": "넉넉한 행은 한 줄을 유지하면서 높이와 주변 여백이 더 큽니다. 확장 행은 각 작업 공간의 이름 아래에 상태 또는 폴더, 마지막 변경 이후 경과 시간, 풀 리퀘스트와 컴퓨터를 표시합니다.", "settings.sidebar.groupBy": "프로젝트 그룹 기준", "settings.sidebar.groupByComputer": "컴퓨터별로 워크스페이스 묶기", "settings.sidebar.groupByComputer.help": "끔: 워크스페이스를 하나의 목록으로 보여 주고, 다른 컴퓨터의 워크스페이스는 제목 아래에 그 컴퓨터 이름을 표시합니다. 켬: 컴퓨터마다 섹션을 나눕니다.", @@ -11763,7 +11763,7 @@ "settings.sidebar.cmd9.last": "Siste element", "settings.sidebar.cmd9.ninth": "Niende element", "settings.sidebar.density": "Sidefelttetthet", - "settings.sidebar.density.help": "Utvidede rader viser status eller mappe for hvert arbeidsområde under navnet, hvor lenge siden det ble endret, og pull request og datamaskin.", + "settings.sidebar.density.help": "Luftige rader holder seg på én linje, men er høyere og har mer plass rundt seg. Utvidede rader viser status eller mappe for hvert arbeidsområde under navnet, hvor lenge siden det ble endret, og pull request og datamaskin.", "settings.sidebar.groupBy": "Grupper prosjekter etter", "settings.sidebar.groupByComputer": "Grupper arbeidsområder etter datamaskin", "settings.sidebar.groupByComputer.help": "Av: én liste med arbeidsområder, og et arbeidsområde på en annen datamaskin viser navnet under tittelen. På: én seksjon per datamaskin.", @@ -12780,7 +12780,7 @@ "settings.sidebar.cmd9.last": "Ostatni element", "settings.sidebar.cmd9.ninth": "Dziewiąty element", "settings.sidebar.density": "Gęstość paska bocznego", - "settings.sidebar.density.help": "Rozszerzone wiersze pokazują pod nazwą stan lub folder każdego obszaru roboczego, jak dawno się zmienił oraz pull request i komputer.", + "settings.sidebar.density.help": "Przestronne wiersze zostają w jednej linii, ale są wyższe i mają więcej miejsca wokół. Rozszerzone wiersze pokazują pod nazwą stan lub folder każdego obszaru roboczego, jak dawno się zmienił oraz pull request i komputer.", "settings.sidebar.groupBy": "Grupuj projekty według", "settings.sidebar.groupByComputer": "Grupuj obszary robocze według komputera", "settings.sidebar.groupByComputer.help": "Wyłączone: jedna lista obszarów roboczych, a obszar na innym komputerze podaje jego nazwę pod tytułem. Włączone: sekcja dla każdego komputera.", @@ -13797,7 +13797,7 @@ "settings.sidebar.cmd9.last": "Último item", "settings.sidebar.cmd9.ninth": "Nono item", "settings.sidebar.density": "Densidade da barra lateral", - "settings.sidebar.density.help": "Linhas expandidas mostram abaixo do nome o status ou a pasta de cada espaço de trabalho, há quanto tempo mudou, e o pull request e o computador.", + "settings.sidebar.density.help": "Linhas confortáveis continuam em uma linha, mas são mais altas e têm mais espaço ao redor. Linhas expandidas mostram abaixo do nome o status ou a pasta de cada espaço de trabalho, há quanto tempo mudou, e o pull request e o computador.", "settings.sidebar.groupBy": "Agrupar projetos por", "settings.sidebar.groupByComputer": "Agrupar espaços de trabalho por computador", "settings.sidebar.groupByComputer.help": "Desativado: uma única lista de espaços de trabalho; um espaço em outro computador mostra o nome dele abaixo do título. Ativado: uma seção por computador.", @@ -14814,7 +14814,7 @@ "settings.sidebar.cmd9.last": "Последний элемент", "settings.sidebar.cmd9.ninth": "Девятый элемент", "settings.sidebar.density": "Плотность боковой панели", - "settings.sidebar.density.help": "Развёрнутые строки показывают под названием статус или папку каждого рабочего пространства, как давно оно менялось, а также pull request и компьютер.", + "settings.sidebar.density.help": "Свободные строки остаются в одну строку, но они выше и с большими отступами. Развёрнутые строки показывают под названием статус или папку каждого рабочего пространства, как давно оно менялось, а также pull request и компьютер.", "settings.sidebar.groupBy": "Группировать проекты по", "settings.sidebar.groupByComputer": "Группировать рабочие пространства по компьютерам", "settings.sidebar.groupByComputer.help": "Выкл.: один список рабочих пространств; у пространства на другом компьютере его имя показано под названием. Вкл.: раздел для каждого компьютера.", @@ -15831,7 +15831,7 @@ "settings.sidebar.cmd9.last": "รายการสุดท้าย", "settings.sidebar.cmd9.ninth": "รายการที่เก้า", "settings.sidebar.density": "ความหนาแน่นของแถบด้านข้าง", - "settings.sidebar.density.help": "แถวแบบขยายจะแสดงสถานะหรือโฟลเดอร์ของแต่ละพื้นที่ทำงานใต้ชื่อ เวลาที่เปลี่ยนแปลงล่าสุด รวมถึงพูลรีเควสต์และคอมพิวเตอร์", + "settings.sidebar.density.help": "แถวแบบกว้างขวางยังคงเป็นบรรทัดเดียว แต่สูงขึ้นและมีระยะห่างรอบๆ มากขึ้น แถวแบบขยายจะแสดงสถานะหรือโฟลเดอร์ของแต่ละพื้นที่ทำงานใต้ชื่อ เวลาที่เปลี่ยนแปลงล่าสุด รวมถึงพูลรีเควสต์และคอมพิวเตอร์", "settings.sidebar.groupBy": "จัดกลุ่มโปรเจกต์ตาม", "settings.sidebar.groupByComputer": "จัดกลุ่มพื้นที่ทำงานตามคอมพิวเตอร์", "settings.sidebar.groupByComputer.help": "ปิด: แสดงพื้นที่ทำงานเป็นรายการเดียว และพื้นที่ทำงานบนคอมพิวเตอร์อื่นจะแสดงชื่อเครื่องใต้ชื่อเรื่อง เปิด: แยกส่วนตามคอมพิวเตอร์", @@ -16848,7 +16848,7 @@ "settings.sidebar.cmd9.last": "Son öğe", "settings.sidebar.cmd9.ninth": "Dokuzuncu öğe", "settings.sidebar.density": "Kenar çubuğu yoğunluğu", - "settings.sidebar.density.help": "Genişletilmiş satırlar, her çalışma alanının adının altında durumunu veya klasörünü, ne kadar önce değiştiğini, pull request'ini ve bilgisayarını gösterir.", + "settings.sidebar.density.help": "Rahat satırlar tek satırda kalır, ancak daha yüksektir ve çevrelerinde daha fazla boşluk vardır. Genişletilmiş satırlar, her çalışma alanının adının altında durumunu veya klasörünü, ne kadar önce değiştiğini, pull request'ini ve bilgisayarını gösterir.", "settings.sidebar.groupBy": "Projeleri şuna göre grupla", "settings.sidebar.groupByComputer": "Çalışma alanlarını bilgisayara göre grupla", "settings.sidebar.groupByComputer.help": "Kapalı: tek bir çalışma alanı listesi; başka bir bilgisayardaki çalışma alanı başlığının altında onun adını gösterir. Açık: her bilgisayar için bir bölüm.", @@ -17865,7 +17865,7 @@ "settings.sidebar.cmd9.last": "Останній елемент", "settings.sidebar.cmd9.ninth": "Дев’ятий елемент", "settings.sidebar.density": "Щільність бічної панелі", - "settings.sidebar.density.help": "Розгорнуті рядки показують під назвою стан або теку кожного робочого простору, як давно він змінювався, а також pull request і комп’ютер.", + "settings.sidebar.density.help": "Просторі рядки залишаються в один рядок, але вищі й мають більше місця навколо. Розгорнуті рядки показують під назвою стан або теку кожного робочого простору, як давно він змінювався, а також pull request і комп’ютер.", "settings.sidebar.groupBy": "Групувати проєкти за", "settings.sidebar.groupByComputer": "Групувати робочі простори за комп'ютером", "settings.sidebar.groupByComputer.help": "Вимк.: один список робочих просторів; простір на іншому комп'ютері показує його назву під заголовком. Увімк.: розділ для кожного комп'ютера.", @@ -18882,7 +18882,7 @@ "settings.sidebar.cmd9.last": "Mục cuối cùng", "settings.sidebar.cmd9.ninth": "Mục thứ chín", "settings.sidebar.density": "Mật độ thanh bên", - "settings.sidebar.density.help": "Hàng mở rộng hiển thị dưới tên trạng thái hoặc thư mục của mỗi không gian làm việc, thời gian kể từ lần thay đổi gần nhất, cùng pull request và máy tính.", + "settings.sidebar.density.help": "Hàng thoáng vẫn giữ một dòng nhưng cao hơn và có nhiều khoảng trống xung quanh hơn. Hàng mở rộng hiển thị dưới tên trạng thái hoặc thư mục của mỗi không gian làm việc, thời gian kể từ lần thay đổi gần nhất, cùng pull request và máy tính.", "settings.sidebar.groupBy": "Nhóm dự án theo", "settings.sidebar.groupByComputer": "Nhóm không gian làm việc theo máy tính", "settings.sidebar.groupByComputer.help": "Tắt: một danh sách không gian làm việc; không gian trên máy tính khác hiện tên máy dưới tiêu đề. Bật: mỗi máy tính một mục.", @@ -19899,7 +19899,7 @@ "settings.sidebar.cmd9.last": "最后一项", "settings.sidebar.cmd9.ninth": "第九项", "settings.sidebar.density": "边栏密度", - "settings.sidebar.density.help": "展开的行会在名称下方显示每个工作区的状态或文件夹、距上次更改的时间,以及拉取请求和电脑。", + "settings.sidebar.density.help": "宽松的行仍为单行,但行高更高、四周留白更多。展开的行会在名称下方显示每个工作区的状态或文件夹、距上次更改的时间,以及拉取请求和电脑。", "settings.sidebar.groupBy": "项目分组方式", "settings.sidebar.groupByComputer": "按电脑分组工作区", "settings.sidebar.groupByComputer.help": "关闭:所有工作区显示为一个列表,其他电脑上的工作区在标题下显示该电脑名称。开启:每台电脑一个分区。", @@ -20916,7 +20916,7 @@ "settings.sidebar.cmd9.last": "最後一項", "settings.sidebar.cmd9.ninth": "第九項", "settings.sidebar.density": "側邊欄密度", - "settings.sidebar.density.help": "展開的列會在名稱下方顯示每個工作區的狀態或資料夾、距上次變更的時間,以及提取要求和電腦。", + "settings.sidebar.density.help": "寬鬆的列仍為單行,但列高更高、四周留白更多。展開的列會在名稱下方顯示每個工作區的狀態或資料夾、距上次變更的時間,以及提取要求和電腦。", "settings.sidebar.groupBy": "專案分組方式", "settings.sidebar.groupByComputer": "依電腦分組工作區", "settings.sidebar.groupByComputer.help": "關閉:所有工作區顯示為一個清單,其他電腦上的工作區在標題下顯示該電腦名稱。開啟:每台電腦一個區段。", From eff3ab4103a95ac9e5fc0dc6160461c49291e403 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:35:45 -0700 Subject: [PATCH 072/183] Chief registry: chief.profile.adopt makes an existing agent participant a Chief record under its own id The owner (a trusted local Unix caller acting as the person) adopts an agent that is already in a conversation: the record keeps the participant's id and display name, so its DMs, history and credential stay its own, and gets chief.profile.create's defaults and only the fields given. A page (WebSocket or the host's page relay, even with a gesture), a paired or remote device and a bound agent get origin.forbidden; there is no page route. agent_mux is default_chief, a live record already_chief, a deleted Chief deleted_chief (an adopt never revives its tombstone or kept memory), and a person or an unknown id not_an_agent_participant. The write goes through the daemon's registry ledger (idempotent per key) and the same chief_written hook as a create. Capability chief-profile-adopt-v1; CLI cmux chief adopt AGENT [PROFILE], refused for an agent caller. Real-daemon tests for each refusal and for an adopt that keeps the DM and its history. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Resources/AppPlatform/scopes.json | 4 + cmux-tui/bindings/cpp/.cmux-resource-api.json | 5 +- cmux-tui/bindings/go/.cmux-resource-api.json | 5 +- .../bindings/java/.cmux-resource-api.json | 5 +- .../bindings/python/.cmux-resource-api.json | 5 +- .../bindings/rust/.cmux-resource-api.json | 5 +- .../typescript/.cmux-resource-api.json | 5 +- cmux-tui/bindings/zig/.cmux-resource-api.json | 5 +- .../cmux-app-host/generated/cmux-app.d.ts | 2 + .../crates/cmux-app-host/generated/ops.json | 1 + .../cmux-app-host/generated/scopes.json | 4 + .../cmux-tui-core/src/chief_registry.rs | 59 +++- .../src/request_origin/page_access.rs | 3 + cmux-tui/crates/cmux-tui-core/src/resource.rs | 2 + .../cmux-tui-core/src/resource/wire_name.rs | 1 + .../src/resource_router/owner.rs | 1 + .../cmux-tui-core/src/server/capabilities.rs | 1 + .../src/server/chief_adopt_tests.rs | 187 +++++++++++++ .../src/server/chief_registry_resource.rs | 84 +++++- .../crates/cmux-tui/src/cli/chief/messages.rs | 4 +- .../crates/cmux-tui/src/cli/chief/registry.rs | 42 ++- .../tests/transport_path_coverage_tests.rs | 3 +- .../crates/cmux-tui/src/cli/mcp/v2_tools.rs | 1 + cmux-tui/crates/cmux-tui/tests/cli/chief.rs | 2 + .../crates/cmux-tui/tests/cli/chief_adopt.rs | 251 ++++++++++++++++++ cmux-tui/spec/cli.md | 6 +- cmux-tui/spec/commands.md | 2 +- cmux-tui/spec/inventory.json | 1 + cmux-tui/spec/resource-api-v2.json | 3 + cmux-tui/spec/resource-api-v2.md | 17 +- cmux-tui/spec/resource-operations-v2.json | 153 +++++++++++ cmux-tui/spec/resource-operations-v2.md | 4 +- 32 files changed, 842 insertions(+), 31 deletions(-) create mode 100644 cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs create mode 100644 cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Resources/AppPlatform/scopes.json b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Resources/AppPlatform/scopes.json index 52ec70c96812..272f7a71cc5c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Resources/AppPlatform/scopes.json +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Resources/AppPlatform/scopes.json @@ -209,6 +209,10 @@ "scope": "chief:read", "class": "read" }, + "chief.profile.adopt": { + "scope": "chief:write", + "class": "mutation" + }, "chief.profile.avatar.get": { "scope": "chief:read", "class": "read" diff --git a/cmux-tui/bindings/cpp/.cmux-resource-api.json b/cmux-tui/bindings/cpp/.cmux-resource-api.json index 47e8c9613d53..2e953eadda52 100644 --- a/cmux-tui/bindings/cpp/.cmux-resource-api.json +++ b/cmux-tui/bindings/cpp/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/go/.cmux-resource-api.json b/cmux-tui/bindings/go/.cmux-resource-api.json index 47e8c9613d53..2e953eadda52 100644 --- a/cmux-tui/bindings/go/.cmux-resource-api.json +++ b/cmux-tui/bindings/go/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/java/.cmux-resource-api.json b/cmux-tui/bindings/java/.cmux-resource-api.json index 47e8c9613d53..2e953eadda52 100644 --- a/cmux-tui/bindings/java/.cmux-resource-api.json +++ b/cmux-tui/bindings/java/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/python/.cmux-resource-api.json b/cmux-tui/bindings/python/.cmux-resource-api.json index 47e8c9613d53..2e953eadda52 100644 --- a/cmux-tui/bindings/python/.cmux-resource-api.json +++ b/cmux-tui/bindings/python/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/rust/.cmux-resource-api.json b/cmux-tui/bindings/rust/.cmux-resource-api.json index 47e8c9613d53..2e953eadda52 100644 --- a/cmux-tui/bindings/rust/.cmux-resource-api.json +++ b/cmux-tui/bindings/rust/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/typescript/.cmux-resource-api.json b/cmux-tui/bindings/typescript/.cmux-resource-api.json index 47e8c9613d53..2e953eadda52 100644 --- a/cmux-tui/bindings/typescript/.cmux-resource-api.json +++ b/cmux-tui/bindings/typescript/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/zig/.cmux-resource-api.json b/cmux-tui/bindings/zig/.cmux-resource-api.json index 47e8c9613d53..2e953eadda52 100644 --- a/cmux-tui/bindings/zig/.cmux-resource-api.json +++ b/cmux-tui/bindings/zig/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts b/cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts index 5b0a0e1f8259..a676e4786e16 100644 --- a/cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts +++ b/cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts @@ -588,6 +588,8 @@ interface CmuxGlobal { /** `chief.list` (read, scope `chief:read`): The user's chiefs (active first, the default marked), archived ones on request, and tombstones. */ list: CmuxOp<{ include_archived?: boolean }, { chiefs: Array; tombstones: Array<{ id: Cmux.ChiefId; owner_user: string; archived_at: Cmux.Timestamp }> }> profile: { + /** `chief.profile.adopt` (mutation, scope `chief:write`) */ + adopt: CmuxOp<{ machine?: string; session?: string; participant_id: string; display_name?: string; role?: string; personality?: string; instructions?: string; tools?: Array; harnesses?: Array; self_edit?: Array; harness?: string; model?: string; effort?: string }, Cmux.MutationResult> avatar: { /** `chief.profile.avatar.get` (read, scope `chief:read`) */ get: CmuxOp<{ machine?: string; session?: string; chief: string }, Cmux.ChiefAvatarPage> diff --git a/cmux-tui/crates/cmux-app-host/generated/ops.json b/cmux-tui/crates/cmux-app-host/generated/ops.json index 74505989015b..2379d643133b 100644 --- a/cmux-tui/crates/cmux-app-host/generated/ops.json +++ b/cmux-tui/crates/cmux-app-host/generated/ops.json @@ -53,6 +53,7 @@ "chief.engine.get", "chief.engine.set", "chief.list", + "chief.profile.adopt", "chief.profile.avatar.get", "chief.profile.avatar.set", "chief.profile.create", diff --git a/cmux-tui/crates/cmux-app-host/generated/scopes.json b/cmux-tui/crates/cmux-app-host/generated/scopes.json index 52ec70c96812..272f7a71cc5c 100644 --- a/cmux-tui/crates/cmux-app-host/generated/scopes.json +++ b/cmux-tui/crates/cmux-app-host/generated/scopes.json @@ -209,6 +209,10 @@ "scope": "chief:read", "class": "read" }, + "chief.profile.adopt": { + "scope": "chief:write", + "class": "mutation" + }, "chief.profile.avatar.get": { "scope": "chief:read", "class": "read" diff --git a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs index c458495e3b26..19ef910b4095 100644 --- a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs @@ -88,6 +88,16 @@ pub(crate) enum Refusal { /// committed. KeyConflict(String), TooMany, + /// `chief.profile.adopt`: the participant is already a live Chief record. + AlreadyChief, + /// `chief.profile.adopt`: the id is a deleted Chief (its tombstone, and + /// its memory while kept); an adopt never revives it. + DeletedChief, + /// `chief.profile.adopt`: the default Chief is a record already. + DefaultChief, + /// `chief.profile.adopt`: no agent participant of a local conversation + /// has this id (or it is a person). + NotAgentParticipant, } impl Refusal { @@ -99,6 +109,10 @@ impl Refusal { Self::RevMismatch(_) => "rev_mismatch", Self::KeyConflict(_) => "idempotency_conflict", Self::TooMany => "too_many_chiefs", + Self::AlreadyChief => "already_chief", + Self::DeletedChief => "deleted_chief", + Self::DefaultChief => "default_chief", + Self::NotAgentParticipant => "not_an_agent_participant", } } } @@ -473,10 +487,51 @@ pub(crate) fn create( let mut random = [0_u8; 10]; getrandom::fill(&mut random).map_err(|_| anyhow::anyhow!("chief id randomness"))?; let id = cmux_conversation::encode_id("agent_", now_ms, random); + insert_new(tx, &id, now_ms, fields).map(Ok) +} + +/// `chief.profile.adopt`'s write: a record for an existing agent participant +/// `id` (already checked to be one, and not `agent_mux`), with `create`'s +/// defaults, `display_name` unless `fields` names one, and only the given +/// fields. A live record or a tombstone of `id` refuses: an adopt never +/// revives a deleted Chief (or the memory kept for it). +pub(crate) fn adopt( + tx: &rusqlite::Transaction<'_>, + id: &str, + display_name: &str, + fields: &Map, +) -> anyhow::Result> { + if id == DEFAULT_CHIEF { + return Ok(Err(Refusal::DefaultChief)); + } + let existing: Option> = + tx.query_row("SELECT record FROM chief WHERE id = ?1", [id], |r| r.get(0)).optional()?; + match existing { + Some(Some(_)) => return Ok(Err(Refusal::AlreadyChief)), + Some(None) => return Ok(Err(Refusal::DeletedChief)), + None => {} + } + let live: i64 = + tx.query_row("SELECT COUNT(*) FROM chief WHERE record IS NOT NULL", [], |r| r.get(0))?; + if usize::try_from(live).unwrap_or(usize::MAX) >= MAX_CHIEFS { + return Ok(Err(Refusal::TooMany)); + } + let mut fields = fields.clone(); + fields.entry("display_name").or_insert_with(|| Value::String(display_name.to_owned())); + insert_new(tx, id, now_ms(), &fields).map(Ok) +} + +/// A new live record `id` with `create`'s defaults and `fields` applied. +fn insert_new( + tx: &rusqlite::Transaction<'_>, + id: &str, + now_ms: u64, + fields: &Map, +) -> anyhow::Result { let seq: i64 = tx.query_row("SELECT COALESCE(MAX(seq), 0) + 1 FROM chief", [], |r| r.get(0))?; let now = cmux_conversation::format_rfc3339_millis(now_ms); let mut record = ChiefRecord { - id: id.clone(), + id: id.to_owned(), display_name: String::new(), role: String::new(), personality: String::new(), @@ -496,7 +551,7 @@ pub(crate) fn create( "INSERT INTO chief (id, seq, record) VALUES (?1, ?2, ?3)", params![id, seq, serde_json::to_string(&record)?], )?; - Ok(Ok(record)) + Ok(record) } /// `chief.profile.update`'s write: `fields` (already checked) on `id`, guarded by diff --git a/cmux-tui/crates/cmux-tui-core/src/request_origin/page_access.rs b/cmux-tui/crates/cmux-tui-core/src/request_origin/page_access.rs index d1cb133f6380..12768b4024d7 100644 --- a/cmux-tui/crates/cmux-tui-core/src/request_origin/page_access.rs +++ b/cmux-tui/crates/cmux-tui-core/src/request_origin/page_access.rs @@ -355,6 +355,9 @@ const fn access(operation: Op) -> Access { | Op::ChiefProfileDelete | Op::ChiefProfileAvatarSet | Op::ChiefProfileAvatarGet + // Adopting an agent as a Chief: no page route at all (owner only, + // from a terminal; chief_registry_resource.rs refuses any page). + | Op::ChiefProfileAdopt // Avatars: a page reads them only through its allow list // (page-router.json: home-channels reads participant.avatar.get). | Op::ParticipantAvatarSet diff --git a/cmux-tui/crates/cmux-tui-core/src/resource.rs b/cmux-tui/crates/cmux-tui-core/src/resource.rs index 25d017189277..219182762b3c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource.rs @@ -134,6 +134,8 @@ pub enum ResourceOperation { ChiefProfileGet, #[serde(rename = "chief.profile.create")] ChiefProfileCreate, + #[serde(rename = "chief.profile.adopt")] + ChiefProfileAdopt, #[serde(rename = "chief.profile.update")] ChiefProfileUpdate, #[serde(rename = "chief.profile.delete")] diff --git a/cmux-tui/crates/cmux-tui-core/src/resource/wire_name.rs b/cmux-tui/crates/cmux-tui-core/src/resource/wire_name.rs index d237b004bc83..5b77b5aa0602 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource/wire_name.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource/wire_name.rs @@ -54,6 +54,7 @@ impl ResourceOperation { Self::ChiefProfileList => "chief.profile.list", Self::ChiefProfileGet => "chief.profile.get", Self::ChiefProfileCreate => "chief.profile.create", + Self::ChiefProfileAdopt => "chief.profile.adopt", Self::ChiefProfileUpdate => "chief.profile.update", Self::ChiefProfileDelete => "chief.profile.delete", Self::ChiefProfileAvatarSet => "chief.profile.avatar.set", diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/owner.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/owner.rs index cdda1a27a72e..223f15caa998 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/owner.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/owner.rs @@ -295,6 +295,7 @@ pub(super) const fn operation_owner(operation: ResourceOperation) -> OperationOw | ResourceOperation::ChiefProfileList | ResourceOperation::ChiefProfileGet | ResourceOperation::ChiefProfileCreate + | ResourceOperation::ChiefProfileAdopt | ResourceOperation::ChiefProfileUpdate | ResourceOperation::ChiefProfileDelete | ResourceOperation::ChiefProfileAvatarSet diff --git a/cmux-tui/crates/cmux-tui-core/src/server/capabilities.rs b/cmux-tui/crates/cmux-tui-core/src/server/capabilities.rs index 0c2c4e806720..d9cfb90eb2e2 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/capabilities.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/capabilities.rs @@ -185,6 +185,7 @@ pub(super) fn advertised_capabilities( conversation_attachments::WS_ATTACHMENTS_CAPABILITY, chief_registry_resource::CHIEF_PROFILE_WS_CAPABILITY, chief_registry_resource::CHIEF_EVENTS_CAPABILITY, + chief_registry_resource::CHIEF_PROFILE_ADOPT_CAPABILITY, SCREEN_METADATA_CAPABILITY, SCREEN_GROUPS_CAPABILITY, NOTIFICATION_SOURCE_CAPABILITY, diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs new file mode 100644 index 000000000000..d641de17ee18 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs @@ -0,0 +1,187 @@ +//! `chief.profile.adopt` on the request path every connection takes +//! (`handle_connection_message`: the origin gate, then the Chief registry): +//! only the owner's own Unix connection adopts. The person's page WebSocket, +//! the host's page relay (even with the person's gesture), a paired device and +//! a bound agent are refused with `origin.forbidden`, and nothing is written. + +use serde_json::{Value, json}; + +use crate::request_origin::params_sha256; +use crate::server::origin_gate::{ + set_page, set_peer_key_for_test, set_role_for_test, set_scoped_page, set_verified_app_for_test, +}; +use crate::server::*; + +struct Conn { + client: u64, + writer: MessageWriter, + outbound: Arc, + scheduler: Arc, +} + +fn connect(mux: &Arc, transport: ClientTransport) -> Conn { + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + let client = mux.control_clients.register(transport, writer.clone()); + let scheduler = + Arc::new(ConnectionSurfaceScheduler::new(mux.surface_operation_admission.clone())); + Conn { client, writer, outbound, scheduler } +} + +fn send(mux: &Arc, conn: &Conn, request: &Value) -> Value { + assert!(handle_connection_message( + mux, + conn.client, + &request.to_string(), + &conn.writer, + &conn.scheduler + )); + let message = conn.outbound.try_pop().expect("a synchronous reply"); + serde_json::from_str(&message).unwrap() +} + +/// A v1 command on `conn`'s client (its data). +fn command(mux: &Arc, conn: &Conn, request: Value) -> Value { + let command: Command = serde_json::from_value(request).unwrap(); + handle_command(mux, conn.client, command, &conn.writer).unwrap() +} + +fn params(fields: Value) -> Value { + let mut params = json!({"machine": "current", "session": "current"}); + for (key, value) in fields.as_object().unwrap() { + params[key] = value.clone(); + } + params +} + +fn v2(operation: &str, params: &Value, key: Option<&str>, origin: Option) -> Value { + let mut request = json!({ + "protocol": "cmux.protocol/2", "type": "request", "id": "r1", + "operation": operation, "params": params, + }); + if let Some(key) = key { + request["idempotency_key"] = json!(key); + } + if let Some(origin) = origin { + request["origin"] = origin; + } + request +} + +fn forbidden(reply: &Value) -> String { + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(reply["error"]["code"], "origin.forbidden", "{reply}"); + reply["error"]["details"]["reason"].as_str().unwrap_or_default().to_owned() +} + +fn chief_ids(mux: &Arc, owner: &Conn) -> Vec { + let listed = send(mux, owner, &v2("chief.profile.list", ¶ms(json!({})), None, None)); + assert_eq!(listed["ok"], true, "{listed}"); + listed["result"]["chiefs"] + .as_array() + .unwrap() + .iter() + .map(|c| c["id"].as_str().unwrap().to_owned()) + .collect() +} + +#[test] +fn only_the_owners_own_unix_connection_adopts_an_agent() { + let mux = Mux::new_for_test("chief-adopt-callers".to_owned(), crate::SurfaceOptions::default()); + let owner = connect(&mux, ClientTransport::Unix); + let created = command( + &mux, + &owner, + json!({"cmd": "conversation-create", "idempotency_key": "dm-release", "actor": "user_local", + "title": "Release Chief", "participants": [ + {"id": "user_local", "kind": "human", "display_name": "Me"}, + {"id": "agent_release", "kind": "agent", "display_name": "Release Chief", + "agent_class": "mux", "acp_session": "release"}]}), + ); + assert!(created["conversation"]["id"].is_string(), "{created}"); + let adopt = params(json!({"participant_id": "agent_release", "role": "Release trains"})); + + // The person's page WebSocket (the Channels page, page-bound credential). + let page = connect(&mux, ClientTransport::WebSocket); + set_scoped_page(&mux, page.client, "home-channels".to_owned()); + let reason = + forbidden(&send(&mux, &page, &v2("chief.profile.adopt", &adopt, Some("p1"), None))); + assert!(matches!(reason.as_str(), "page_not_allowed" | "chief_adopt_owner_only"), "{reason}"); + // The daemon's own credential on a WebSocket with no page: still a page path. + let socket = connect(&mux, ClientTransport::WebSocket); + assert_eq!( + forbidden(&send(&mux, &socket, &v2("chief.profile.adopt", &adopt, Some("w1"), None))), + "chief_adopt_owner_only" + ); + + // The host's page relay, even with the person's gesture for exactly this request. + let app = connect(&mux, ClientTransport::Unix); + set_role_for_test(&mux, app.client, "main"); + set_peer_key_for_test(&mux, app.client, "token:20.1"); + set_verified_app_for_test(&mux, app.client, true); + let relay = connect(&mux, ClientTransport::Unix); + set_role_for_test(&mux, relay.client, "page_relay"); + set_peer_key_for_test(&mux, relay.client, "token:20.1"); + set_page(&mux, relay.client, "home-channels".to_owned()); + let issued = send( + &mux, + &app, + &v2( + "origin.confirmation.issue", + &json!({"machine": "current", "session": "current", "operation": "chief.profile.adopt", + "params_sha256": params_sha256(&adopt), + "relay_connection_id": relay.client.to_string()}), + None, + None, + ), + ); + let gesture = issued["result"]["token"] + .as_str() + .map(|token| json!({"claim": "user", "confirmation": token})); + let relayed = send(&mux, &relay, &v2("chief.profile.adopt", &adopt, Some("r1"), gesture)); + assert_eq!(relayed["error"]["code"], "origin.forbidden", "{relayed}"); + + // A paired device (a pairing-store credential). + let paired = connect(&mux, ClientTransport::WebSocket); + mux.control_clients.mark_person(paired.client); + assert_eq!( + forbidden(&send(&mux, &paired, &v2("chief.profile.adopt", &adopt, Some("d1"), None))), + "chief_registry_caller" + ); + + // A bound agent on a Unix connection: the agent itself, and a live Chief. + let token = command( + &mux, + &owner, + json!({"cmd": "conversation-agent-token", "participant": "agent_release"}), + )["token"] + .as_str() + .unwrap() + .to_owned(); + let agent = connect(&mux, ClientTransport::Unix); + command( + &mux, + &agent, + json!({"cmd": "conversation-bind", "participant": "agent_release", "token": token}), + ); + assert_eq!( + forbidden(&send(&mux, &agent, &v2("chief.profile.adopt", &adopt, Some("a1"), None))), + "chief_registry_caller" + ); + + // Nothing was written by any of them. + assert_eq!(chief_ids(&mux, &owner), vec!["agent_mux".to_owned()]); + + // The owner's own terminal adopts it. + let adopted = send(&mux, &owner, &v2("chief.profile.adopt", &adopt, Some("o1"), None)); + assert_eq!(adopted["ok"], true, "{adopted}"); + assert_eq!(adopted["result"]["value"]["id"], "agent_release"); + assert_eq!(chief_ids(&mux, &owner), vec!["agent_mux".to_owned(), "agent_release".to_owned()]); + // Now a live Chief, bound on its own connection: still refused. + let mut again = adopt.clone(); + again["participant_id"] = json!("agent_release"); + assert_eq!( + forbidden(&send(&mux, &agent, &v2("chief.profile.adopt", &again, Some("a2"), None))), + "chief_adopt_owner_only" + ); +} diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs index b26a4ac90bb7..3da8faa5064a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs @@ -54,6 +54,7 @@ pub(super) const fn handles(operation: ResourceOperation) -> bool { Op::ChiefProfileList | Op::ChiefProfileGet | Op::ChiefProfileCreate + | Op::ChiefProfileAdopt | Op::ChiefProfileUpdate | Op::ChiefProfileDelete | Op::ChiefProfileAvatarSet @@ -79,6 +80,10 @@ pub const CHIEF_PROFILE_WS_CAPABILITY: &str = "chief-profile-ws-v1"; /// Channels page redraw their Chiefs on it instead of polling. pub const CHIEF_EVENTS_CAPABILITY: &str = "chief-events-v1"; +/// `chief.profile.adopt`: the owner makes an existing agent participant a +/// Chief record under its own id. +pub const CHIEF_PROFILE_ADOPT_CAPABILITY: &str = "chief-profile-adopt-v1"; + pub(super) fn handle( mux: &Arc, client: u64, @@ -94,6 +99,12 @@ pub(super) fn handle( fn answer(mux: &Mux, client: u64, request: &ParsedResourceRequest) -> Result { let operation = request.envelope.operation; let fields = &request.fields; + // The participant an adopt names, read before the registry lock (the + // conversation store has its own lock; never both at once). + let adoptee = match operation { + Op::ChiefProfileAdopt => Some(adoptee(mux, operation, fields)?), + _ => None, + }; with_store(mux, operation, |store| { let caller = caller(mux, client, store)?; match operation { @@ -125,7 +136,7 @@ fn answer(mux: &Mux, client: u64, request: &ParsedResourceRequest) -> Result { let key = request.envelope.idempotency_key.clone().unwrap_or_default(); - mutate(mux, client, store, &caller, operation, fields, key) + mutate(mux, client, store, &caller, operation, fields, key, adoptee.as_ref()) } } }) @@ -144,7 +155,7 @@ pub(super) fn set_chief_avatar( let operation = Op::ChiefProfileAvatarSet; with_store(mux, operation, |store| { let caller = caller(mux, client, store)?; - mutate(mux, client, store, &caller, operation, fields, key) + mutate(mux, client, store, &caller, operation, fields, key, None) }) } @@ -203,6 +214,7 @@ fn mutate( operation: Op, fields: &Map, key: String, + adoptee: Option<&Adoptee>, ) -> Result { if key.is_empty() { return Err(ResourceError::validation_invalid( @@ -213,7 +225,10 @@ fn mutate( let profile: Map = fields .iter() .filter(|(k, _)| { - !matches!(k.as_str(), "chief" | "expected_rev" | "media_type" | "data" | "clear") + !matches!( + k.as_str(), + "chief" | "expected_rev" | "media_type" | "data" | "clear" | "participant_id" + ) }) .map(|(k, v)| (k.clone(), v.clone())) .collect(); @@ -242,6 +257,13 @@ fn mutate( .map(|avatar| avatar.sha256), _ => None, }; + if operation == Op::ChiefProfileAdopt + && (*caller != Caller::Owner || super::origin_gate::connection_page(mux, client).is_some()) + { + // Owner only, from a terminal: no page (a WebSocket or the host's page + // relay), no Chief, no paired or remote device (caller() refuses those). + return Err(forbidden_adopt()); + } if let Caller::Chief(me) = caller { self_edit_allowed(store, operation, me, &chief, &profile)?; } @@ -259,6 +281,15 @@ fn mutate( let outcome = store .mutate(&actor, &key, operation.wire_name(), &body, |tx| match operation { Op::ChiefProfileCreate => chief_registry::create(tx, &profile), + Op::ChiefProfileAdopt => match adoptee { + Some(Adoptee { id, display_name: Some(name) }) => { + chief_registry::adopt(tx, id, name, &profile) + } + Some(Adoptee { id, .. }) if id == chief_registry::DEFAULT_CHIEF => { + Ok(Err(Refusal::DefaultChief)) + } + _ => Ok(Err(Refusal::NotAgentParticipant)), + }, Op::ChiefProfileUpdate => { chief_registry::update(tx, &chief, expected_rev, &profile, None) } @@ -278,7 +309,7 @@ fn mutate( } if !replayed && rev_before != Some(record.rev) { let change = match operation { - Op::ChiefProfileCreate => "created", + Op::ChiefProfileCreate | Op::ChiefProfileAdopt => "created", Op::ChiefProfileDelete => "deleted", _ => "updated", }; @@ -290,7 +321,7 @@ fn mutate( if !replayed { mux.chiefs().brains.chief_written( &record, - operation == Op::ChiefProfileCreate, + matches!(operation, Op::ChiefProfileCreate | Op::ChiefProfileAdopt), operation == Op::ChiefProfileDelete, ); } @@ -460,6 +491,45 @@ fn with_store( run(store.as_mut().expect("chief registry opened above")) } +/// The participant `chief.profile.adopt` names: its id, and its display name +/// when it is an agent participant of a local conversation. +struct Adoptee { + id: String, + display_name: Option, +} + +fn adoptee( + mux: &Mux, + operation: Op, + fields: &Map, +) -> Result { + let id = fields.get("participant_id").and_then(Value::as_str).unwrap_or("").to_owned(); + let shaped = id.strip_prefix("agent_").is_some_and(|rest| { + (1..=64).contains(&rest.len()) + && rest.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + }); + if !shaped { + // A person, or no participant id at all: never a Chief. + return Ok(Adoptee { id, display_name: None }); + } + let summaries = + mux.with_conversations(|store| store.list()).map_err(|e| internal(operation, &e))?; + let display_name = summaries + .iter() + .flat_map(|summary| summary.participants.iter()) + .find(|p| p.id == id && p.kind == cmux_conversation::ParticipantKind::Agent) + .map(|p| p.display_name.clone()) + .filter(|name| !name.trim().is_empty()); + Ok(Adoptee { id, display_name }) +} + +fn forbidden_adopt() -> ResourceError { + crate::request_origin::forbidden( + "only the person, from a terminal on this machine, adopts an agent as a Chief", + json!({"derived": "agent", "required": "user", "reason": "chief_adopt_owner_only"}), + ) +} + fn chief_field(fields: &Map) -> &str { fields.get("chief").and_then(Value::as_str).unwrap_or("") } @@ -485,3 +555,7 @@ fn internal(operation: Op, error: &anyhow::Error) -> ResourceError { json!({"message": format!("{error:#}")}), ) } + +#[cfg(test)] +#[path = "chief_adopt_tests.rs"] +mod adopt_tests; diff --git a/cmux-tui/crates/cmux-tui/src/cli/chief/messages.rs b/cmux-tui/crates/cmux-tui/src/cli/chief/messages.rs index 9084a31eac3f..f27638803fed 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/chief/messages.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/chief/messages.rs @@ -41,7 +41,7 @@ pub(super) struct Messages { } static ENGLISH: Messages = Messages { - usage: "usage: cmux chief [-p TEXT] [--timeout SECONDS] [--history N]\n cmux chief engine [--harness H] [--model M] [--effort E] | cmux chief stop | cmux chief shutdown\n cmux chief list | new | edit | delete (cmux chief new --help)\n\n Talk to your Chief: the same conversation as Home, live in both.\n On a terminal it opens the chat. With -p, or with text on stdin, it sends\n one message, prints the Chief's reply and exits when the turn ends.\n\n -p, --prompt TEXT send TEXT (pipe mode)\n --timeout SECONDS pipe mode: give up after SECONDS (default 1800, 0: no limit; exit 124)\n --no-wait-agents pipe mode: exit when the turn that answers ends, not after the subagents it started\n --history N chat: show the last N messages first (default 20)\n --chief-home DIR an isolated Chief (its own daemon and brain), also CMUX_CHIEF_HOME;\n without it and without --socket the CLI starts your Chief when it is not running\n --json pipe mode: print the reply messages as JSON lines\n engine show the Chief's engine, or set --harness, --model, --effort\n stop stop the Chief's running turn\n shutdown stop the Chief: its brain, acpmux daemon, agent sessions and session daemon", + usage: "usage: cmux chief [-p TEXT] [--timeout SECONDS] [--history N]\n cmux chief engine [--harness H] [--model M] [--effort E] | cmux chief stop | cmux chief shutdown\n cmux chief list | new | adopt | edit | delete (cmux chief new --help)\n\n Talk to your Chief: the same conversation as Home, live in both.\n On a terminal it opens the chat. With -p, or with text on stdin, it sends\n one message, prints the Chief's reply and exits when the turn ends.\n\n -p, --prompt TEXT send TEXT (pipe mode)\n --timeout SECONDS pipe mode: give up after SECONDS (default 1800, 0: no limit; exit 124)\n --no-wait-agents pipe mode: exit when the turn that answers ends, not after the subagents it started\n --history N chat: show the last N messages first (default 20)\n --chief-home DIR an isolated Chief (its own daemon and brain), also CMUX_CHIEF_HOME;\n without it and without --socket the CLI starts your Chief when it is not running\n --json pipe mode: print the reply messages as JSON lines\n engine show the Chief's engine, or set --harness, --model, --effort\n stop stop the Chief's running turn\n shutdown stop the Chief: its brain, acpmux daemon, agent sessions and session daemon", help: "Enter sends. Alt+Enter or Ctrl+J adds a line. Up and Down recall sent messages.\nCtrl+C stops the Chief's turn (or clears the input); Ctrl+D quits.\n/model [NAME] show or set the Chief's model\n/effort [LEVEL] show or set the Chief's effort\n/thoughts show or hide the Chief's thinking\n/help this text\n/quit leave (the Chief keeps working)", you: "You", chief: "Chief", @@ -80,7 +80,7 @@ static ENGLISH: Messages = Messages { }; static JAPANESE: Messages = Messages { - usage: "使い方: cmux chief [-p TEXT] [--timeout SECONDS] [--history N]\n cmux chief engine [--harness H] [--model M] [--effort E] | cmux chief stop | cmux chief shutdown\n cmux chief list | new | edit | delete (cmux chief new --help)\n\n Chief と話します。Home と同じ会話で、両方にすぐ表示されます。\n 端末ではチャットを開きます。-p または標準入力のテキストがあると、\n メッセージを 1 件送り、Chief の返信を表示して、ターンの終わりに終了します。\n\n -p, --prompt TEXT TEXT を送信 (パイプモード)\n --timeout SECONDS パイプモード: SECONDS 秒で中止 (既定 1800、0 で無制限、終了コード 124)\n --no-wait-agents パイプモード: 開始したサブエージェントを待たず、答えたターンの終わりに終了\n --history N チャット: 最初に直近 N 件を表示 (既定 20)\n --chief-home DIR 独立した Chief (専用のデーモンと頭脳)。CMUX_CHIEF_HOME でも可。\n 指定も --socket もないとき、動いていなければあなたの Chief を開始します\n --json パイプモード: 返信を JSON Lines で出力\n engine Chief のエンジンを表示、または --harness、--model、--effort を設定\n stop Chief の実行中のターンを停止\n shutdown Chief を停止 (頭脳、acpmux デーモン、エージェントのセッション、セッションデーモン)", + usage: "使い方: cmux chief [-p TEXT] [--timeout SECONDS] [--history N]\n cmux chief engine [--harness H] [--model M] [--effort E] | cmux chief stop | cmux chief shutdown\n cmux chief list | new | adopt | edit | delete (cmux chief new --help)\n\n Chief と話します。Home と同じ会話で、両方にすぐ表示されます。\n 端末ではチャットを開きます。-p または標準入力のテキストがあると、\n メッセージを 1 件送り、Chief の返信を表示して、ターンの終わりに終了します。\n\n -p, --prompt TEXT TEXT を送信 (パイプモード)\n --timeout SECONDS パイプモード: SECONDS 秒で中止 (既定 1800、0 で無制限、終了コード 124)\n --no-wait-agents パイプモード: 開始したサブエージェントを待たず、答えたターンの終わりに終了\n --history N チャット: 最初に直近 N 件を表示 (既定 20)\n --chief-home DIR 独立した Chief (専用のデーモンと頭脳)。CMUX_CHIEF_HOME でも可。\n 指定も --socket もないとき、動いていなければあなたの Chief を開始します\n --json パイプモード: 返信を JSON Lines で出力\n engine Chief のエンジンを表示、または --harness、--model、--effort を設定\n stop Chief の実行中のターンを停止\n shutdown Chief を停止 (頭脳、acpmux デーモン、エージェントのセッション、セッションデーモン)", help: "Enter で送信。Alt+Enter または Ctrl+J で改行。上下キーで送信済みメッセージを呼び出します。\nCtrl+C で Chief のターンを停止 (または入力を消去)、Ctrl+D で終了します。\n/model [NAME] Chief のモデルを表示または設定\n/effort [LEVEL] Chief の effort を表示または設定\n/thoughts Chief の思考の表示を切り替え\n/help このテキスト\n/quit 終了 (Chief は作業を続けます)", you: "あなた", chief: "Chief", diff --git a/cmux-tui/crates/cmux-tui/src/cli/chief/registry.rs b/cmux-tui/crates/cmux-tui/src/cli/chief/registry.rs index b4a7861b46d2..e40eb3c32b9b 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/chief/registry.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/chief/registry.rs @@ -1,5 +1,5 @@ -//! `cmux chief list|new|edit|delete`: the person's Chiefs, through the -//! daemon's Chief registry (`chief.profile.list`, `chief.profile.create`, `chief.profile.update`, +//! `cmux chief list|new|adopt|edit|delete`: the person's Chiefs, through the +//! daemon's Chief registry (`chief.profile.list`, `chief.profile.create`, `chief.profile.adopt`, `chief.profile.update`, //! `chief.profile.delete`, `chief.profile.avatar.set`). One call per command on the Chief //! home's owner, as `cmux chief engine` finds it; nothing is started. A //! CHIEF argument is an id or a display name that names one Chief. @@ -18,7 +18,7 @@ use crate::cli::OutputMode; const MAX_AVATAR_BYTES: u64 = 1024 * 1024; /// The registry words `cmux chief` takes first. -pub(super) const WORDS: [&str; 4] = ["list", "new", "edit", "delete"]; +pub(super) const WORDS: [&str; 5] = ["list", "new", "adopt", "edit", "delete"]; #[derive(Debug, Default, PartialEq, Eq)] struct Request { @@ -40,6 +40,8 @@ struct Text { no_such: &'static str, ambiguous: &'static str, created: &'static str, + needs_agent: &'static str, + adopted: &'static str, updated: &'static str, deleted: &'static str, default_mark: &'static str, @@ -50,12 +52,14 @@ struct Text { } static ENGLISH: Text = Text { - usage: "usage: cmux chief list\n cmux chief new --name NAME [PROFILE]\n cmux chief edit CHIEF [PROFILE] [--clear-avatar]\n cmux chief delete CHIEF\n\n Your Chiefs: each has its own name, role, picture, personality, instructions,\n tools and memory. CHIEF is an id (agent_...) or a name.\n\n PROFILE:\n --name NAME 1 to 100 characters\n --role TEXT one line, for example \"Release captain\"\n --personality TEXT tone and style\n --instructions TEXT added to the Chief's system prompt (after the base rules)\n --instructions-file F the same, read from file F\n --avatar FILE a PNG, JPEG, GIF or WebP picture, at most 1 MiB\n --tools a,b from browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces\n --harnesses a,b the harnesses its subagents may use (empty: all)\n --self-edit a,b what the Chief may change itself: avatar, display_name, role, personality, instructions\n --harness H --model M --effort E its engine\n --chief-home DIR an isolated Chief home", + usage: "usage: cmux chief list\n cmux chief new --name NAME [PROFILE]\n cmux chief adopt AGENT [PROFILE]\n cmux chief edit CHIEF [PROFILE] [--clear-avatar]\n cmux chief delete CHIEF\n\n Your Chiefs: each has its own name, role, picture, personality, instructions,\n tools and memory. CHIEF is an id (agent_...) or a name.\n adopt makes AGENT, an agent already in your conversations (agent_...), a Chief\n under its own id: its conversations and history stay; it gets a new Chief's defaults.\n\n PROFILE:\n --name NAME 1 to 100 characters\n --role TEXT one line, for example \"Release captain\"\n --personality TEXT tone and style\n --instructions TEXT added to the Chief's system prompt (after the base rules)\n --instructions-file F the same, read from file F\n --avatar FILE a PNG, JPEG, GIF or WebP picture, at most 1 MiB\n --tools a,b from browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces\n --harnesses a,b the harnesses its subagents may use (empty: all)\n --self-edit a,b what the Chief may change itself: avatar, display_name, role, personality, instructions\n --harness H --model M --effort E its engine\n --chief-home DIR an isolated Chief home", needs_name: "cmux chief new needs --name NAME", needs_chief: "give the Chief: an id (agent_...) or its name; cmux chief list shows them", no_such: "no Chief is named {chief}; cmux chief list shows them", ambiguous: "more than one Chief is named {chief}; use its id", created: "created {name} ({id})", + needs_agent: "cmux chief adopt needs the agent: its participant id (agent_...)", + adopted: "adopted {name} ({id}) as a Chief", updated: "updated {name} ({id}, rev {rev})", deleted: "deleted {name} ({id})", default_mark: "default", @@ -66,12 +70,14 @@ static ENGLISH: Text = Text { }; static JAPANESE: Text = Text { - usage: "使い方: cmux chief list\n cmux chief new --name NAME [PROFILE]\n cmux chief edit CHIEF [PROFILE] [--clear-avatar]\n cmux chief delete CHIEF\n\n あなたの Chief たち。それぞれに名前、役割、画像、性格、指示、ツール、記憶があります。\n CHIEF は id (agent_...) または名前です。\n\n PROFILE:\n --name NAME 1 から 100 文字\n --role TEXT 1 行。例: \"リリース担当\"\n --personality TEXT 口調とスタイル\n --instructions TEXT Chief のシステムプロンプトに追加 (基本ルールの後)\n --instructions-file F 同じ内容をファイル F から読む\n --avatar FILE PNG、JPEG、GIF または WebP の画像 (1 MiB まで)\n --tools a,b browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces から\n --harnesses a,b サブエージェントが使えるハーネス (空ならすべて)\n --self-edit a,b Chief が自分で変えられる項目: avatar, display_name, role, personality, instructions\n --harness H --model M --effort E エンジン\n --chief-home DIR 独立した Chief ホーム", + usage: "使い方: cmux chief list\n cmux chief new --name NAME [PROFILE]\n cmux chief adopt AGENT [PROFILE]\n cmux chief edit CHIEF [PROFILE] [--clear-avatar]\n cmux chief delete CHIEF\n\n あなたの Chief たち。それぞれに名前、役割、画像、性格、指示、ツール、記憶があります。\n CHIEF は id (agent_...) または名前です。\n adopt は会話にすでにいるエージェント AGENT (agent_...) を、その id のまま Chief にします。\n 会話と履歴はそのまま残り、新しい Chief の既定値が付きます。\n\n PROFILE:\n --name NAME 1 から 100 文字\n --role TEXT 1 行。例: \"リリース担当\"\n --personality TEXT 口調とスタイル\n --instructions TEXT Chief のシステムプロンプトに追加 (基本ルールの後)\n --instructions-file F 同じ内容をファイル F から読む\n --avatar FILE PNG、JPEG、GIF または WebP の画像 (1 MiB まで)\n --tools a,b browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces から\n --harnesses a,b サブエージェントが使えるハーネス (空ならすべて)\n --self-edit a,b Chief が自分で変えられる項目: avatar, display_name, role, personality, instructions\n --harness H --model M --effort E エンジン\n --chief-home DIR 独立した Chief ホーム", needs_name: "cmux chief new には --name NAME が必要です", needs_chief: "Chief を指定してください: id (agent_...) または名前。cmux chief list で一覧を表示します", no_such: "{chief} という名前の Chief はいません。cmux chief list で一覧を表示します", ambiguous: "{chief} という名前の Chief が複数います。id を使ってください", created: "{name} を作成しました ({id})", + needs_agent: "cmux chief adopt にはエージェントの参加者 id (agent_...) が必要です", + adopted: "{name} ({id}) を Chief にしました", updated: "{name} を更新しました ({id}、rev {rev})", deleted: "{name} を削除しました ({id})", default_mark: "既定", @@ -145,6 +151,9 @@ fn parse(args: &[String]) -> Result { if word == "list" && (request.chief.is_some() || !request.fields.is_empty()) { return Err("cmux chief list takes no Chief or profile".into()); } + if word == "adopt" && !request.help && request.chief.is_none() { + return Err(text().needs_agent.into()); + } if word == "new" && request.chief.is_some() { return Err("cmux chief new takes --name, not a Chief".into()); } @@ -179,7 +188,7 @@ impl Request { /// connection. `None` for a read (list). fn write(&self) -> Option<&str> { match self.word.as_str() { - "new" | "edit" | "delete" => Some(self.word.as_str()), + "new" | "adopt" | "edit" | "delete" => Some(self.word.as_str()), _ => None, } } @@ -356,7 +365,14 @@ fn execute(link: &mut Link, request: &Request, output: OutputMode) -> Result<(), return Ok(()); } let avatar = request.avatar.as_deref().map(read_avatar).transpose().map_err(Failure::Usage)?; - let mut record = if request.word == "new" { + let mut record = if request.word == "adopt" { + let agent = request.chief.as_deref().ok_or_else(|| Failure::Usage(t.needs_agent.into()))?; + let mut params = request.fields.clone(); + params.insert("participant_id".into(), json!(agent)); + let key = super::link::new_message_id(); + let adopted = link.call("chief.profile.adopt", Value::Object(params), Some(&key))?; + adopted.get("value").cloned().unwrap_or(Value::Null) + } else if request.word == "new" { if !request.fields.contains_key("display_name") { return Err(Failure::Usage(t.needs_name.into())); } @@ -406,13 +422,21 @@ fn execute(link: &mut Link, request: &Request, output: OutputMode) -> Result<(), match link.call("chief.profile.avatar.set", params, Some(&key)) { Ok(set) => record = set.get("value").cloned().unwrap_or(Value::Null), Err(error) => { - let template = if request.word == "new" { t.created } else { t.updated }; + let template = match request.word.as_str() { + "new" => t.created, + "adopt" => t.adopted, + _ => t.updated, + }; let done = format!("{}; {}", fill(&record, template), t.picture_not_set); return Err(Failure::Partial { done, error }); } } } - let template = if request.word == "new" { t.created } else { t.updated }; + let template = match request.word.as_str() { + "new" => t.created, + "adopt" => t.adopted, + _ => t.updated, + }; print_result(&record, template, json_out); Ok(()) } diff --git a/cmux-tui/crates/cmux-tui/src/cli/command/tests/transport_path_coverage_tests.rs b/cmux-tui/crates/cmux-tui/src/cli/command/tests/transport_path_coverage_tests.rs index 869c9c74165c..411c5167cab1 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/command/tests/transport_path_coverage_tests.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/command/tests/transport_path_coverage_tests.rs @@ -227,11 +227,12 @@ fn every_safe_transport_operation_has_a_noun_first_path() { | "chief.engine.get" | "chief.engine.set" | "chief.stop" - // `cmux chief list|new|edit|delete` send these + // `cmux chief list|new|adopt|edit|delete` send these // (cli/chief/registry.rs), outside the noun grammar. | "chief.profile.list" | "chief.profile.get" | "chief.profile.create" + | "chief.profile.adopt" | "chief.profile.update" | "chief.profile.delete" | "chief.profile.avatar.set" diff --git a/cmux-tui/crates/cmux-tui/src/cli/mcp/v2_tools.rs b/cmux-tui/crates/cmux-tui/src/cli/mcp/v2_tools.rs index d3a00db810ec..0e6d2677d6e8 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/mcp/v2_tools.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/mcp/v2_tools.rs @@ -96,6 +96,7 @@ pub(super) const EXCLUDED: &[(&str, &str)] = &[ ("chief.profile.list", CHIEF_REGISTRY_REASON), ("chief.profile.get", CHIEF_REGISTRY_REASON), ("chief.profile.create", CHIEF_REGISTRY_REASON), + ("chief.profile.adopt", CHIEF_REGISTRY_REASON), ("chief.profile.update", CHIEF_REGISTRY_REASON), ("chief.profile.delete", CHIEF_REGISTRY_REASON), ("chief.profile.avatar.set", CHIEF_REGISTRY_REASON), diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief.rs index 3a68c5d58842..84ff885bf57c 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/chief.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief.rs @@ -6,6 +6,8 @@ use super::*; use serde_json::{Value, json}; use std::os::fd::FromRawFd; +#[path = "chief_adopt.rs"] +mod adopt; #[path = "chief_autostart.rs"] mod autostart; #[cfg(unix)] diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs new file mode 100644 index 000000000000..6def1412e52d --- /dev/null +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs @@ -0,0 +1,251 @@ +//! `chief.profile.adopt` (`chief-profile-adopt-v1`) against a real headless +//! daemon: the person makes an agent that is already in a conversation a +//! Chief record under its own id with `cmux chief adopt`. The DM and its +//! history stay the agent's, its credential still works, and the record gets +//! a new Chief's defaults. Each refusal is one behavior: the default Chief, a +//! person, an unknown agent, a live Chief, a deleted Chief (its tombstone and +//! kept memory are never revived), an idempotency key reused for another +//! request, and an agent caller of the CLI. The adopt reaches the Chief brain +//! supervisor like a create. + +use serde_json::{Value, json}; + +use super::*; + +fn v2(conn: &mut Conn, operation: &str, params: Value, key: Option<&str>) -> Value { + let id = format!("adopt-{}", conn.next); + conn.next += 1; + let mut params = params.as_object().cloned().unwrap_or_default(); + params.insert("machine".into(), json!("current")); + params.insert("session".into(), json!("current")); + let mut frame = json!({"protocol": "cmux.protocol/2", "type": "request", "id": id, + "operation": operation, "params": params}); + if let Some(key) = key { + frame["idempotency_key"] = json!(key); + } + writeln!(conn.writer, "{frame}").unwrap(); + loop { + let line = conn.line(); + if line["id"] == json!(id) && line["type"] == "response" { + return line; + } + } +} + +fn ok(response: &Value) -> Value { + assert_eq!(response["ok"], true, "{response}"); + response["result"].clone() +} + +fn reason(response: &Value) -> String { + assert_eq!(response["ok"], false, "{response}"); + let code = response["error"]["code"].as_str().unwrap_or("").to_owned(); + match response["error"]["details"]["reason"].as_str() { + Some(reason) if code == "operation.failed" => reason.to_owned(), + _ => code, + } +} + +/// A DM between the person and `agent` (display name `name`); its id. +fn dm_with(home: &mut Conn, agent: &str, name: &str) -> String { + let created = home.request( + "conversation-create", + json!({"idempotency_key": format!("dm-{agent}"), "actor": "user_local", "title": name, + "participants": [ + {"id": "user_local", "kind": "human", "display_name": "Tester"}, + {"id": agent, "kind": "agent", "display_name": name, "agent_class": "mux", + "acp_session": agent}]}), + ); + created["conversation"]["id"].as_str().unwrap().to_owned() +} + +fn say(conn: &mut Conn, conversation: &str, key: &str, text: &str) { + conn.request( + "conversation-op", + json!({"conversation": conversation, "idempotency_key": key, + "op": {"kind": "message.send", "client_msg_id": key, + "parts": [{"type": "text", "text": text}]}}), + ); +} + +fn texts(home: &mut Conn, conversation: &str) -> Vec<(String, String)> { + let snapshot = + home.request("conversation-snapshot", json!({"conversation": conversation, "tail": 50})); + snapshot["messages"] + .as_array() + .unwrap() + .iter() + .map(|m| { + ( + m["author"].as_str().unwrap_or("").to_owned(), + m["parts"][0]["text"].as_str().unwrap_or("").to_owned(), + ) + }) + .collect() +} + +#[test] +fn the_person_adopts_an_agent_and_it_keeps_its_dm_history_and_credential() { + let server = HeadlessServer::start("chief-adopt"); + let mut home = Conn::open(&server.socket); + let identity = home.request("identify", json!({})); + assert!( + identity["capabilities"].as_array().unwrap().iter().any(|c| c == "chief-profile-adopt-v1"), + "{identity}" + ); + let dm = dm_with(&mut home, "agent_release", "Release Chief"); + let token = + home.request("conversation-agent-token", json!({"participant": "agent_release"}))["token"] + .as_str() + .unwrap() + .to_owned(); + let mut agent = Conn::open(&server.socket); + agent.request("conversation-bind", json!({"participant": "agent_release", "token": token})); + say(&mut home, &dm, "q1", "Is the train ready?"); + say(&mut home, &dm, "q2", "The 0.66 notes too, please."); + let before = texts(&mut home, &dm); + + // The CLI adopts it: its id and name, a new Chief's defaults, the role given. + let output = + chief_cli(&server, &["adopt", "agent_release", "--role", "Release trains", "--json"], ""); + assert_success(&output); + let record: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(record["id"], "agent_release", "{record}"); + assert_eq!(record["display_name"], "Release Chief"); + assert_eq!(record["role"], "Release trains"); + assert_eq!(record["self_edit"], json!(["avatar"])); + assert_eq!(record["tools"], json!(["spawn", "tell", "workspaces"])); + assert_eq!(record["instructions"], ""); + assert_eq!(record["is_default"], false); + + // The DM and its history are the same, and the agent's credential still posts there. + assert_eq!(texts(&mut home, &dm), before); + say(&mut agent, &dm, "a2", "Adopted and still here."); + let after = texts(&mut home, &dm); + assert_eq!(after.len(), before.len() + 1); + assert_eq!( + after.last().unwrap(), + &("agent_release".to_owned(), "Adopted and still here.".to_owned()) + ); + let listed = ok(&v2(&mut home, "chief.profile.list", json!({}), None)); + let ids: Vec<&str> = + listed["chiefs"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert_eq!(ids, vec!["agent_mux", "agent_release"], "{listed}"); + + // Idempotent per key: a retry is the committed record; another request under the key is refused. + let dm_infra = dm_with(&mut home, "agent_infra", "Infra Chief"); + let first = ok(&v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_infra", "display_name": "Infra"}), + Some("adopt-infra"), + )); + assert_eq!(first["replayed"], false); + assert_eq!(first["value"]["display_name"], "Infra"); + let again = ok(&v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_infra", "display_name": "Infra"}), + Some("adopt-infra"), + )); + assert_eq!(again["replayed"], true); + assert_eq!(again["value"], first["value"]); + let clash = v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_infra", "display_name": "Other"}), + Some("adopt-infra"), + ); + assert_eq!(reason(&clash), "idempotency.conflict"); + let _ = dm_infra; + + // Typed refusals. + let refusal = |home: &mut Conn, participant: &str, key: &str| { + reason(&v2(home, "chief.profile.adopt", json!({"participant_id": participant}), Some(key))) + }; + let _ = dm_with(&mut home, "agent_mux", "mux"); + assert_eq!(refusal(&mut home, "agent_mux", "r-default"), "default_chief"); + assert_eq!(refusal(&mut home, "user_local", "r-person"), "not_an_agent_participant"); + assert_eq!(refusal(&mut home, "agent_nobody", "r-unknown"), "not_an_agent_participant"); + assert_eq!(refusal(&mut home, "agent_release", "r-live"), "already_chief"); + + // A deleted Chief that was in a conversation: its id is never revived. + let made = + ok(&v2(&mut home, "chief.profile.create", json!({"display_name": "Gone"}), Some("mk"))); + let gone = made["value"]["id"].as_str().unwrap().to_owned(); + let _ = dm_with(&mut home, &gone, "Gone"); + ok(&v2(&mut home, "chief.profile.delete", json!({"chief": gone}), Some("rm"))); + assert_eq!(refusal(&mut home, &gone, "r-deleted"), "deleted_chief"); + + // The CLI refuses an agent caller before it connects; nothing is written. + let dm_design = dm_with(&mut home, "agent_design", "Design Chief"); + let output = chief_cli_as( + &server, + &["adopt", "agent_design", "--json"], + "", + &[("ACPMUX_SESSION_ID", "s_chief_turn")], + ); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let error = json_error(&output); + assert_eq!(error["code"], "origin.forbidden", "{error}"); + assert_eq!(error["details"]["reason"], "agent_caller", "{error}"); + assert_eq!(error["details"]["marker"], "ACPMUX_SESSION_ID", "{error}"); + let listed = ok(&v2(&mut home, "chief.profile.list", json!({}), None)); + assert!( + !listed["chiefs"].as_array().unwrap().iter().any(|c| c["id"] == "agent_design"), + "{listed}" + ); + let _ = dm_design; +} + +const BRAIN_STUB: &str = r#"#!/bin/sh +n=$(cat "$MUX_HOME/starts" 2>/dev/null || echo 0) +n=$((n+1)) +echo "$n" > "$MUX_HOME/starts" +exec sleep 600 +"#; + +/// An adopt is a committed Chief write like a create: the brain supervisor +/// starts the adopted Chief's brain. +#[test] +fn an_adopted_chief_reaches_the_brain_supervisor_like_a_create() { + use std::os::unix::fs::PermissionsExt; + let files = unique_temp_dir("adopt-b"); + fs::create_dir_all(&files).unwrap(); + let stub = files.join("brain.sh"); + fs::write(&stub, BRAIN_STUB).unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap(); + let chief_home = files.join("chief-home"); + fs::create_dir_all(&chief_home).unwrap(); + let brains = json!({"executable": stub, "home": chief_home, + "app": {"bin_dir": "/opt/cmux-test/bin", "user_name": "Test Person", + "chief_title": "Chief", "acpmux_bin": "/opt/cmux-test/bin/acpmux"}}) + .to_string(); + let mut server = HeadlessServer::start_with_options( + "chief-adopt-brains", + None, + None, + &[("CMUX_TUI_CHIEF_BRAINS", brains.as_str())], + ); + let mut home = Conn::open(&server.socket); + let _ = dm_with(&mut home, "agent_release", "Release Chief"); + ok(&v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_release"}), + Some("adopt-release"), + )); + let id = "agent_release"; + let brain = chief_home.join("c").join(id[id.len() - 8..].to_ascii_lowercase()); + let starts = brain.join("starts"); + let deadline = Instant::now() + Duration::from_secs(20); + while !starts.exists() { + assert!(Instant::now() < deadline, "the adopted Chief's brain never started"); + std::thread::sleep(Duration::from_millis(25)); + } + let daemon = i32::try_from(server.child.id()).unwrap(); + // SAFETY: the test's own daemon child. + unsafe { libc::kill(daemon, libc::SIGTERM) }; + let _ = server.child.wait(); + let _ = fs::remove_dir_all(&files); +} diff --git a/cmux-tui/spec/cli.md b/cmux-tui/spec/cli.md index 9e4535bf73e5..f7cc1b0a1865 100644 --- a/cmux-tui/spec/cli.md +++ b/cmux-tui/spec/cli.md @@ -715,7 +715,11 @@ person's Chiefs with the Chief registry (`chief.profile.list`, `chief.profile.cr Chief's display name; `` is `--name`, `--role`, `--personality`, `--instructions` or `--instructions-file`, `--avatar `, `--tools`, `--harnesses`, `--self-edit` (comma lists) and `--harness`, -`--model`, `--effort`. `--json` prints the record. It refuses a socket under +`--model`, `--effort`. `chief adopt []` (`chief.profile.adopt`) +makes an agent already in the person's conversations a Chief under its own +id; its conversations and history stay. Every write (new, adopt, edit, +delete) is refused for an agent caller (`origin.forbidden`, reason +`agent_caller`). `--json` prints the record. It refuses a socket under `~/.cmux/brains/` (a Chief brain's own session, where a client acts as the Chief) and `--machine`. Exit codes: 0, 1 refused, 2 usage, 3 transport, 124 timeout. diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index 6d2c60700221..252cbec2a05a 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -351,7 +351,7 @@ With `daemon-build-v1`, `build_id` names the exact build of the binary that serv `capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `daemon-handoff-force-v1` advertises the optional `force` field on `shutdown-daemon`. `browser-provider-v1` advertises the trusted-local, connection-scoped native browser provider lease used by cmux-browser and local automation. `browser-provider-native-hosting-v1` advertises the optional `hosting` field of `register-browser-provider`: a `"native"` provider presents its pages itself, so the daemon never overrides their device metrics and screencasts them only while a frame viewer is attached. `browser-pointer-frame-guard-v1` advertises authoritative `pointer_frame_seq` and `pointer_frame_floor_seq` browser attach/frame state plus the additive `browser-frame-presented`, `browser-mouse-guarded`, and `browser-wheel-guarded` commands. Each admitted bitmap receives a new guard even when its document and dimensions match the previous bitmap. The reported floor through latest range proves route membership only. `browser-frame-presented` advances one exact acknowledged token for that connection, and only that token authorizes a new guarded pointer action. A guarded pointer command implicitly acknowledges its own token. Each connection retains one token, while the bounded browser input queue owns actions admitted before a later presentation. Navigation or geometry changes clear the range and all acknowledgements. An accepted press keeps its original guard for motion across ordinary repaints while document and geometry remain valid; invalidation suppresses further motion but retains its balancing release. A capable client echoes that value in `set-client-info`; browser attach requires the bilateral capability while PTY attach remains available without it. The legacy `browser-mouse` and `browser-wheel` schemas retain their optional guard, but guarded servers reject a missing guard before surface lookup. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `pane-browser-kind-v1` advertises `kind` and `url` on `split` and `new-pane-right`: `kind:"browser"` with a non-empty `url` puts a browser tab at that URL in the new pane, through the same `pane.split` commit and restart recovery as a terminal pane; public `pane.split` stays terminal-only. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `view-attachment-lease-v1` returns a connection-owned lease for each attach and enables lease-fenced sizing. `view-attachment-detach-v1` enables targeted stream cleanup. `creation-receipts-v1` enables idempotent destination creation, `creation-attempt-keys-v1` separates a stable correlation from the same-key or new-key execution attempt selected by `session.creation.resolve`, and `creation-selector-fallbacks-v1` adds bounded ordered destination continuations. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. `terminal-idle-close-v1` advertises `set-terminal-idle-policy` and the owner-side reaper that closes a terminal after its policy elapses with no attached view. `terminal-pending-sequence-v1` advertises the separate `pending` field on byte-attach `vt-state` and `resized` events; a client that echoes it in `set-client-info` receives it (see `events.md`). `terminal-placement-env-v1` advertises a caller-chosen `terminal_id` on `new-tab`, `split`, `new-pane`, and `new-pane-right`, `cwd` and `env` on `new-pane` and `new-pane-right`, and `terminal_id`/`terminal_incarnation` in all four results. `split-client-keys-v1` advertises the client-minted `pane_id` and `tab_id` on `split`, `new-pane`, and `new-pane-right`, keyed retries that return the first result with `replayed`, and `pane_id`/`tab_id` in their results (see "Client-minted pane ids"). `terminal-resources-v1` advertises `terminal-resources`, which reads the CPU time and memory of each terminal's shell, descendants, and terminal host at request time. `batch-close-v1` advertises `close-tabs` and the optional `end_terminals` field on `close-pane`, `close-screen`, `close-workspace`, and `close-tab-group`: many placements and the terminals they end close in one durable commit. `close-workspace-if-empty-v1` advertises the optional `if_empty` field on `close-workspace`: the close commits only when the workspace has no tab at commit time, else it fails with `workspace_not_empty` and changes nothing. `end-terminals-keep-layout-v1` advertises `keep_layout` on `shutdown-daemon`: with `end_terminals`, placed terminals keep their tabs across the handoff. `terminal-reap-v1` advertises the owner-side reaper that ends a terminal after it has had no tab placement for the reap grace period (active only when the daemon was started with `--terminal-reap-grace-seconds`), `set-terminal-keep`, the `keep` field on `new-tab`, `split`, and `create-terminal`, the `terminal-reaped` event, and `end_terminals` on `shutdown-daemon`. `dock-columns-v1` advertises `set-column-dock` and the optional `Screen.columns[].dock` field for every edge; the resource API operation `column.update` (resource-operations-v2.json) sets the same flag and the column width. `terminal-env-v1` advertises the per-terminal `env` object on `new-tab`, `split`, and `create-terminal`, and `cwd` on `split`. `tab-groups-v1` advertises Chrome-style tab groups: the `*-tab-group` commands, `Pane.tab_groups`, and `Tab.group`. `saved-tab-groups-v1` advertises saved groups: `save-tab-group`, `unsave-tab-group`, `delete-saved-tab-group`, `list-saved-tab-groups`, and `reopen-saved-tab-group`. `notification-ack-v1` advertises `ack-tab-notifications`, `list-notifications`, durable notification acknowledgement, and `Workspace.unread_count`. `tab-drag-v1` advertises the single-command tab drag outcomes `move-tab-to-split`, `move-tab-to-column`, and `move-tab-to-new-workspace`, layout undo for same-screen tab drags and cross-pane `move-tab`, and the optional `transaction` field on every drag command, echoed in the resulting `tab-changed` delta. `tab-workspace-name-v1` advertises the optional `name` field on `move-tab-to-new-workspace`: the new workspace takes that name in the same commit. `frontend-browser-tabs-v1` advertises `new-frontend-browser-tab`, `update-frontend-browser-tab`, and the frontend browser tab fields. `tab-metadata-v1` advertises `set-tab-pinned`, pinned-first tab order, the `Tab.pinned`, `Tab.cwd`, `Tab.git_branch`, and `Tab.git_detached` fields, and the `tab-changed` delta. `workspace-metadata-v1` advertises `set-workspace-metadata`, the `Workspace.color`, `Workspace.icon`, and `Workspace.title` fields, and the `workspace-changed` delta. `workspace-pin-v1` advertises the `pinned` field on `set-workspace-metadata` and `Workspace.pinned`. `notification-mark-unread-v1` advertises the `marked_unread` field on `set-workspace-metadata` and `Workspace.marked_unread`. `workspace-groups-v1` advertises durable sidebar groups: the `*-workspace-group` commands, `move-workspace-to-group`, `Tree.groups`, and `Workspace.group`. `loopback-forward-v1` advertises multiplexed TCP streams to the daemon machine's own loopback services (see "Loopback forwarding"); a Unix client echoes it in `set-client-info` before its first `loopback-open`. `fs-v1` advertises the `fs.*` file ops and their byte streams (see "File system (fs-v1)"); only cmux Cloud hosts serve it. `session-identity-v1` advertises `identify.session_id` (the durable `registry_id`, stable across restarts and upgrades) and `identify.machine_name` (the host name, at most 255 bytes, no control characters). `remote-terminal-tabs-v1` advertises `new-remote-terminal-tab`, `update-remote-terminal-tab`, `remote-terminal-snapshot`, and the `kind:"remote-terminal"` tab with its `remote` object. `detached-terminals-v1` advertises `detached` on `create-terminal` and `terminal_resource_id` in its detached result. `profiles-v1` advertises the home session's personal state: `list-personal`, the `*-profile` room commands, `set-profile-follows`, `pin-workspace`, `unpin-workspace`, `put-session`, `forget-session`, `import-session-organization`, the `*-personal-group` commands, `set-personal-workspace`, and the `personal-changed` event. `personal-terminals-v1` advertises `set-personal-terminal` and `list-personal.terminals`. `screen-metadata-v1` advertises `set-screen-metadata`, `set-screen-pinned`, `move-screen`, the `screen_name`, `color`, `icon`, `pinned`, `index`, `group`, and `cwd` fields on `new-screen` (whose result then also carries `screen`), the `Screen.color`, `Screen.icon`, `Screen.pinned`, and `Screen.group` fields, and the `screen-changed` delta. `screen-groups-v1` advertises Chrome-style screen groups: the `*-screen-group` commands, saved screen groups, and `Workspace.screen_groups`. `browser-profiles-v1` advertises browser profile records in personal state: `browser_profiles` in `list-personal` and `create-browser-profile`, `update-browser-profile`, `move-browser-profile`, and `delete-browser-profile`. `notification-source-v1` advertises `source` on `notify`, the `notification` event, the tab `notification` marker, and `list-notifications` rows (`extra.source` in resource notification snapshots), and daemon-side desktop notifications from terminal output (OSC 9, OSC 777 `notify`, kitty OSC 99) with source `terminal`. `terminal-shell-args-v1` advertises `shell_args` on `new-tab`, `split`, `new-pane`, `new-pane-right`, and `create-terminal`: the terminal runs its `SHELL` from `env` (else the daemon's default shell) with those arguments, so a frontend can apply Ghostty's argv-based shell integration (bash `--posix` with `ENV`, nushell `--execute`). `terminal-frontend-shell-integration-v1` lets a frontend that resolves Ghostty's shell integration itself (from the user's `shell-integration` and `shell-integration-features`, into the terminal's `env` and `shell_args`) echo it in `set-client-info`; the terminals that connection creates with a `SHELL` in `env` then run that shell exactly as given (stored as `argv`), with no host-added integration, on `new-tab`, `split`, `new-pane`, `new-pane-right`, `new-row`, `create-terminal`, and a terminal `respawn`; without a `SHELL` the host integrates as before. `new-workspace`, `new-screen` and the reopen-saved-group commands take no `env` and keep the host integration. `screen-terminal-env-v1` advertises `env`, `terminal_id`, and `shell_args` on `new-screen` (the first terminal takes them as on `new-pane`, including `terminal-frontend-shell-integration-v1`) and `terminal_id` in its result. `launch-snapshot-v1` advertises `launch_snapshot_path` in `identify` and the launch snapshot file described there. `state-resources-v1` advertises the `cmux.protocol/2` state resources (workspace metadata, tab pins and tab groups, personal workspace groups, rooms and saved tab groups, screen metadata, order and screen groups, closed history, ephemeral workspaces, and workspace status, progress and log; see `resource-api-v2.md`), `extra.state` on session snapshots, and `state_upsert`/`state_delete` changes on `session.events`. `window-records-v1` advertises `window_record.list`, `window_record.put` and `window_record.delete` (see `resource-api-v2.md`). `sidebar-layout-v1` advertises `sidebar_layout.get`, `sidebar_layout.update` and `extra.state.sidebar_layout` on session snapshots (see `resource-api-v2.md`). `sidebar-doc-v1` advertises the sidebar document: `sidebar.get`, `sidebar.move`, `sidebar.pin`, `sidebar.unpin`, the `sidebar.item.*`, `sidebar.section.*` and `sidebar.group.*` mutations, `sidebar.adopt`, `sidebar.apply`, `extra.state.sidebar` on session snapshots, and `state_upsert`/`state_delete` changes of resources `sidebar_item`, `sidebar_section` and `sidebar_doc` on `session.events` (see `resource-api-v2.md`). `placement-shift-v1` advertises the placement shift: a client that echoes it in `set-client-info` or `client.metadata.update` gets no `workspace_placement` restatement on `session.events` for rows that a new or moved workspace only shifts; every other connection still gets them (see "Placement shift" in `resource-api-v2.md`). `project-list-v1` advertises the project list: `project.list`, `project.observe`, `project.add`, `project.update`, `project.remove` and `project.sync`, and `state_upsert`/`state_delete` changes of resource `project` on `session.events` (see `resource-api-v2.md` and plans/cmux-next/projects.md). `project-sources-v1` advertises `project.source.update` (a source turned off or on) and `sources` (each source with its on state and project count) in the `project.list` result. `browser-devices-v1` advertises `browser.devices.list`, `browser.devices.add_custom`, `browser.devices.remove_custom`, `browser.tab.emulate` and `browser.tab.emulate.clear`, and a browser tab's `extra.emulation` (the resolved device emulation every engine applies; see `resource-api-v2.md`). `browser-profile-agent-v1` advertises `browser.tab.set_profile` (moves a frontend browser tab to another browser profile in place, as `update-frontend-browser-tab {profile_id}` does; `frontend-browser-tab-profile-v1`) and a frontend browser tab's `extra.browser_profile_id` (see `resource-api-v2.md`). `palette-usage-v1` advertises `palette_usage.get`, `palette_usage.record`, `palette_usage.import`, `palette_usage.hide` and `palette_usage.forget`, the user's local palette usage history (see `resource-api-v2.md`). `frontend-browser-owner-v1` advertises `owner` on `new-frontend-browser-tab` and `update-frontend-browser-tab`, `Tab.browser_owner`, `tab.update {owner}` and the tab's `extra.owner`. `frontend-browser-tab-keys-v1` advertises `idempotency_key` on `new-frontend-browser-tab` and `replayed` in its result: a retry with the same key returns the tab the first request created. The raw screen commands of `screen-metadata-v1` and `screen-groups-v1` and the v2 `screen.*` and `screen_group.*` operations read and write one storage and publish the same changes. `workspace-kind-v1` advertises `workspace.ensure_home` and `extra.kind` on workspace snapshots (see `resource-api-v2.md`). `workspace-agent-folder-v1` advertises `workspace.agent_folder.set` and `extra.agent_folder` on workspace snapshots (see `resource-api-v2.md`). `personal-mixed-order-v1` advertises one personal order for groups and loose workspaces: `top_index` on `workspace_group.update`, on `WorkspaceGroupSnapshot` and on `list-personal` groups, `workspace.list {order}`, and the personal row of a new workspace in its creation commit (see `resource-api-v2.md`). `workspace-group-icon-v1` advertises a personal workspace group's icon: `icon` on `workspace_group.update`, on `WorkspaceGroupSnapshot` and on `list-personal` groups (see `resource-api-v2.md`). `screen-group-icon-v1` advertises a screen group's icon: `icon` on `update-screen-group`, on screen group runs (`Workspace.screen_groups`), on saved screen groups (`list-saved-screen-groups`, kept by `reopen-saved-screen-group`), on `screen_group.update` and on `ScreenGroupSnapshot` (see `resource-api-v2.md`). `tab-group-icon-v1` advertises a tab group's icon: `icon` on `update-tab-group`, on tab group runs (`Pane.tab_groups`), on saved tab groups (`list-saved-tab-groups`, kept by `reopen-saved-tab-group`), on `tab_group.update`, on `TabGroupSnapshot` and on `SavedTabGroupSnapshot` (see `resource-api-v2.md`). `workspace-group-pin-v1` advertises pinned (saved) personal workspace groups: `pinned` on `workspace_group.update`, on `WorkspaceGroupSnapshot` and on `list-personal` groups (see `resource-api-v2.md`). `chief-inspect-v1` advertises `chief-inspect`, the Chief memory inspector's read-only API for the owner's trusted connection. `conversation-tabs-v1` advertises `new-conversation-tab`, the canonical `conversation` tab kind, and `capabilities` on `client.metadata.update`; a client that echoes it in `set-client-info` or `client.metadata.update` reads conversation tabs in their canonical form. `agent-session-tabs-v1` advertises the `agent_session` source of `new-conversation-tab` (an acpmux agent session on the install `host`; its tabs are ordinary store tabs that move, split and close like any tab) and `bind-conversation-tab-session`; a client that echoes it as well reads agent session tabs in their canonical form, and any other connection reads them as `browser` tabs without `conversation`. `page-tabs-v1` advertises the `page` source of `new-conversation-tab` (one of the app's own pages, such as the App Store or Settings; its tabs are ordinary store tabs); a client that echoes it as well reads page tabs in their canonical form, and any other connection reads them as `browser` tabs without `conversation`. `page-tab-route-v1` advertises the optional `route` of a page tab on `new-conversation-tab` and in its `conversation` record. `close-reason-v1` advertises `reason` on `close-tabs` and, with `end_terminals`, on `close-workspace`: `session_end` closes without a closed-history record. `conversation-tab-transaction-v1` advertises `transaction` on `new-conversation-tab`: the created tab's `tab-added` delta and the result echo it. `browser-host-provider-v1` advertises `browser-host-provider`: the verified local app gets the provider credentials of the daemon's own browser host. `frontend-browser-activate-v1` advertises `activate` on `new-frontend-browser-tab`. `frontend-browser-insert-after-v1` advertises `after` on `new-frontend-browser-tab`. `bookmarks-v1` advertises one bookmark tree per browser profile: `list-bookmarks`, `create-bookmark`, `update-bookmark`, `move-bookmark`, `delete-bookmark`, `import-bookmarks`, the `bookmarks-changed` event, and `deleted_bookmarks` in the `delete-browser-profile` result. `bookmark-icon-v1` advertises a bookmark folder's `icon`: set or cleared by `update-bookmark` and shown in `list-bookmarks` (see "Icon values" under `put-blob`). `session-icon-v1` advertises a session's (machine's) `icon` in the home session registry: set or cleared by `set-session-icon`, kept by `put-session`, and shown on each `list-personal` session record. `icon-assets-v1` advertises the asset blob store (`put-blob`, `get-blob`) and the asset icon values `image:sha256-` and `svg:sha256-` on the workspace, screen, room, browser profile and workspace status icon fields (see "Icon values" under `put-blob`). `origin-claim-v1` advertises `client-hello` step 1, the optional `origin` claim on `cmux.protocol/2` requests, and `origin.confirmation.issue` (see "client-hello" and `resource-api-v2.md`, "Request origin") `page-router-v1` advertises the daemon page router (`page-router.md`): the optional `page` on a page relay's `client-hello` (the reply carries that page's `page_operations`), the page of a WebSocket connection whose handshake Origin is `cmux-page://cmux.`, the per-page allow list of `cmux.protocol/2` operations in `page-router.json`, and the routed v2 namespaces (`apps.catalog.list`, `apps.catalog.get`, `apps.installed.list`, `apps.grants.get`, `apps.logs`, `apps.asset.get`, `apps.set`, `apps.install`, `apps.uninstall`, `apps.hidden.set`, `apps.grant.set`, `apps.grant.revoke`) answered by their owner outside the operation catalog. `ws-listen-origins-v1` advertises `allow_origins` on `ws-listen` and `origins` in its reply. `page-events-v1` advertises `page_events` in a page relay's `client-hello` reply (the `events` of its page in `page-router.json`) and `subscribe` on a page relay whose page names events: the stream carries only those events (no `surface` output stream, no pairing requests). `terminal-clipboard-read-v1` advertises the OSC 52 clipboard-read broker: `terminal-clipboard-subscribe`, `terminal-clipboard-reply`, and the targeted `terminal-clipboard-read` and `terminal-clipboard-read-cancelled` control events (see "Terminal clipboard reads"). `launch-credential-v1` advertises the optional `credential` member of `cmux.protocol/2` requests and `credential.verify`, `credential.mint` and `credential.rotate` (see `resource-api-v2.md`, "Launch credentials"); a client sends a credential only to a daemon that advertises it. `feed-local-owner-v1` advertises the daemon's local feed owner (plans/cmux-next/feed.md section 9.1): every notification is also a local feed item committed in the same transaction, `select-tab`, `select-workspace`, `select-screen`, `focus-pane` and `focus-direction` no longer clear unread (a client acknowledges with `ack-tab-notifications`, which also reads the tab's local items and reports `refused` items), the `feed-local-list`, `feed-local-read`, `feed-local-handoff-begin`, `feed-local-handoff-abort` and `feed-local-handoff-done` commands, and the error codes `feed.moving`, `owner.unreachable` (both retryable), `feed.invalid_state`, `not_found` and `forbidden` (a handoff step from a connection that is not the verified cmux app). `workspace-open-url-v1` advertises `workspace.open_url`: a web link opens on the right side of a workspace (the home workspace when none is named), reusing its browser tab, never in the agent chat dock and without moving the keyboard focus (see `resource-api-v2.md`). . -`capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `daemon-handoff-force-v1` advertises the optional `force` field on `shutdown-daemon`. `browser-provider-v1` advertises the trusted-local, connection-scoped native browser provider lease used by cmux-browser and local automation. `browser-provider-native-hosting-v1` advertises the optional `hosting` field of `register-browser-provider`: a `"native"` provider presents its pages itself, so the daemon never overrides their device metrics and screencasts them only while a frame viewer is attached. `browser-pointer-frame-guard-v1` advertises authoritative `pointer_frame_seq` and `pointer_frame_floor_seq` browser attach/frame state plus the additive `browser-frame-presented`, `browser-mouse-guarded`, and `browser-wheel-guarded` commands. Each admitted bitmap receives a new guard even when its document and dimensions match the previous bitmap. The reported floor through latest range proves route membership only. `browser-frame-presented` advances one exact acknowledged token for that connection, and only that token authorizes a new guarded pointer action. A guarded pointer command implicitly acknowledges its own token. Each connection retains one token, while the bounded browser input queue owns actions admitted before a later presentation. Navigation or geometry changes clear the range and all acknowledgements. An accepted press keeps its original guard for motion across ordinary repaints while document and geometry remain valid; invalidation suppresses further motion but retains its balancing release. A capable client echoes that value in `set-client-info`; browser attach requires the bilateral capability while PTY attach remains available without it. The legacy `browser-mouse` and `browser-wheel` schemas retain their optional guard, but guarded servers reject a missing guard before surface lookup. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `pane-browser-kind-v1` advertises `kind` and `url` on `split` and `new-pane-right`: `kind:"browser"` with a non-empty `url` puts a browser tab at that URL in the new pane, through the same `pane.split` commit and restart recovery as a terminal pane; public `pane.split` stays terminal-only. `dock-columns-v1` advertises `set-column-dock` and the optional `Screen.columns[].dock` field for every edge. `edge-docks-v1` advertises the edges `top` and `bottom` on `set-column-dock` and on `dock` in `move-tab-to-column` (the `dock` field itself needs `dock-columns-v1`). `dock-column-role-v1` advertises `role` on `set-column-dock` and on `dock` in `move-tab-to-column`, and `Screen.columns[].dock.role`. `rows-v1` advertises `new-row`, `set-row-heights`, and the optional `Screen.columns[].rows` field. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `view-attachment-lease-v1` returns a connection-owned lease for each attach and enables lease-fenced sizing. `view-attachment-detach-v1` enables targeted stream cleanup. `creation-receipts-v1` enables idempotent destination creation, `creation-attempt-keys-v1` separates a stable correlation from the same-key or new-key execution attempt selected by `session.creation.resolve`, and `creation-selector-fallbacks-v1` adds bounded ordered destination continuations. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. `terminal-idle-close-v1` advertises `set-terminal-idle-policy` and the owner-side reaper that closes a terminal after its policy elapses with no attached view. `terminal-pending-sequence-v1` advertises the separate `pending` field on byte-attach `vt-state` and `resized` events; a client that echoes it in `set-client-info` receives it (see `events.md`). `terminal-placement-env-v1` advertises a caller-chosen `terminal_id` on `new-tab`, `split`, `new-pane`, and `new-pane-right`, `cwd` and `env` on `new-pane` and `new-pane-right`, and `terminal_id`/`terminal_incarnation` in all four results. `split-client-keys-v1` advertises the client-minted `pane_id` and `tab_id` on `split`, `new-pane`, and `new-pane-right`, keyed retries that return the first result with `replayed`, and `pane_id`/`tab_id` in their results (see "Client-minted pane ids"). `terminal-resources-v1` advertises `terminal-resources`, which reads the CPU time and memory of each terminal's shell, descendants, and terminal host at request time. `batch-close-v1` advertises `close-tabs` and the optional `end_terminals` field on `close-pane`, `close-screen`, `close-workspace`, and `close-tab-group`: many placements and the terminals they end close in one durable commit. `close-workspace-if-empty-v1` advertises the optional `if_empty` field on `close-workspace`: the close commits only when the workspace has no tab at commit time, else it fails with `workspace_not_empty` and changes nothing. `end-terminals-keep-layout-v1` advertises `keep_layout` on `shutdown-daemon`: with `end_terminals`, placed terminals keep their tabs across the handoff. `terminal-reap-v1` advertises the owner-side reaper that ends a terminal after it has had no tab placement for the reap grace period (active only when the daemon was started with `--terminal-reap-grace-seconds`), `set-terminal-keep`, the `keep` field on `new-tab`, `split`, and `create-terminal`, the `terminal-reaped` event, and `end_terminals` on `shutdown-daemon`. `terminal-env-v1` advertises the per-terminal `env` object on `new-tab`, `split`, and `create-terminal`, and `cwd` on `split`. `tab-groups-v1` advertises tab groups: the `*-tab-group` commands, `Pane.tab_groups`, and `Tab.group`. `saved-tab-groups-v1` advertises saved groups: `save-tab-group`, `unsave-tab-group`, `delete-saved-tab-group`, `list-saved-tab-groups`, and `reopen-saved-tab-group`. `notification-ack-v1` advertises `ack-tab-notifications`, `list-notifications`, durable notification acknowledgement, and `Workspace.unread_count`. `tab-drag-v1` advertises the single-command tab drag outcomes `move-tab-to-split`, `move-tab-to-column`, and `move-tab-to-new-workspace`, layout undo for same-screen tab drags and cross-pane `move-tab`, and the optional `transaction` field on every drag command, echoed in the resulting `tab-changed` delta. `tab-workspace-name-v1` advertises the optional `name` field on `move-tab-to-new-workspace`: the new workspace takes that name in the same commit. `tab-split-respawn-v1` advertises `respawn` on `move-tab-to-split`: a pane's only tab dropped on its own pane's edge splits that pane and leaves a fresh tab of the same kind behind. `tab-split-keep-focus-v1` advertises `keep_focus` on `move-tab-to-split` and `move-tab-to-column`: the new pane does not take focus. `tab-column-respawn-v1` advertises `respawn` on `move-tab-to-column`: a pane's only tab moves into the new column and leaves a fresh tab of the same kind in its pane. `frontend-browser-tabs-v1` advertises `new-frontend-browser-tab`, `update-frontend-browser-tab`, and the frontend browser tab fields. `frontend-browser-history-v1` advertises `set-frontend-browser-history` and `get-frontend-browser-history`: an opaque per-tab session history object for frontend-rendered browsers, stored durably outside the tree. `tab-metadata-v1` advertises `set-tab-pinned`, pinned-first tab order, the `Tab.pinned`, `Tab.cwd`, `Tab.git_branch`, and `Tab.git_detached` fields, and the `tab-changed` delta. `workspace-metadata-v1` advertises `set-workspace-metadata`, the `Workspace.color`, `Workspace.icon`, and `Workspace.title` fields, and the `workspace-changed` delta. `workspace-pin-v1` advertises the `pinned` field on `set-workspace-metadata` and `Workspace.pinned`. `notification-mark-unread-v1` advertises the `marked_unread` field on `set-workspace-metadata` and `Workspace.marked_unread`. `workspace-groups-v1` advertises durable sidebar groups: the `*-workspace-group` commands, `move-workspace-to-group`, `Tree.groups`, and `Workspace.group`. `loopback-forward-v1` advertises multiplexed TCP streams to the daemon machine's own loopback services (see "Loopback forwarding"); a Unix client echoes it in `set-client-info` before its first `loopback-open`. `fs-v1` advertises the `fs.*` file ops and their byte streams (see "File system (fs-v1)"); only cmux Cloud hosts serve it. `session-identity-v1` advertises `identify.session_id` (the durable `registry_id`, stable across restarts and upgrades) and `identify.machine_name` (the host name, at most 255 bytes, no control characters). `profiles-v1` advertises the home session's personal state: `list-personal`, the `*-profile` room commands, `set-profile-follows`, `pin-workspace`, `unpin-workspace`, `put-session`, `forget-session`, `import-session-organization`, the `*-personal-group` commands, `set-personal-workspace`, and the `personal-changed` event. `personal-terminals-v1` advertises `set-personal-terminal` and `list-personal.terminals`. `screen-metadata-v1` advertises `set-screen-metadata`, `set-screen-pinned`, `move-screen`, the `screen_name`, `color`, `icon`, `pinned`, `index`, `group`, and `cwd` fields on `new-screen` (whose result then also carries `screen`), the `Screen.color`, `Screen.icon`, `Screen.pinned`, and `Screen.group` fields, and the `screen-changed` delta. `screen-groups-v1` advertises screen groups: the `*-screen-group` commands, saved screen groups, and `Workspace.screen_groups`. `browser-profiles-v1` advertises browser profile records in personal state: `browser_profiles` in `list-personal` and `create-browser-profile`, `update-browser-profile`, `move-browser-profile`, and `delete-browser-profile`. `conversation-search-v1` advertises `conversation-search` on the local conversation owner. `conversation-draft-v1` advertises `conversation-draft`, the v1 command of the `conversation.draft` operation for a bound agent connection. `conversation-draft-events-v1` advertises the `conversation-draft` event on `subscribe` (the `conversation.events` draft item, with its `conversation` and `participant`) for a trusted local connection that echoes the capability in `set-client-info`; other connections never get drafts on `subscribe`. `conversation-threads-v1` advertises `thread_root` on `message.send` and on `Message`, the `invalid_thread_root` reject, and that a `message.send` moves the sender's own read cursor to the new seq when the sender had read everything before it (see `conversation-op`). `conversation-thread-v1` advertises `conversation-thread`, the `threads` root stats on `conversation-snapshot` and `conversation-history` results, and, with `cloud-conversations-v1`, `cloud-conversation-thread`. `conversation-tree-v1` advertises threads that nest without a limit on the local conversation owner (`reply_to` is the parent, `thread_root` the top root; a `message.send` whose `reply_to` parent is in a thread joins that thread, and a named `thread_root` must be the parent's own; see `conversation-op`) and `conversation-link-resolve`, which opens a conversation or message permalink. `conversation-inbox-state-v1` advertises the person's own list state of a conversation, kept beside it (a pin with its position and a "marked unread" flag): `conversation-inbox-op`, the caller's `pinned`, `pin_position` and `marked_unread` on `conversation-list` items, the `conversation-inbox-changed` event on `subscribe` (only to connections whose principal is that participant), and with `cloud-conversations-v1` also `cloud-inbox-op`. `conversation-thread-objects-v1` advertises threads as objects: the `thread.update` op (a `title`, a `status` of `open` or `resolved`, and an `owner` participant; any participant may send it, an agent too) on `conversation-op` and through `cloud-conversation-op`, the `thread` object on a root `Message`, and the `status`, `title`, `owner`, `resolved_by` and `resolved_at` fields of `ThreadSummary` (see `conversation-op` and `conversation-thread`). `conversation-runs-v1` advertises agent runs on the local conversation owner: `conversation-run-append` for a bound agent participant, `conversation-runs` and `conversation-run` on trusted local connections (Unix, and authenticated WebSocket connections with `ws-conversations-v1`), and the `conversation-run-changed` event on `subscribe` for a connection that echoes the capability in `set-client-info` (see "conversation-run-append, conversation-runs, conversation-run"). `cloud-conversation-runs-v1` (advertised with `cloud-conversations-v1`) advertises `cloud-conversation-runs`, `cloud-conversation-run` and the `cloud-conversation-run-changed` event (see "cloud-conversation-runs, cloud-conversation-run"). `local-attachments-v1` advertises `attachment` message parts on the local conversation owner and the `conversation-attachment-upload` and `conversation-attachment-read` commands on trusted local connections. `cloud-attachments-v1` (advertised with `cloud-conversations-v1`) advertises `cloud-attachment-upload`, `cloud-attachment-read` and `cloud-attachment-link`: the same steps and results for a cloud conversation's attachments, through the daemon with the person's lease. `ws-attachments-v1` advertises the same two commands and `conversation-attachment-link` on an authenticated WebSocket connection (the clients of `ws-conversations-v1`; never a relayed remote device), and the listener's `GET /attachments/` download links with byte ranges (transports.md). `link-preview-v1` advertises `link_preview` message parts on the local conversation owner (see `conversation-attachment-upload`). `conversation-person-credentials-v1` advertises that `conversation-agent-token` and `conversation-bind` also take a person other than the server's own user (a `user_` id that is not `user_local`; never a `remote_` device): the bound Unix connection then acts as that person (sends, thread replies, reactions, edits, typing) and is a human participant (it may answer questions, its messages reset the agent turn budget and wake a Chief the way an imported message of that person does), but never the owner: it cannot mint credentials, import history or use any command reserved to `user_local`. That person's typing ends when their last bound connection ends or their credential is replaced. `participant-avatars-v1` advertises `participant.avatar.set` and `participant.avatar.get` (one picture per participant, a person or a Chief, in one avatar store; resource-api-v2.md "Participant avatars") and the `participant-avatar-changed` event on `subscribe` for a connection that echoes the capability in `set-client-info`. `ws-conversations-v1` advertises the local conversation owner on an authenticated WebSocket connection (the static credential or an approved pairing) as `user_local`, the same as on a Unix connection: `conversation-list`, `conversation-create`, `conversation-snapshot`, `conversation-history`, `conversation-thread`, `conversation-link-resolve` (with `conversation-tree-v1`), `conversation-search`, `conversation-op`, `conversation-typing`, the `conversation.*` operations, and the `conversation-changed`, `conversation-typing` and (with `conversation-draft-events-v1`) `conversation-draft` events on `subscribe`; `conversation-bind`, `conversation-agent-token` and `conversation-import` stay on Unix connections, and the attachment commands come to WebSocket connections only with `ws-attachments-v1` (cmux#13742). `local-conversations-v1` advertises the local conversation owner on trusted local connections: `conversation-list`, `conversation-create`, `conversation-snapshot`, `conversation-history`, `conversation-op`, `conversation-typing`, `conversation-bind`, `conversation-agent-token`, `conversation-import`, and the `conversation-changed` and `conversation-typing` events. `notification-source-v1` advertises `source` on `notify`, the `notification` event, the tab `notification` marker, and `list-notifications` rows (`extra.source` in resource notification snapshots), and daemon-side desktop notifications from terminal output (OSC 9, OSC 777 `notify`, kitty OSC 99) with source `terminal`. `terminal-shell-args-v1` advertises `shell_args` on `new-tab`, `split`, `new-pane`, `new-pane-right`, and `create-terminal`: the terminal runs its `SHELL` from `env` (else the daemon's default shell) with those arguments, so a frontend can apply Ghostty's argv-based shell integration (bash `--posix` with `ENV`, nushell `--execute`). `terminal-frontend-shell-integration-v1` lets a frontend that resolves Ghostty's shell integration itself (from the user's `shell-integration` and `shell-integration-features`, into the terminal's `env` and `shell_args`) echo it in `set-client-info`; the terminals that connection creates with a `SHELL` in `env` then run that shell exactly as given (stored as `argv`), with no host-added integration, on `new-tab`, `split`, `new-pane`, `new-pane-right`, `new-row`, `create-terminal`, and a terminal `respawn`; without a `SHELL` the host integrates as before. `new-workspace` and the reopen-saved-group commands take no `env` and keep the host integration; `new-screen` follows it with `screen-terminal-env-v1`. `screen-terminal-env-v1` advertises `env`, `terminal_id`, and `shell_args` on `new-screen` (the first terminal takes them as on `new-pane`, including `terminal-frontend-shell-integration-v1`) and `terminal_id` in its result. `launch-snapshot-v1` advertises `launch_snapshot_path` in `identify` and the launch snapshot file described there. `close-reason-v1` advertises `reason` on `close-tabs` and, with `end_terminals`, on `close-workspace`: `session_end` closes without a closed-history record. `conversation-tab-transaction-v1` advertises `transaction` on `new-conversation-tab`: the created tab's `tab-added` delta and the result echo it. `browser-host-provider-v1` advertises `browser-host-provider`: the verified local app gets the provider credentials of the daemon's own browser host. `frontend-browser-activate-v1` advertises `activate` on `new-frontend-browser-tab`. `history-search-v1` (advertised only when the daemon has a search index) advertises `history-search`. `frontend-browser-insert-after-v1` advertises `after` on `new-frontend-browser-tab`. `bookmarks-v1` advertises one bookmark tree per browser profile: `list-bookmarks`, `create-bookmark`, `update-bookmark`, `move-bookmark`, `delete-bookmark`, `import-bookmarks`, the `bookmarks-changed` event, and `deleted_bookmarks` in the `delete-browser-profile` result. `bookmark-icon-v1` advertises a bookmark folder's `icon`: set or cleared by `update-bookmark` and shown in `list-bookmarks` (see "Icon values" under `put-blob`). `session-icon-v1` advertises a session's (machine's) `icon` in the home session registry: set or cleared by `set-session-icon`, kept by `put-session`, and shown on each `list-personal` session record. `icon-assets-v1` advertises the asset blob store (`put-blob`, `get-blob`) and the asset icon values `image:sha256-` and `svg:sha256-` on the workspace, screen, room, browser profile and workspace status icon fields (see "Icon values" under `put-blob`). `ws-listen-v1` (advertised when the binary can open a page listener) advertises `ws-listen`: a running owner opens its loopback page listener and credential file on request. `cloud-conversations-v1` (advertised only when the daemon has a cloud transport) advertises the cloud conversations proxy on trusted local connections: the `cloud-session-*`, `cloud-inbox-*` and `cloud-conversation-*` commands and the `cloud-*` events (plans/cmux-next/home-cloud-proxy.md). `cloud-lease-app-only-v1` (advertised with `cloud-conversations-v1`) advertises that only the verified cmux app connection may replace, clear or use a lease the app set and send `cloud-conversation-op` with `origin: "user"`; other connections get `cloud_conversation_rejected` with `reason` `forbidden`. `page-credential-v1` advertises `page-credential`: the verified cmux app gets a credential for its own page. `page-credential-scoped-v1` advertises the optional `page` field of `page-credential`: any trusted local connection gets a single-use credential bound to that page of the page-router table, which admits only a WebSocket handshake whose Origin names that page and never carries the Cloud lease. `ws-cloud-conversations-v1` (advertised with `cloud-conversations-v1`) advertises that a WebSocket connection that authenticated with a pairing-store credential (an approved pairing, or a `page-credential`; never the static or file token, which any same-user process can read) acts as the person with the lease the verified app set: `cloud-session-status` (with `participant`), `cloud-inbox-list`, `cloud-conversation-snapshot`, `cloud-conversation-history`, `cloud-conversation-thread`, `cloud-conversation-op` (also with `origin: "user"`), the `cloud-*-subscribe` and `cloud-*-unsubscribe` commands, and the `cloud-*` events on `subscribe`; it never sets or clears a lease, and without the app's lease it is signed out (`cloud_signed_out`), never using a lease another connection set (cmux#13742). `origin-claim-v1` advertises `client-hello` step 1, the optional `origin` claim on `cmux.protocol/2` requests, and `origin.confirmation.issue` (see "client-hello" and `resource-api-v2.md`, "Request origin"). `terminal-clipboard-read-v1` advertises the OSC 52 clipboard-read broker: `terminal-clipboard-subscribe`, `terminal-clipboard-reply`, and the targeted `terminal-clipboard-read` and `terminal-clipboard-read-cancelled` control events (see "Terminal clipboard reads"). `agent-session-attach-v1` (advertised when the daemon resolved an acpmux socket path) advertises the `agent-session-*` commands and events on trusted local connections (see "Agent session attach"). `agent-session-start-v1` (advertised with `agent-session-attach-v1`) advertises `agent-session-start` (see "agent-session-start"). `agent-session-start-keyed-reason-v1` (advertised with `agent-session-start-v1`) advertises `require_person_key` and `person_instance` on `agent-session-start` and its typed `agent_session.needs_keyed_start` refusal. `agent-transfer-v1` (advertised with `agent-session-attach-v1` once the daemon knows its host) advertises the `agent-transfer-*` commands on trusted local connections (see "Agent chat transfer"). `access-request-v1` (advertised on a cmux Cloud host) advertises the `access-request*` commands on trusted local connections (see "Access requests"). `feed-local-owner-v1` advertises the daemon's local feed owner (plans/cmux-next/feed.md section 9.1): every notification is also a local feed item committed in the same transaction, `select-tab`, `select-workspace`, `select-screen`, `focus-pane` and `focus-direction` no longer clear unread (a client acknowledges with `ack-tab-notifications`, which also reads the tab's local items and reports `refused` items), the `feed-local-list`, `feed-local-read`, `feed-local-handoff-begin`, `feed-local-handoff-abort` and `feed-local-handoff-done` commands, and the error codes `feed.moving`, `owner.unreachable` (both retryable), `feed.invalid_state`, `not_found` and `forbidden` (a handoff step from a connection that is not the verified cmux app). `chief-profile-ws-v1` advertises that the person's page (page `home-channels` admitted with a page-bound credential, or an authenticated WebSocket with the daemon's own credential and no page Origin) may call `chief.profile.*`: list, read, and change display_name, role and the avatar; create, delete, instructions, personality and the grant fields need the person's gesture (resource-api-v2.md "Chief registry"). `chief-events-v1` advertises the `chief-changed` event on `subscribe` (a Chief's id and `created`, `updated` or `deleted`, after every committed `chief.profile.*` write) for the person's own connection (`user_local`, the Channels page included) that echoes the capability in `set-client-info`. +`capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `daemon-handoff-force-v1` advertises the optional `force` field on `shutdown-daemon`. `browser-provider-v1` advertises the trusted-local, connection-scoped native browser provider lease used by cmux-browser and local automation. `browser-provider-native-hosting-v1` advertises the optional `hosting` field of `register-browser-provider`: a `"native"` provider presents its pages itself, so the daemon never overrides their device metrics and screencasts them only while a frame viewer is attached. `browser-pointer-frame-guard-v1` advertises authoritative `pointer_frame_seq` and `pointer_frame_floor_seq` browser attach/frame state plus the additive `browser-frame-presented`, `browser-mouse-guarded`, and `browser-wheel-guarded` commands. Each admitted bitmap receives a new guard even when its document and dimensions match the previous bitmap. The reported floor through latest range proves route membership only. `browser-frame-presented` advances one exact acknowledged token for that connection, and only that token authorizes a new guarded pointer action. A guarded pointer command implicitly acknowledges its own token. Each connection retains one token, while the bounded browser input queue owns actions admitted before a later presentation. Navigation or geometry changes clear the range and all acknowledgements. An accepted press keeps its original guard for motion across ordinary repaints while document and geometry remain valid; invalidation suppresses further motion but retains its balancing release. A capable client echoes that value in `set-client-info`; browser attach requires the bilateral capability while PTY attach remains available without it. The legacy `browser-mouse` and `browser-wheel` schemas retain their optional guard, but guarded servers reject a missing guard before surface lookup. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `pane-browser-kind-v1` advertises `kind` and `url` on `split` and `new-pane-right`: `kind:"browser"` with a non-empty `url` puts a browser tab at that URL in the new pane, through the same `pane.split` commit and restart recovery as a terminal pane; public `pane.split` stays terminal-only. `dock-columns-v1` advertises `set-column-dock` and the optional `Screen.columns[].dock` field for every edge. `edge-docks-v1` advertises the edges `top` and `bottom` on `set-column-dock` and on `dock` in `move-tab-to-column` (the `dock` field itself needs `dock-columns-v1`). `dock-column-role-v1` advertises `role` on `set-column-dock` and on `dock` in `move-tab-to-column`, and `Screen.columns[].dock.role`. `rows-v1` advertises `new-row`, `set-row-heights`, and the optional `Screen.columns[].rows` field. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `view-attachment-lease-v1` returns a connection-owned lease for each attach and enables lease-fenced sizing. `view-attachment-detach-v1` enables targeted stream cleanup. `creation-receipts-v1` enables idempotent destination creation, `creation-attempt-keys-v1` separates a stable correlation from the same-key or new-key execution attempt selected by `session.creation.resolve`, and `creation-selector-fallbacks-v1` adds bounded ordered destination continuations. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. `terminal-idle-close-v1` advertises `set-terminal-idle-policy` and the owner-side reaper that closes a terminal after its policy elapses with no attached view. `terminal-pending-sequence-v1` advertises the separate `pending` field on byte-attach `vt-state` and `resized` events; a client that echoes it in `set-client-info` receives it (see `events.md`). `terminal-placement-env-v1` advertises a caller-chosen `terminal_id` on `new-tab`, `split`, `new-pane`, and `new-pane-right`, `cwd` and `env` on `new-pane` and `new-pane-right`, and `terminal_id`/`terminal_incarnation` in all four results. `split-client-keys-v1` advertises the client-minted `pane_id` and `tab_id` on `split`, `new-pane`, and `new-pane-right`, keyed retries that return the first result with `replayed`, and `pane_id`/`tab_id` in their results (see "Client-minted pane ids"). `terminal-resources-v1` advertises `terminal-resources`, which reads the CPU time and memory of each terminal's shell, descendants, and terminal host at request time. `batch-close-v1` advertises `close-tabs` and the optional `end_terminals` field on `close-pane`, `close-screen`, `close-workspace`, and `close-tab-group`: many placements and the terminals they end close in one durable commit. `close-workspace-if-empty-v1` advertises the optional `if_empty` field on `close-workspace`: the close commits only when the workspace has no tab at commit time, else it fails with `workspace_not_empty` and changes nothing. `end-terminals-keep-layout-v1` advertises `keep_layout` on `shutdown-daemon`: with `end_terminals`, placed terminals keep their tabs across the handoff. `terminal-reap-v1` advertises the owner-side reaper that ends a terminal after it has had no tab placement for the reap grace period (active only when the daemon was started with `--terminal-reap-grace-seconds`), `set-terminal-keep`, the `keep` field on `new-tab`, `split`, and `create-terminal`, the `terminal-reaped` event, and `end_terminals` on `shutdown-daemon`. `terminal-env-v1` advertises the per-terminal `env` object on `new-tab`, `split`, and `create-terminal`, and `cwd` on `split`. `tab-groups-v1` advertises tab groups: the `*-tab-group` commands, `Pane.tab_groups`, and `Tab.group`. `saved-tab-groups-v1` advertises saved groups: `save-tab-group`, `unsave-tab-group`, `delete-saved-tab-group`, `list-saved-tab-groups`, and `reopen-saved-tab-group`. `notification-ack-v1` advertises `ack-tab-notifications`, `list-notifications`, durable notification acknowledgement, and `Workspace.unread_count`. `tab-drag-v1` advertises the single-command tab drag outcomes `move-tab-to-split`, `move-tab-to-column`, and `move-tab-to-new-workspace`, layout undo for same-screen tab drags and cross-pane `move-tab`, and the optional `transaction` field on every drag command, echoed in the resulting `tab-changed` delta. `tab-workspace-name-v1` advertises the optional `name` field on `move-tab-to-new-workspace`: the new workspace takes that name in the same commit. `tab-split-respawn-v1` advertises `respawn` on `move-tab-to-split`: a pane's only tab dropped on its own pane's edge splits that pane and leaves a fresh tab of the same kind behind. `tab-split-keep-focus-v1` advertises `keep_focus` on `move-tab-to-split` and `move-tab-to-column`: the new pane does not take focus. `tab-column-respawn-v1` advertises `respawn` on `move-tab-to-column`: a pane's only tab moves into the new column and leaves a fresh tab of the same kind in its pane. `frontend-browser-tabs-v1` advertises `new-frontend-browser-tab`, `update-frontend-browser-tab`, and the frontend browser tab fields. `frontend-browser-history-v1` advertises `set-frontend-browser-history` and `get-frontend-browser-history`: an opaque per-tab session history object for frontend-rendered browsers, stored durably outside the tree. `tab-metadata-v1` advertises `set-tab-pinned`, pinned-first tab order, the `Tab.pinned`, `Tab.cwd`, `Tab.git_branch`, and `Tab.git_detached` fields, and the `tab-changed` delta. `workspace-metadata-v1` advertises `set-workspace-metadata`, the `Workspace.color`, `Workspace.icon`, and `Workspace.title` fields, and the `workspace-changed` delta. `workspace-pin-v1` advertises the `pinned` field on `set-workspace-metadata` and `Workspace.pinned`. `notification-mark-unread-v1` advertises the `marked_unread` field on `set-workspace-metadata` and `Workspace.marked_unread`. `workspace-groups-v1` advertises durable sidebar groups: the `*-workspace-group` commands, `move-workspace-to-group`, `Tree.groups`, and `Workspace.group`. `loopback-forward-v1` advertises multiplexed TCP streams to the daemon machine's own loopback services (see "Loopback forwarding"); a Unix client echoes it in `set-client-info` before its first `loopback-open`. `fs-v1` advertises the `fs.*` file ops and their byte streams (see "File system (fs-v1)"); only cmux Cloud hosts serve it. `session-identity-v1` advertises `identify.session_id` (the durable `registry_id`, stable across restarts and upgrades) and `identify.machine_name` (the host name, at most 255 bytes, no control characters). `profiles-v1` advertises the home session's personal state: `list-personal`, the `*-profile` room commands, `set-profile-follows`, `pin-workspace`, `unpin-workspace`, `put-session`, `forget-session`, `import-session-organization`, the `*-personal-group` commands, `set-personal-workspace`, and the `personal-changed` event. `personal-terminals-v1` advertises `set-personal-terminal` and `list-personal.terminals`. `screen-metadata-v1` advertises `set-screen-metadata`, `set-screen-pinned`, `move-screen`, the `screen_name`, `color`, `icon`, `pinned`, `index`, `group`, and `cwd` fields on `new-screen` (whose result then also carries `screen`), the `Screen.color`, `Screen.icon`, `Screen.pinned`, and `Screen.group` fields, and the `screen-changed` delta. `screen-groups-v1` advertises screen groups: the `*-screen-group` commands, saved screen groups, and `Workspace.screen_groups`. `browser-profiles-v1` advertises browser profile records in personal state: `browser_profiles` in `list-personal` and `create-browser-profile`, `update-browser-profile`, `move-browser-profile`, and `delete-browser-profile`. `conversation-search-v1` advertises `conversation-search` on the local conversation owner. `conversation-draft-v1` advertises `conversation-draft`, the v1 command of the `conversation.draft` operation for a bound agent connection. `conversation-draft-events-v1` advertises the `conversation-draft` event on `subscribe` (the `conversation.events` draft item, with its `conversation` and `participant`) for a trusted local connection that echoes the capability in `set-client-info`; other connections never get drafts on `subscribe`. `conversation-threads-v1` advertises `thread_root` on `message.send` and on `Message`, the `invalid_thread_root` reject, and that a `message.send` moves the sender's own read cursor to the new seq when the sender had read everything before it (see `conversation-op`). `conversation-thread-v1` advertises `conversation-thread`, the `threads` root stats on `conversation-snapshot` and `conversation-history` results, and, with `cloud-conversations-v1`, `cloud-conversation-thread`. `conversation-tree-v1` advertises threads that nest without a limit on the local conversation owner (`reply_to` is the parent, `thread_root` the top root; a `message.send` whose `reply_to` parent is in a thread joins that thread, and a named `thread_root` must be the parent's own; see `conversation-op`) and `conversation-link-resolve`, which opens a conversation or message permalink. `conversation-inbox-state-v1` advertises the person's own list state of a conversation, kept beside it (a pin with its position and a "marked unread" flag): `conversation-inbox-op`, the caller's `pinned`, `pin_position` and `marked_unread` on `conversation-list` items, the `conversation-inbox-changed` event on `subscribe` (only to connections whose principal is that participant), and with `cloud-conversations-v1` also `cloud-inbox-op`. `conversation-thread-objects-v1` advertises threads as objects: the `thread.update` op (a `title`, a `status` of `open` or `resolved`, and an `owner` participant; any participant may send it, an agent too) on `conversation-op` and through `cloud-conversation-op`, the `thread` object on a root `Message`, and the `status`, `title`, `owner`, `resolved_by` and `resolved_at` fields of `ThreadSummary` (see `conversation-op` and `conversation-thread`). `conversation-runs-v1` advertises agent runs on the local conversation owner: `conversation-run-append` for a bound agent participant, `conversation-runs` and `conversation-run` on trusted local connections (Unix, and authenticated WebSocket connections with `ws-conversations-v1`), and the `conversation-run-changed` event on `subscribe` for a connection that echoes the capability in `set-client-info` (see "conversation-run-append, conversation-runs, conversation-run"). `cloud-conversation-runs-v1` (advertised with `cloud-conversations-v1`) advertises `cloud-conversation-runs`, `cloud-conversation-run` and the `cloud-conversation-run-changed` event (see "cloud-conversation-runs, cloud-conversation-run"). `local-attachments-v1` advertises `attachment` message parts on the local conversation owner and the `conversation-attachment-upload` and `conversation-attachment-read` commands on trusted local connections. `cloud-attachments-v1` (advertised with `cloud-conversations-v1`) advertises `cloud-attachment-upload`, `cloud-attachment-read` and `cloud-attachment-link`: the same steps and results for a cloud conversation's attachments, through the daemon with the person's lease. `ws-attachments-v1` advertises the same two commands and `conversation-attachment-link` on an authenticated WebSocket connection (the clients of `ws-conversations-v1`; never a relayed remote device), and the listener's `GET /attachments/` download links with byte ranges (transports.md). `link-preview-v1` advertises `link_preview` message parts on the local conversation owner (see `conversation-attachment-upload`). `conversation-person-credentials-v1` advertises that `conversation-agent-token` and `conversation-bind` also take a person other than the server's own user (a `user_` id that is not `user_local`; never a `remote_` device): the bound Unix connection then acts as that person (sends, thread replies, reactions, edits, typing) and is a human participant (it may answer questions, its messages reset the agent turn budget and wake a Chief the way an imported message of that person does), but never the owner: it cannot mint credentials, import history or use any command reserved to `user_local`. That person's typing ends when their last bound connection ends or their credential is replaced. `participant-avatars-v1` advertises `participant.avatar.set` and `participant.avatar.get` (one picture per participant, a person or a Chief, in one avatar store; resource-api-v2.md "Participant avatars") and the `participant-avatar-changed` event on `subscribe` for a connection that echoes the capability in `set-client-info`. `chief-profile-adopt-v1` advertises `chief.profile.adopt` (the owner makes an existing agent participant a Chief record under its own id; resource-api-v2.md "Chief registry"). `ws-conversations-v1` advertises the local conversation owner on an authenticated WebSocket connection (the static credential or an approved pairing) as `user_local`, the same as on a Unix connection: `conversation-list`, `conversation-create`, `conversation-snapshot`, `conversation-history`, `conversation-thread`, `conversation-link-resolve` (with `conversation-tree-v1`), `conversation-search`, `conversation-op`, `conversation-typing`, the `conversation.*` operations, and the `conversation-changed`, `conversation-typing` and (with `conversation-draft-events-v1`) `conversation-draft` events on `subscribe`; `conversation-bind`, `conversation-agent-token` and `conversation-import` stay on Unix connections, and the attachment commands come to WebSocket connections only with `ws-attachments-v1` (cmux#13742). `local-conversations-v1` advertises the local conversation owner on trusted local connections: `conversation-list`, `conversation-create`, `conversation-snapshot`, `conversation-history`, `conversation-op`, `conversation-typing`, `conversation-bind`, `conversation-agent-token`, `conversation-import`, and the `conversation-changed` and `conversation-typing` events. `notification-source-v1` advertises `source` on `notify`, the `notification` event, the tab `notification` marker, and `list-notifications` rows (`extra.source` in resource notification snapshots), and daemon-side desktop notifications from terminal output (OSC 9, OSC 777 `notify`, kitty OSC 99) with source `terminal`. `terminal-shell-args-v1` advertises `shell_args` on `new-tab`, `split`, `new-pane`, `new-pane-right`, and `create-terminal`: the terminal runs its `SHELL` from `env` (else the daemon's default shell) with those arguments, so a frontend can apply Ghostty's argv-based shell integration (bash `--posix` with `ENV`, nushell `--execute`). `terminal-frontend-shell-integration-v1` lets a frontend that resolves Ghostty's shell integration itself (from the user's `shell-integration` and `shell-integration-features`, into the terminal's `env` and `shell_args`) echo it in `set-client-info`; the terminals that connection creates with a `SHELL` in `env` then run that shell exactly as given (stored as `argv`), with no host-added integration, on `new-tab`, `split`, `new-pane`, `new-pane-right`, `new-row`, `create-terminal`, and a terminal `respawn`; without a `SHELL` the host integrates as before. `new-workspace` and the reopen-saved-group commands take no `env` and keep the host integration; `new-screen` follows it with `screen-terminal-env-v1`. `screen-terminal-env-v1` advertises `env`, `terminal_id`, and `shell_args` on `new-screen` (the first terminal takes them as on `new-pane`, including `terminal-frontend-shell-integration-v1`) and `terminal_id` in its result. `launch-snapshot-v1` advertises `launch_snapshot_path` in `identify` and the launch snapshot file described there. `close-reason-v1` advertises `reason` on `close-tabs` and, with `end_terminals`, on `close-workspace`: `session_end` closes without a closed-history record. `conversation-tab-transaction-v1` advertises `transaction` on `new-conversation-tab`: the created tab's `tab-added` delta and the result echo it. `browser-host-provider-v1` advertises `browser-host-provider`: the verified local app gets the provider credentials of the daemon's own browser host. `frontend-browser-activate-v1` advertises `activate` on `new-frontend-browser-tab`. `history-search-v1` (advertised only when the daemon has a search index) advertises `history-search`. `frontend-browser-insert-after-v1` advertises `after` on `new-frontend-browser-tab`. `bookmarks-v1` advertises one bookmark tree per browser profile: `list-bookmarks`, `create-bookmark`, `update-bookmark`, `move-bookmark`, `delete-bookmark`, `import-bookmarks`, the `bookmarks-changed` event, and `deleted_bookmarks` in the `delete-browser-profile` result. `bookmark-icon-v1` advertises a bookmark folder's `icon`: set or cleared by `update-bookmark` and shown in `list-bookmarks` (see "Icon values" under `put-blob`). `session-icon-v1` advertises a session's (machine's) `icon` in the home session registry: set or cleared by `set-session-icon`, kept by `put-session`, and shown on each `list-personal` session record. `icon-assets-v1` advertises the asset blob store (`put-blob`, `get-blob`) and the asset icon values `image:sha256-` and `svg:sha256-` on the workspace, screen, room, browser profile and workspace status icon fields (see "Icon values" under `put-blob`). `ws-listen-v1` (advertised when the binary can open a page listener) advertises `ws-listen`: a running owner opens its loopback page listener and credential file on request. `cloud-conversations-v1` (advertised only when the daemon has a cloud transport) advertises the cloud conversations proxy on trusted local connections: the `cloud-session-*`, `cloud-inbox-*` and `cloud-conversation-*` commands and the `cloud-*` events (plans/cmux-next/home-cloud-proxy.md). `cloud-lease-app-only-v1` (advertised with `cloud-conversations-v1`) advertises that only the verified cmux app connection may replace, clear or use a lease the app set and send `cloud-conversation-op` with `origin: "user"`; other connections get `cloud_conversation_rejected` with `reason` `forbidden`. `page-credential-v1` advertises `page-credential`: the verified cmux app gets a credential for its own page. `page-credential-scoped-v1` advertises the optional `page` field of `page-credential`: any trusted local connection gets a single-use credential bound to that page of the page-router table, which admits only a WebSocket handshake whose Origin names that page and never carries the Cloud lease. `ws-cloud-conversations-v1` (advertised with `cloud-conversations-v1`) advertises that a WebSocket connection that authenticated with a pairing-store credential (an approved pairing, or a `page-credential`; never the static or file token, which any same-user process can read) acts as the person with the lease the verified app set: `cloud-session-status` (with `participant`), `cloud-inbox-list`, `cloud-conversation-snapshot`, `cloud-conversation-history`, `cloud-conversation-thread`, `cloud-conversation-op` (also with `origin: "user"`), the `cloud-*-subscribe` and `cloud-*-unsubscribe` commands, and the `cloud-*` events on `subscribe`; it never sets or clears a lease, and without the app's lease it is signed out (`cloud_signed_out`), never using a lease another connection set (cmux#13742). `origin-claim-v1` advertises `client-hello` step 1, the optional `origin` claim on `cmux.protocol/2` requests, and `origin.confirmation.issue` (see "client-hello" and `resource-api-v2.md`, "Request origin"). `terminal-clipboard-read-v1` advertises the OSC 52 clipboard-read broker: `terminal-clipboard-subscribe`, `terminal-clipboard-reply`, and the targeted `terminal-clipboard-read` and `terminal-clipboard-read-cancelled` control events (see "Terminal clipboard reads"). `agent-session-attach-v1` (advertised when the daemon resolved an acpmux socket path) advertises the `agent-session-*` commands and events on trusted local connections (see "Agent session attach"). `agent-session-start-v1` (advertised with `agent-session-attach-v1`) advertises `agent-session-start` (see "agent-session-start"). `agent-session-start-keyed-reason-v1` (advertised with `agent-session-start-v1`) advertises `require_person_key` and `person_instance` on `agent-session-start` and its typed `agent_session.needs_keyed_start` refusal. `agent-transfer-v1` (advertised with `agent-session-attach-v1` once the daemon knows its host) advertises the `agent-transfer-*` commands on trusted local connections (see "Agent chat transfer"). `access-request-v1` (advertised on a cmux Cloud host) advertises the `access-request*` commands on trusted local connections (see "Access requests"). `feed-local-owner-v1` advertises the daemon's local feed owner (plans/cmux-next/feed.md section 9.1): every notification is also a local feed item committed in the same transaction, `select-tab`, `select-workspace`, `select-screen`, `focus-pane` and `focus-direction` no longer clear unread (a client acknowledges with `ack-tab-notifications`, which also reads the tab's local items and reports `refused` items), the `feed-local-list`, `feed-local-read`, `feed-local-handoff-begin`, `feed-local-handoff-abort` and `feed-local-handoff-done` commands, and the error codes `feed.moving`, `owner.unreachable` (both retryable), `feed.invalid_state`, `not_found` and `forbidden` (a handoff step from a connection that is not the verified cmux app). `chief-profile-ws-v1` advertises that the person's page (page `home-channels` admitted with a page-bound credential, or an authenticated WebSocket with the daemon's own credential and no page Origin) may call `chief.profile.*`: list, read, and change display_name, role and the avatar; create, delete, instructions, personality and the grant fields need the person's gesture (resource-api-v2.md "Chief registry"). `chief-events-v1` advertises the `chief-changed` event on `subscribe` (a Chief's id and `created`, `updated` or `deleted`, after every committed `chief.profile.*` write) for the person's own connection (`user_local`, the Channels page included) that echoes the capability in `set-client-info`. Errors: diff --git a/cmux-tui/spec/inventory.json b/cmux-tui/spec/inventory.json index a7d671c20a82..c854864b6165 100644 --- a/cmux-tui/spec/inventory.json +++ b/cmux-tui/spec/inventory.json @@ -32,6 +32,7 @@ "browser.viewer.resize", "chief.engine.get", "chief.engine.set", + "chief.profile.adopt", "chief.profile.avatar.get", "chief.profile.avatar.set", "chief.profile.create", diff --git a/cmux-tui/spec/resource-api-v2.json b/cmux-tui/spec/resource-api-v2.json index cbbb52ded50b..2ec69a923219 100644 --- a/cmux-tui/spec/resource-api-v2.json +++ b/cmux-tui/spec/resource-api-v2.json @@ -77,6 +77,7 @@ "browser.viewer.resize", "chief.engine.get", "chief.engine.set", + "chief.profile.adopt", "chief.profile.avatar.get", "chief.profile.avatar.set", "chief.profile.create", @@ -453,6 +454,7 @@ "browser.tab.emulate.clear", "browser.tab.set_profile", "chief.engine.set", + "chief.profile.adopt", "chief.profile.avatar.set", "chief.profile.create", "chief.profile.delete", @@ -688,6 +690,7 @@ "browser.tab.emulate.clear", "browser.tab.set_profile", "chief.engine.set", + "chief.profile.adopt", "chief.profile.avatar.set", "chief.profile.create", "chief.profile.delete", diff --git a/cmux-tui/spec/resource-api-v2.md b/cmux-tui/spec/resource-api-v2.md index 87e600ef3659..26936d0cd657 100644 --- a/cmux-tui/spec/resource-api-v2.md +++ b/cmux-tui/spec/resource-api-v2.md @@ -968,7 +968,7 @@ operations after the server socket is bound. | Class | Operations | | --- | --- | | read | `agent.list`, `agent.message.list`, `browser.devices.list`, `browser.get`, `browser.list`, `chief.engine.get`, `chief.profile.avatar.get`, `chief.profile.get`, `chief.profile.list`, `client.get`, `client.list`, `closed.list`, `conversation.get`, `conversation.history`, `conversation.list`, `conversation.search`, `credential.mint`, `credential.verify`, `frontend_projection.get`, `git.branches`, `git.checkpoint.diff`, `git.checkpoint.get`, `git.checkpoint.list`, `git.diff`, `git.files.list`, `git.files.search`, `git.status`, `history.entries.list`, `history.visit.summaries`, `keybindings.get`, `machine.get`, `machine.list`, `notification.list`, `pairing_request.list`, `palette_usage.get`, `pane.get`, `pane.list`, `pane.neighbor.get`, `participant.avatar.get`, `project.list`, `room.list`, `saved_tab_group.list`, `screen.get`, `screen.layout.export`, `screen.list`, `screen_group.get`, `screen_group.list`, `session.creation.resolve`, `session.get`, `session.journal.checkpoint.list`, `session.journal.hook.list`, `session.journal.producer.list`, `session.journal.restore.preview`, `session.journal.segment.list`, `session.list`, `session.ping`, `session.snapshot`, `settings.get`, `settings.list`, `settings.schema`, `settings.snapshot`, `sidebar.get`, `sidebar_layout.get`, `sidebar_view.get`, `tab.get`, `tab.list`, `tab_group.get`, `tab_group.list`, `terminal.copy`, `terminal.get`, `terminal.history.read`, `terminal.list`, `terminal.output_read`, `terminal.process.get`, `terminal.screen.read`, `terminal.state.read`, `terminal.wait`, `terminal.wait_exit`, `window_record.list`, `workspace.agent_start.get`, `workspace.get`, `workspace.list`, `workspace.placement.list`, `workspace_group.list`, `workspace_log.list`, `workspace_status.list` | -| mutation | `agent.message.mark`, `agent.message.release`, `agent.message.send`, `agent.report`, `browser.activate`, `browser.back`, `browser.close`, `browser.devices.add_custom`, `browser.devices.remove_custom`, `browser.forward`, `browser.input.key`, `browser.input.mouse`, `browser.input.text`, `browser.input.wheel`, `browser.navigate`, `browser.reload`, `browser.tab.emulate`, `browser.tab.emulate.clear`, `browser.tab.set_profile`, `chief.engine.set`, `chief.profile.avatar.set`, `chief.profile.create`, `chief.profile.delete`, `chief.profile.update`, `chief.stop`, `closed.delete`, `closed.reopen`, `column.update`, `conversation.draft`, `conversation.send`, `conversation.typing`, `credential.rotate`, `frontend_projection.put`, `git.checkpoint.create`, `git.checkpoint.pin`, `git.checkpoint.unpin`, `git.commit`, `git.push`, `git.worktree.apply`, `git.worktree.create`, `git.worktree.remove`, `history.backups.purge`, `history.clear`, `history.entries.remove`, `history.restore`, `history.site.remove`, `history.trail.record`, `history.visit.import`, `history.visit.record`, `history.visit.remove`, `history.visit.title`, `keybindings.remove`, `keybindings.reset`, `keybindings.set`, `layout.bulk`, `notification.ack`, `notification.clear`, `notification.create`, `pairing_request.resolve`, `palette_usage.forget`, `palette_usage.hide`, `palette_usage.import`, `palette_usage.record`, `pane.close`, `pane.create`, `pane.focus`, `pane.focus_direction`, `pane.rename`, `pane.run`, `pane.split`, `pane.split_ratio.set`, `pane.swap`, `pane.viewport_width.set`, `pane.zoom`, `participant.avatar.set`, `project.add`, `project.observe`, `project.remove`, `project.source.update`, `project.sync`, `project.update`, `room.create`, `room.delete`, `room.follow`, `room.move`, `room.pin`, `room.unpin`, `room.update`, `saved_tab_group.delete`, `saved_tab_group.reopen`, `saved_tab_group.save`, `screen.close`, `screen.create`, `screen.focus`, `screen.layout.undo`, `screen.move`, `screen.rename`, `screen.update`, `screen_group.add_screens`, `screen_group.create`, `screen_group.remove_screens`, `screen_group.ungroup`, `screen_group.update`, `session.journal.append`, `session.journal.checkpoint.create`, `session.journal.hook.put`, `session.journal.producer.put`, `session.journal.segment.seal`, `session.open`, `session.reload_config`, `session.shutdown`, `session.terminal_defaults.update`, `session.window.title.clear`, `session.window.title.set`, `settings.reset`, `settings.reset_all`, `settings.set`, `sidebar.adopt`, `sidebar.apply`, `sidebar.group.create`, `sidebar.group.ungroup`, `sidebar.group.update`, `sidebar.item.add`, `sidebar.item.remove`, `sidebar.item.remove_ref`, `sidebar.item.update`, `sidebar.move`, `sidebar.pin`, `sidebar.reset`, `sidebar.section.add`, `sidebar.section.move`, `sidebar.section.remove`, `sidebar.section.update`, `sidebar.unpin`, `sidebar_layout.update`, `sidebar_view.ensure`, `sidebar_view.input`, `sidebar_view.reload`, `sidebar_view.resize`, `tab.close`, `tab.create_browser`, `tab.create_terminal`, `tab.focus`, `tab.move`, `tab.pin`, `tab.rename`, `tab.unpin`, `tab.update`, `tab_group.add_tabs`, `tab_group.close`, `tab_group.create`, `tab_group.move`, `tab_group.remove_tabs`, `tab_group.ungroup`, `tab_group.update`, `terminal.close`, `terminal.history.clear`, `terminal.input.focus`, `terminal.input.keys`, `terminal.input.mouse`, `terminal.input.write`, `terminal.move`, `terminal.project`, `terminal.viewport.scroll`, `window_record.delete`, `window_record.put`, `workspace.agent_folder.set`, `workspace.close`, `workspace.create`, `workspace.ensure_app`, `workspace.ensure_home`, `workspace.focus`, `workspace.layout.apply`, `workspace.move`, `workspace.open_url`, `workspace.place`, `workspace.rename`, `workspace.run`, `workspace.update`, `workspace_group.create`, `workspace_group.delete`, `workspace_group.move`, `workspace_group.update`, `workspace_log.append`, `workspace_log.clear`, `workspace_progress.clear`, `workspace_progress.set`, `workspace_status.clear`, `workspace_status.set` | +| mutation | `agent.message.mark`, `agent.message.release`, `agent.message.send`, `agent.report`, `browser.activate`, `browser.back`, `browser.close`, `browser.devices.add_custom`, `browser.devices.remove_custom`, `browser.forward`, `browser.input.key`, `browser.input.mouse`, `browser.input.text`, `browser.input.wheel`, `browser.navigate`, `browser.reload`, `browser.tab.emulate`, `browser.tab.emulate.clear`, `browser.tab.set_profile`, `chief.engine.set`, `chief.profile.adopt`, `chief.profile.avatar.set`, `chief.profile.create`, `chief.profile.delete`, `chief.profile.update`, `chief.stop`, `closed.delete`, `closed.reopen`, `column.update`, `conversation.draft`, `conversation.send`, `conversation.typing`, `credential.rotate`, `frontend_projection.put`, `git.checkpoint.create`, `git.checkpoint.pin`, `git.checkpoint.unpin`, `git.commit`, `git.push`, `git.worktree.apply`, `git.worktree.create`, `git.worktree.remove`, `history.backups.purge`, `history.clear`, `history.entries.remove`, `history.restore`, `history.site.remove`, `history.trail.record`, `history.visit.import`, `history.visit.record`, `history.visit.remove`, `history.visit.title`, `keybindings.remove`, `keybindings.reset`, `keybindings.set`, `layout.bulk`, `notification.ack`, `notification.clear`, `notification.create`, `pairing_request.resolve`, `palette_usage.forget`, `palette_usage.hide`, `palette_usage.import`, `palette_usage.record`, `pane.close`, `pane.create`, `pane.focus`, `pane.focus_direction`, `pane.rename`, `pane.run`, `pane.split`, `pane.split_ratio.set`, `pane.swap`, `pane.viewport_width.set`, `pane.zoom`, `participant.avatar.set`, `project.add`, `project.observe`, `project.remove`, `project.source.update`, `project.sync`, `project.update`, `room.create`, `room.delete`, `room.follow`, `room.move`, `room.pin`, `room.unpin`, `room.update`, `saved_tab_group.delete`, `saved_tab_group.reopen`, `saved_tab_group.save`, `screen.close`, `screen.create`, `screen.focus`, `screen.layout.undo`, `screen.move`, `screen.rename`, `screen.update`, `screen_group.add_screens`, `screen_group.create`, `screen_group.remove_screens`, `screen_group.ungroup`, `screen_group.update`, `session.journal.append`, `session.journal.checkpoint.create`, `session.journal.hook.put`, `session.journal.producer.put`, `session.journal.segment.seal`, `session.open`, `session.reload_config`, `session.shutdown`, `session.terminal_defaults.update`, `session.window.title.clear`, `session.window.title.set`, `settings.reset`, `settings.reset_all`, `settings.set`, `sidebar.adopt`, `sidebar.apply`, `sidebar.group.create`, `sidebar.group.ungroup`, `sidebar.group.update`, `sidebar.item.add`, `sidebar.item.remove`, `sidebar.item.remove_ref`, `sidebar.item.update`, `sidebar.move`, `sidebar.pin`, `sidebar.reset`, `sidebar.section.add`, `sidebar.section.move`, `sidebar.section.remove`, `sidebar.section.update`, `sidebar.unpin`, `sidebar_layout.update`, `sidebar_view.ensure`, `sidebar_view.input`, `sidebar_view.reload`, `sidebar_view.resize`, `tab.close`, `tab.create_browser`, `tab.create_terminal`, `tab.focus`, `tab.move`, `tab.pin`, `tab.rename`, `tab.unpin`, `tab.update`, `tab_group.add_tabs`, `tab_group.close`, `tab_group.create`, `tab_group.move`, `tab_group.remove_tabs`, `tab_group.ungroup`, `tab_group.update`, `terminal.close`, `terminal.history.clear`, `terminal.input.focus`, `terminal.input.keys`, `terminal.input.mouse`, `terminal.input.write`, `terminal.move`, `terminal.project`, `terminal.viewport.scroll`, `window_record.delete`, `window_record.put`, `workspace.agent_folder.set`, `workspace.close`, `workspace.create`, `workspace.ensure_app`, `workspace.ensure_home`, `workspace.focus`, `workspace.layout.apply`, `workspace.move`, `workspace.open_url`, `workspace.place`, `workspace.rename`, `workspace.run`, `workspace.update`, `workspace_group.create`, `workspace_group.delete`, `workspace_group.move`, `workspace_group.update`, `workspace_log.append`, `workspace_log.clear`, `workspace_progress.clear`, `workspace_progress.set`, `workspace_status.clear`, `workspace_status.set` | | stream_open | `browser.attach`, `conversation.events`, `session.events`, `session.journal.subscribe`, `sidebar_view.attach`, `terminal.attach` | | connection_control | `browser.viewer.release`, `browser.viewer.resize`, `client.cell_pixels.set`, `client.detach`, `client.metadata.update`, `client.sizing.release`, `client.sizing.set`, `origin.confirmation.issue`, `request.cancel`, `stream.cancel`, `terminal.renderer_grant.create`, `terminal.viewer.release`, `terminal.viewer.resize` | | local | `sidebar_plugin.install`, `sidebar_plugin.list`, `sidebar_plugin.remove`, `sidebar_plugin.update`, `sidebar_plugin.use`, `sidebar_plugin.use_builtin` | @@ -1221,6 +1221,21 @@ directory. A Chief's id is its conversation participant id: the default Chief is `agent_mux` and exists from the first read; a new Chief is `agent_` and 26 Crockford base32 characters. A deleted id is never reused. +`chief.profile.adopt` (capability `chief-profile-adopt-v1`) makes an existing +agent participant of the owner's conversations a Chief record under its own +id, so its conversations, history and credentials stay its own. Only the +owner calls it, from a trusted local Unix connection: a page (WebSocket or +the host's page relay), a paired or remote device and a bound agent get +`origin.forbidden`, and no page route lists it. The record gets +`chief.profile.create`'s defaults and only the fields given; from the +participant it takes its id and, without `display_name`, its display name. +`agent_mux` is `default_chief`, a live record `already_chief`, a deleted +Chief `deleted_chief` (an adopt never revives a deleted Chief or its kept +memory), and a person or an unknown id `not_an_agent_participant` +(`operation.failed` `details.reason`). It is idempotent per key like +`chief.profile.create`, reaches the brain supervisor as a create does and +publishes `chief-changed` with change `created`. + The callers are the owner (the rule of Chief control above) and the Chiefs: a Unix connection bound with `conversation-bind` to a live record's id. With `chief-profile-ws-v1` the person's page is a caller too: an authenticated diff --git a/cmux-tui/spec/resource-operations-v2.json b/cmux-tui/spec/resource-operations-v2.json index cc4eaad352b4..8ab557f83a5d 100644 --- a/cmux-tui/spec/resource-operations-v2.json +++ b/cmux-tui/spec/resource-operations-v2.json @@ -15172,6 +15172,159 @@ "Setting the same values again changes nothing, so a retry is safe; replayed is always false." ] }, + "chief.profile.adopt": { + "class": "mutation", + "idempotency": "required", + "target": "session", + "ancestors": [ + "machine" + ], + "params": { + "selectors": { + "machine": "required", + "session": "required" + }, + "fields": { + "participant_id": { + "required": true, + "type": { + "kind": "primitive", + "name": "string", + "min_length": 7, + "max_length": 70 + }, + "description": "An agent participant of the owner's local conversations (agent_ then 1 to 64 characters) that is not a Chief record and not a deleted Chief; never agent_mux and never a person." + }, + "display_name": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "min_length": 1, + "max_length": 100 + }, + "description": "1 to 100 characters, one line; default: the participant's display name." + }, + "role": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 200 + } + }, + "personality": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 4000 + } + }, + "instructions": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 32000 + }, + "description": "System prompt additions." + }, + "tools": { + "required": false, + "type": { + "kind": "array", + "items": { + "kind": "primitive", + "name": "string", + "min_length": 1, + "max_length": 64 + } + }, + "description": "Names from browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces; owner only. A new Chief without it gets spawn, tell, workspaces." + }, + "harnesses": { + "required": false, + "type": { + "kind": "array", + "items": { + "kind": "primitive", + "name": "string", + "min_length": 1, + "max_length": 64 + } + }, + "description": "Owner only; empty: every harness." + }, + "self_edit": { + "required": false, + "type": { + "kind": "array", + "items": { + "kind": "primitive", + "name": "string", + "min_length": 1, + "max_length": 64 + } + }, + "description": "Names from avatar, display_name, instructions, personality, role; owner only. A new Chief without it gets avatar." + }, + "harness": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 128 + }, + "description": "Owner only; empty or default clears it." + }, + "model": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 128 + }, + "description": "Owner only; empty or default clears it." + }, + "effort": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 128 + }, + "description": "Owner only; empty or default clears it." + } + }, + "extra": false + }, + "result": { + "kind": "apply", + "name": "MutationResult", + "arguments": [ + { + "kind": "ref", + "name": "ChiefRecord" + } + ] + }, + "errors": [ + "idempotency.conflict", + "operation.failed", + "origin.forbidden", + "selector.ambiguous", + "selector.invalid", + "selector.not_found", + "validation.invalid" + ], + "constraints": [ + "Owner only: a trusted local Unix caller acting as the person (a registered Unix client with no link peer record whose conversation principal is user_local). A page WebSocket, a paired or remote device and a bound agent (a Chief) are refused with origin.forbidden; no page route allows it.", + "The participant must be an agent participant (kind agent) of a local conversation that is not a Chief record: a live record is already_chief, a deleted Chief (its tombstone, and its memory while it is kept) is deleted_chief, agent_mux is default_chief, anything else is not_an_agent_participant (operation.failed details.reason). At most 64 live Chiefs: too_many_chiefs.", + "The record gets create's defaults (tools spawn, tell, workspaces; self_edit avatar; no engine) and only the fields given; from the participant it takes only its id and, without display_name, its display name. Its conversations, history and credentials stay as they are.", + "The idempotency key is per caller, as for chief.profile.create: the same key with the same params answers the committed record with replayed true; other params under the key are idempotency.conflict. The committed write reaches the Chief brain supervisor and publishes chief-profile-changed as a create does." + ] + }, "chief.profile.avatar.get": { "class": "read", "idempotency": "forbidden", diff --git a/cmux-tui/spec/resource-operations-v2.md b/cmux-tui/spec/resource-operations-v2.md index eac6a4514bb5..4ff8864429ac 100644 --- a/cmux-tui/spec/resource-operations-v2.md +++ b/cmux-tui/spec/resource-operations-v2.md @@ -13,7 +13,7 @@ broker protocol. | Class | Count | Semantics | | --- | ---: | --- | | `read` | 86 | Reads state and forbids an idempotency key | -| `mutation` | 189 | Requires an idempotency key and returns a mutation result | +| `mutation` | 190 | Requires an idempotency key and returns a mutation result | | `stream_open` | 6 | Opens a connection-owned typed stream | | `connection_control` | 13 | Changes only connection-local state | @@ -31,7 +31,7 @@ correlation, and idempotency metadata. | `agent` | 6 | `agent.list`, `agent.message.list`, `agent.message.mark`, `agent.message.release`, `agent.message.send`, `agent.report` | | `browser` | 15 | `browser.activate`, `browser.attach`, `browser.back`, `browser.close`, `browser.forward`, `browser.get`, `browser.input.key`, `browser.input.mouse`, `browser.input.text`, `browser.input.wheel`, `browser.list`, `browser.navigate`, `browser.reload`, `browser.viewer.release`, `browser.viewer.resize` | | `browser_device` | 3 | `browser.devices.add_custom`, `browser.devices.list`, `browser.devices.remove_custom` | -| `chief` | 10 | `chief.engine.get`, `chief.engine.set`, `chief.profile.avatar.get`, `chief.profile.avatar.set`, `chief.profile.create`, `chief.profile.delete`, `chief.profile.get`, `chief.profile.list`, `chief.profile.update`, `chief.stop` | +| `chief` | 11 | `chief.engine.get`, `chief.engine.set`, `chief.profile.adopt`, `chief.profile.avatar.get`, `chief.profile.avatar.set`, `chief.profile.create`, `chief.profile.delete`, `chief.profile.get`, `chief.profile.list`, `chief.profile.update`, `chief.stop` | | `client` | 8 | `client.cell_pixels.set`, `client.detach`, `client.get`, `client.list`, `client.metadata.update`, `client.sizing.release`, `client.sizing.set`, `origin.confirmation.issue` | | `closed` | 3 | `closed.delete`, `closed.list`, `closed.reopen` | | `conversation` | 8 | `conversation.draft`, `conversation.events`, `conversation.get`, `conversation.history`, `conversation.list`, `conversation.search`, `conversation.send`, `conversation.typing` | From e5f8e8b0171930a2ce9df9aff8616515151162af Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:48:06 -0700 Subject: [PATCH 073/183] cmux-motion: SpringParameters::finite_or clamps a negative damping fraction to 0 (hq-4e note; solve already clamped zeta) Co-Authored-By: Claude Opus 5.5 (1M context) --- cmux-tui/crates/cmux-motion/src/spring.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/cmux-motion/src/spring.rs b/cmux-tui/crates/cmux-motion/src/spring.rs index 5c0b312763c3..2ef5079dd163 100644 --- a/cmux-tui/crates/cmux-motion/src/spring.rs +++ b/cmux-tui/crates/cmux-motion/src/spring.rs @@ -49,9 +49,9 @@ impl SpringParameters { } /// This tuning with a non-finite or non-positive response, or a - /// non-finite damping fraction, replaced by `fallback`'s. A NaN damping - /// would otherwise become 0 (an undamped spring that never settles, so - /// the clock never idles). + /// non-finite damping fraction, replaced by `fallback`'s, and a negative + /// damping fraction clamped to 0. A NaN damping would otherwise become 0 + /// (an undamped spring that never settles, so the clock never idles). pub fn finite_or(self, fallback: SpringParameters) -> Self { let response = if self.response.is_finite() && self.response > 0. { self.response @@ -59,7 +59,7 @@ impl SpringParameters { fallback.response }; let damping_fraction = if self.damping_fraction.is_finite() { - self.damping_fraction + self.damping_fraction.max(0.) } else { fallback.damping_fraction }; From 3c8b77a62a1528d9248b59c2aa1da16657d4be79 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:50:18 -0700 Subject: [PATCH 074/183] fix(cmux-next): a Chief profile page call always answers, on a relay connection to the current owner (cx-ebm.59.5) - Every page call answers within ChiefProfilePageRelay.callDeadline (10 s, a DemandTimer on an injected clock): the owner's result or refusal, or cmux.protocol.timeout; the sheet then clears busy and shows the error, and the next call starts on a new relay connection. - The relay connection is keyed by the owner process the app's main connection is attached to (socket, pid, generation): after a Chief owner restart the next call connects to the new owner. - Each relay connection has its own client-hello id: a confirmation token is bound to the connection the call goes on (a closed connection's late handshake can no longer overwrite it). - A call whose relay connection closed under it goes once more on a new connection (mutations carry their idempotency key); the gesture backs that retry too. --- .../ChiefProfilePageRelay.swift | 144 +++++++++++++----- .../CmuxNextHomeEngine/PageCallDeadline.swift | 46 ++++++ 2 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift index 5ccf0be70264..345bca608456 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift @@ -38,16 +38,37 @@ public final class ChiefProfilePageRelay { } private weak var owner: ChiefConversationOwner? - private let identity = PageRelayIdentity(page: "home-channels") private let wake = RetryWake(owner: "ChiefProfilePageRelay") private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "chief-profile-relay") - private var relay: (connection: DaemonConnection, socket: String)? - private var starting: (task: Task, socket: String)? + private let clock: any Clock + /// Every page call answers within this, also when a step below it never does: the page's + /// sheet then clears busy and shows the error (`cmux.protocol.timeout`). It covers a connect + /// (two 2 s handshake lines), a confirmation (2 s) and the call (2 s), and one reconnect. + nonisolated static let callDeadline: Duration = .seconds(10) + + /// The owner process a relay connection serves: a restarted owner (same socket, new process) + /// is another owner, and its page calls go on a connection to it. + struct OwnerKey: Equatable { + var socket: String + var pid: Int32 + var generation: DaemonGeneration + } + + /// One page relay connection with its own `client-hello` id (a token is bound to it). + private struct Relay { + var connection: DaemonConnection + var owner: OwnerKey + var identity: PageRelayIdentity + } + + private var relay: Relay? + private var starting: (task: Task, owner: OwnerKey)? /// The event uptime of the last gesture a call spent. private var spentGesture: TimeInterval? - init(owner: ChiefConversationOwner) { + init(owner: ChiefConversationOwner, clock: any Clock = ContinuousClock()) { self.owner = owner + self.clock = clock } /// Whether the owner takes `operation` with `params` from the page only on a gesture. @@ -60,59 +81,108 @@ public final class ChiefProfilePageRelay { } /// One page call to the owner. `gestureEvent`: the uptime of the real key or pointer event - /// that backs this call (`PageCallContext.gestureEvent`), nil without one. + /// that backs this call (`PageCallContext.gestureEvent`), nil without one. It always answers + /// within ``callDeadline``: the owner's result, its refusal, or a typed error. public func send(operation: String, params: [String: JSONValue], idempotencyKey: String?, gestureEvent: TimeInterval?) async throws -> JSONValue { guard Self.operations.contains(operation) else { throw DaemonError.command(cmd: operation, message: "not a Chief profile operation", code: "unknown_op") } + // One gesture backs one call; a reconnect inside this call may use it again. + var gesture = false + if Self.needsGesture(operation, params: params), let event = gestureEvent, event != spentGesture { + spentGesture = event + gesture = true + } let started = ContinuousClock.now - let ms = { Int((ContinuousClock.now - started) / .milliseconds(1)) } - logger.info("\(operation, privacy: .public): start gesture=\(gestureEvent != nil)") + let elapsed = { Int((ContinuousClock.now - started) / .milliseconds(1)) } + logger.info("\(operation, privacy: .public): start, gesture \(gesture)") do { - guard let owner, let main = owner.connection, let socket = await owner.socketPath() else { throw DaemonError.notConnected } - let relay = try await relayConnection(socket: socket) - logger.info("\(operation, privacy: .public): relay ready at \(ms()) ms") - var origin = JSONValue.object(["claim": .string("page")]) - if Self.needsGesture(operation, params: params), let event = gestureEvent, event != spentGesture { - spentGesture = event - let token = try await confirmationToken(main: main, operation: operation, params: params) - logger.info("\(operation, privacy: .public): confirmation at \(ms()) ms") - origin = .object(["claim": .string("user"), "confirmation": .string(token)]) + let result = try await PageCallDeadline.answer(within: Self.callDeadline, clock: clock, onTimeout: { [weak self] in + // A step that never answered: the next call starts on a new connection. + self?.dropRelay() + return DaemonError.timedOut("\(operation) (no answer from the Chief owner within \(Self.callDeadline))") + }) { [self] in + try await call(operation: operation, params: params, idempotencyKey: idempotencyKey, gesture: gesture) } - let result = try await ResourceRelayClient(connection: relay).send( - operation: operation, params: params, idempotencyKey: idempotencyKey, origin: origin) - logger.info("\(operation, privacy: .public): answered at \(ms()) ms") + logger.info("\(operation, privacy: .public): answered in \(elapsed()) ms") return result } catch { - logger.info("\(operation, privacy: .public): failed at \(ms()) ms: \(String(describing: error), privacy: .public)") + logger.info("\(operation, privacy: .public): failed in \(elapsed()) ms: \(String(describing: error), privacy: .public)") throw error } } - /// The page relay connection to the owner at `socket`; a new one when the owner moved. - private func relayConnection(socket: String) async throws -> DaemonConnection { - if let relay, relay.socket == socket, await relay.connection.isReady { return relay.connection } - // A connect to the same owner in flight is shared; one to an owner that moved is not. - if let starting, starting.socket == socket { return try await starting.task.value } + /// The call on the relay connection to the current owner; once more on a new connection when + /// that connection was closed under it (the owner restarted). A mutation carries its + /// idempotency key, so the owner applies it once. + private func call(operation: String, params: [String: JSONValue], idempotencyKey: String?, gesture: Bool) async throws -> JSONValue { + var attempt = 0 + while true { + attempt += 1 + guard let owner, let main = owner.connection, let socket = await owner.socketPath(), + let identity = owner.identity else { throw DaemonError.notConnected } + let relay = try await relayConnection(OwnerKey(socket: socket, pid: identity.pid, generation: identity.generation)) + do { + var origin = JSONValue.object(["claim": .string("page")]) + if gesture { + let token = try await confirmationToken(main: main, relay: relay, operation: operation, params: params) + origin = .object(["claim": .string("user"), "confirmation": .string(token)]) + } + return try await ResourceRelayClient(connection: relay.connection).send( + operation: operation, params: params, idempotencyKey: idempotencyKey, origin: origin) + } catch let error as DaemonError where attempt == 1 && Self.isConnectionLost(error) { + logger.info("\(operation, privacy: .public): relay connection lost (\(error.description, privacy: .public)); again on a new one") + if self.relay?.connection === relay.connection { dropRelay() } + } + } + } + + nonisolated static func isConnectionLost(_ error: DaemonError) -> Bool { + switch error { + case .notConnected, .connectionClosed, .daemonShutdown: true + default: false + } + } + + /// Closes the relay connection; the next call connects again. + private func dropRelay() { + guard let relay else { return } + self.relay = nil + // task-owner: one close of a connection no call uses any more + Task { await relay.connection.close() } + } + + /// The page relay connection to `owner`, the owner process the app's main connection is + /// attached to now; a new one when that owner restarted or the connection dropped. + private func relayConnection(_ owner: OwnerKey) async throws -> Relay { + if let relay, relay.owner == owner, await relay.connection.isReady, await relay.connection.identity.map({ + $0.pid == owner.pid && $0.generation == owner.generation + }) == true { + return relay + } + // A connect to the same owner in flight is shared; one to another owner is not. + if let starting, starting.owner == owner { return try await starting.task.value } + let identity = PageRelayIdentity(page: "home-channels") let configuration = DaemonConnection.Configuration( clientName: "cmux-next-chief-profile-relay", requiredCapabilities: [], retryWake: wake, terminalEnvironment: nil, role: .pageRelay, relayIdentity: identity) - // task-owner: one connect; ends with its handshake - let task = Task { () async throws -> DaemonConnection in - let connection = DaemonConnection(endpoint: DaemonEndpoint(socketPath: socket), configuration: configuration) + // task-owner: one connect; ends with its handshake (each line has the request deadline) + let task = Task { () async throws -> Relay in + let connection = DaemonConnection(endpoint: DaemonEndpoint(socketPath: owner.socket), configuration: configuration) try await connection.start() - return connection + return Relay(connection: connection, owner: owner, identity: identity) } - starting = (task, socket) - defer { if starting?.socket == socket { starting = nil } } - let connection = try await task.value - await relay?.connection.close() - relay = (connection, socket) - return connection + starting = (task, owner) + defer { if starting?.owner == owner { starting = nil } } + let made = try await task.value + let old = relay + relay = made + if let old, old.connection !== made.connection { await old.connection.close() } + return made } /// `origin.confirmation.issue` on the app's trusted main connection to the owner. - private func confirmationToken(main: DaemonConnection, operation: String, params: [String: JSONValue]) async throws -> String { - guard let relayID = identity.connectionID else { throw DaemonError.notConnected } + private func confirmationToken(main: DaemonConnection, relay: Relay, operation: String, params: [String: JSONValue]) async throws -> String { + guard let relayID = relay.identity.connectionID else { throw DaemonError.notConnected } let issued = try await ResourceRelayClient(connection: main).send( operation: "origin.confirmation.issue", params: ["operation": .string(operation), "params_sha256": .string(try ResourceRelayClient.paramsDigest(params)), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift new file mode 100644 index 000000000000..48588766d131 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift @@ -0,0 +1,46 @@ +import CmuxNextWakeups +import Foundation + +/// A page call that answers by a deadline, also when its work never ends: the first of the work's +/// end and the deadline answers (once). The deadline is a ``DemandTimer`` on the injected clock, so +/// a test moves time without waiting. The work's task is cancelled when the deadline answers; a +/// late result is dropped. +@MainActor +enum PageCallDeadline { + static func answer(within deadline: Duration, clock: any Clock, + onTimeout: @escaping @MainActor @Sendable () -> any Error, + _ work: @escaping @MainActor @Sendable () async throws -> T) async throws -> T { + let once = Once() + let timer = DemandTimer(owner: "PageCallDeadline", clock: clock) + defer { timer.cancel() } + return try await withCheckedThrowingContinuation { continuation in + once.continuation = continuation + // task-owner: the call's work; cancelled when the deadline answers first + once.task = Task { @MainActor in + let result: Result + do { result = .success(try await work()) } catch { result = .failure(error) } + once.finish(result) + } + timer.schedule(after: deadline) { @MainActor in + guard once.isWaiting else { return } + once.finish(.failure(onTimeout())) + } + } + } + + /// The continuation and the work's task; the first ``finish(_:)`` resumes, later ones are dropped. + @MainActor + private final class Once { + var continuation: CheckedContinuation? + var task: Task? + + var isWaiting: Bool { continuation != nil } + + func finish(_ result: Result) { + guard let continuation else { return } + self.continuation = nil + task?.cancel() + continuation.resume(with: result) + } + } +} From 68094767af54c65c22d18ab90bcf5a206f99b9b5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:53:37 -0700 Subject: [PATCH 075/183] test(cmux-next): click proof holds the press so NoActivateKeyboardGuard keeps the activation and WebKit gets the click (cx-ebm.59.5) --- scripts/cmux-next/chief-create-click-proof.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/cmux-next/chief-create-click-proof.py b/scripts/cmux-next/chief-create-click-proof.py index a70c08e14efc..42acd32fa881 100644 --- a/scripts/cmux-next/chief-create-click-proof.py +++ b/scripts/cmux-next/chief-create-click-proof.py @@ -194,11 +194,16 @@ class P(ctypes.Structure): cg.CGEventCreateMouseEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, P, ctypes.c_uint32] cg.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p] cg.CFRelease.argtypes = [ctypes.c_void_p] - for kind in (5, 1, 2): # mouseMoved, leftMouseDown, leftMouseUp + # The app runs with CMUX_NEXT_NO_ACTIVATE, so every click lands on an inactive app: mouse-down activates it, + # and NoActivateKeyboardGuard keeps that activation only when it sees a mouse button still pressed + # (NSEvent.pressedMouseButtons) at didBecomeActive. With an 80 ms press the button was often up by then: the + # guard gave the activation back ("keyboard given back: app_activated, no_user_input"), WebKit got no click, + # and the hovered primary button (grey hover background) looked disabled. A human press is longer; hold it. + for kind, hold in ((5, 0.08), (1, 0.4), (2, 0.08)): # mouseMoved, leftMouseDown (held), leftMouseUp e = cg.CGEventCreateMouseEvent(None, kind, P(x, y), 0) cg.CGEventPost(0, e) cg.CFRelease(e) - time.sleep(0.08) # human-like press interval for the window server, not app synchronization + time.sleep(hold) # human-like press interval for the window server, not app synchronization def post_key(code): From 695ed41686cb427a0d161457a6e03ba28c142969 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:00:30 -0700 Subject: [PATCH 076/183] red: a new Chief's brain gets the job runner's HOME, not the account's (cx-ebm.60.6) hq-6d P1 (mac26 401): the default Chief's host gets the account home (FileManager.homeDirectoryForCurrentUser); a supervisor-started brain gets the daemon's inherited HOME. Behavior test (red): the daemon runs with a job runner's HOME, the brain's HOME is that, not the account's. --- .../crates/cmux-tui/tests/cli/chief_brains.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief_brains.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief_brains.rs index ee01a36a830d..87f20eb724d0 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/chief_brains.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief_brains.rs @@ -383,3 +383,68 @@ fn a_chief_brain_finds_claude_on_the_persons_login_path() { let _ = owned.child.wait(); wait_gone(pid, "after daemon shutdown"); } + +/// The account's home directory (the user database), as the app's default +/// Chief host gets it (`FileManager.homeDirectoryForCurrentUser`). +fn account_home() -> String { + // SAFETY: getpwuid returns a pointer into static storage or null; it is + // read at once, on this thread. + unsafe { + let record = libc::getpwuid(libc::getuid()); + assert!(!record.is_null(), "no user database record"); + std::ffi::CStr::from_ptr((*record).pw_dir).to_str().unwrap().to_owned() + } +} + +/// A new Chief's brain gets the same HOME as the default Chief's (hq-6d P1 +/// 2026-10-14, mac26 401): the account's home, not the HOME of the process +/// that started the app. An app started by a job runner with its own HOME +/// (nx-remote on mac26: HOME is the job root) gave the daemon that HOME; the +/// new Chief's host then read the person's Claude settings env (the +/// subrouter route) from `/.claude/settings.json`, found none, +/// and its turns signed in with the account's expired login. +#[test] +fn a_chief_brain_gets_the_accounts_home_like_the_default_chief() { + use std::os::unix::fs::PermissionsExt; + + let files = unique_temp_dir("cbh"); + fs::create_dir_all(&files).unwrap(); + let stub = files.join("brain.sh"); + fs::write(&stub, STUB).unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap(); + let chief_home = files.join("h"); + fs::create_dir_all(&chief_home).unwrap(); + // The job runner's own HOME, which the daemon inherits. + let job_home = files.join("job-home"); + fs::create_dir_all(job_home.join(".claude")).unwrap(); + let brains = json!({ + "executable": stub, + "home": chief_home, + "app": {"bin_dir": "/opt/cmux-test/bin", "user_name": "Test Person", + "chief_title": "Chief", "acpmux_bin": "/usr/bin/false"}, + }) + .to_string(); + let job_home_text = job_home.display().to_string(); + let mut owned = HeadlessServer::start_with_options( + "chief-brains-home", + None, + None, + &[("CMUX_TUI_CHIEF_BRAINS", brains.as_str()), ("HOME", job_home_text.as_str())], + ); + let server = &owned; + let scout = make_chief(server, "Scout", "Researcher"); + let scout_home = brain_dir(&chief_home, &scout); + let pid: i32 = wait_file(&scout_home.join("pid.1")).parse().unwrap(); + let env = env_of(&scout_home.join("env.1")); + assert_eq!( + env.get("HOME").map(String::as_str), + Some(account_home().as_str()), + "the brain's HOME is not the account's (the job runner's was {job_home_text})" + ); + + let daemon = i32::try_from(owned.child.id()).unwrap(); + // SAFETY: the test's own daemon child. + unsafe { libc::kill(daemon, libc::SIGTERM) }; + let _ = owned.child.wait(); + wait_gone(pid, "after daemon shutdown"); +} From 22dd715d52763448bafc4eba6972bc0166265c34 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:00:56 -0700 Subject: [PATCH 077/183] chief brains: a new Chief's brain gets the account's home (cx-ebm.60.6) hq-6d P1 (mac26 401 for a new Chief): the default Chief's host gets the account home (FileManager.homeDirectoryForCurrentUser); a brain the daemon's supervisor started got the daemon's inherited HOME. On mac26 nx-remote sets HOME to the job root, so the new Chief's host looked for the person's Claude settings env (the subrouter route) in /.claude/settings.json, copied none into its session settings, and its turns and compactor signed in with the account's expired OAuth login. The brain's HOME (and its fixed tool dirs) now come from the user database, the inherited HOME only when it has none. The old per-Chief acpmux cleanup rules out ~/.cmux/chief/default under both homes. --- .../crates/cmux-tui-core/src/chief_brains.rs | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs b/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs index 9f810459d94f..1c7ab70e115c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs +++ b/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs @@ -166,7 +166,14 @@ pub fn brain_environment( ) -> BTreeMap { let mux_home = brain_home(&config.home, chief); let app = &config.app; - let user_home = inherited("HOME").unwrap_or_else(|| "/".into()); + // The account's home, as the app gives the default Chief's host + // (`FileManager.homeDirectoryForCurrentUser`), not the HOME the daemon + // inherited: an app a job runner started (nx-remote on mac26: HOME is + // the job root) passed that on, and the brain then found no Claude + // settings env (the person's API route) in it (hq-6d P1 2026-10-14). + let user_home = account_home() + .or_else(|| inherited("HOME")) + .unwrap_or_else(|| "/".into()); let mut env = BTreeMap::new(); let mut put = |key: &str, value: String| { env.insert(key.to_owned(), value); @@ -235,6 +242,38 @@ pub fn brain_environment( env } +/// The home directory of this process's account in the user database +/// (getpwuid_r); None where there is none (or off Unix). +fn account_home() -> Option { + #[cfg(unix)] + { + let mut buffer = vec![0 as libc::c_char; 4096]; + let mut record = std::mem::MaybeUninit::::uninit(); + let mut found: *mut libc::passwd = std::ptr::null_mut(); + // SAFETY: every pointer is valid for the call and the buffer length + // is the buffer's length. + let rc = unsafe { + libc::getpwuid_r( + libc::getuid(), + record.as_mut_ptr(), + buffer.as_mut_ptr(), + buffer.len(), + &mut found, + ) + }; + if rc != 0 || found.is_null() { + return None; + } + // SAFETY: getpwuid_r succeeded, so pw_dir points into `buffer`. + let dir = unsafe { std::ffi::CStr::from_ptr((*found).pw_dir) }; + dir.to_str().ok().filter(|d| d.starts_with('/')).map(str::to_owned) + } + #[cfg(not(unix))] + { + None + } +} + /// What the supervisor hears. enum Signal { /// A committed message: its conversation and author. @@ -949,10 +988,14 @@ fn stop_lock_holder(lock: &Path) { fn remove_old_acpmux_homes(home: &Path, acpmux_bin: Option<&Path>) { // Without HOME the person's default home cannot be ruled out: keep all. let Some(user) = std::env::var_os("HOME").filter(|h| !h.is_empty()) else { return }; - let shared_default = - std::fs::canonicalize(PathBuf::from(user).join(".cmux/chief/default")).ok(); let Ok(canonical) = std::fs::canonicalize(home) else { return }; - if shared_default.as_deref() == Some(canonical.as_path()) { + // The default home under the inherited HOME and under the account's + // home (a job runner's HOME differs from it). + let defaults = [Some(PathBuf::from(user)), account_home().map(PathBuf::from)]; + if defaults.into_iter().flatten().any(|user| { + std::fs::canonicalize(user.join(".cmux/chief/default")).ok().as_deref() + == Some(canonical.as_path()) + }) { return; } let Ok(entries) = std::fs::read_dir(home.join("c")) else { return }; From de926dcb301c672ba246a34c6bec4d86e3cd60e3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:02:32 -0700 Subject: [PATCH 078/183] cmux-next palette: selecting a Window Style row previews it live Lawrence: "dont make me press enter to preview the style, just selecting it should show the style". A generic previewable-action mechanism in CmuxNextActions (ActionRegistry+Preview): an action can bind a preview that applies its look at once and returns its revert. The palette's root and commands pages report the highlighted row (arrow keys, hover, typing that moves the selection) to registry.showPreview; another row, Escape, leaving the page or closing the palette ends the preview and puts the committed look back. Running the previewed action keeps the look (it writes the setting as before); running any other action, or a run that does not happen, reverts first. Previews of one setting replace each other in place, so moving between styles is one in-memory DesignSettings write: no config write, no file watcher round trip. SettingChoiceActions.bind takes an optional `current:` and then binds a preview for every value; appearance.chromeStyle uses it, so every value of the generated style table (including future ones) previews. A refused save ends the preview. debug.pane_chrome reports each window's chrome_style. GUI proof: scripts/cmux-next/window-style-preview-live.py. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ActionRegistry+Preview.swift | 75 ++++++ .../CmuxNextActions/ActionRegistry.swift | 7 + .../CmuxNextApp/Control/DebugPaneChrome.swift | 2 +- .../PaletteController+Pages.swift | 17 +- .../CmuxNextSettings/ChromeStyleSetting.swift | 6 +- .../SettingChoiceActions.swift | 18 +- .../cmux-next/window-style-preview-live.py | 215 ++++++++++++++++++ 7 files changed, 332 insertions(+), 8 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Preview.swift create mode 100755 scripts/cmux-next/window-style-preview-live.py diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Preview.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Preview.swift new file mode 100644 index 000000000000..6cb7ca5b2be7 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Preview.swift @@ -0,0 +1,75 @@ +/// A live preview of an action before it runs: the palette's highlighted +/// "Window Style: Card" row shows that style in the window without writing +/// cmux.json. `show` applies the look at once and returns how to put the +/// previous look back. Previews of one `group` (one setting) replace each +/// other in place: moving from Card to Flat applies Flat and keeps Card's +/// revert to the committed value, so each move is one cheap write. +public struct ActionPreview { + public var group: String + public var show: @MainActor () -> @MainActor () -> Void + + public init(group: String, show: @escaping @MainActor () -> @MainActor () -> Void) { + self.group = group + self.show = show + } +} + +/// The preview on screen now: its action, group and the revert to the look +/// before the group's first preview. +struct ActivePreview { + var id: ActionID + var group: String + var revert: @MainActor () -> Void +} + +extension ActionRegistry { + /// Makes `id` previewable (`showPreview`). + public func bindPreview(_ id: ActionID, _ preview: ActionPreview) { + previews[canonicalID(for: id)] = preview + } + + public func hasPreview(_ id: ActionID) -> Bool { + previews[canonicalID(for: id)] != nil + } + + /// The action previewed now, if any. + public var previewedAction: ActionID? { activePreview?.id } + + /// Shows the preview of `id`; nil, or an action with no preview, ends + /// the one on screen (the highlight moved to another row). + public func showPreview(_ id: ActionID?) { + guard let id = id.map(canonicalID(for:)), let preview = previews[id] else { return endPreview() } + guard id != activePreview?.id else { return } + if let active = activePreview, active.group == preview.group { + _ = preview.show() + activePreview?.id = id + return + } + endPreview() + activePreview = ActivePreview(id: id, group: preview.group, revert: preview.show()) + } + + /// Puts the committed look back (Escape, the palette closed, a refused run). + public func endPreview() { + guard let active = activePreview else { return } + activePreview = nil + active.revert() + } + + /// Runs `perform` for `id` with the preview settled: running the + /// previewed action keeps its look (the run sets the same value; a + /// handler that refuses calls `endPreview`), running anything else, or + /// a run that did not happen, reverts first. + func settlingPreview(for id: ActionID, _ perform: () -> Bool) -> Bool { + guard let previewed = activePreview?.id else { return perform() } + guard previewed == canonicalID(for: id) else { + endPreview() + return perform() + } + let ran = perform() + if activePreview?.id == previewed { + if ran { activePreview = nil } else { endPreview() } + } + return ran + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry.swift index 6c6d24fc0fc3..581725f01ba9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry.swift @@ -73,6 +73,9 @@ public final class ActionRegistry { /// with the hovered value (action, argument name, value, target), and /// with a nil value when the menu closes. The App previews themes here. @ObservationIgnored public var choicePreview: (@MainActor (ActionID, String, String?, ActionTargetRef?) -> Void)? + /// Live previews by action (`ActionRegistry+Preview`) and the one on screen. + @ObservationIgnored var previews: [ActionID: ActionPreview] = [:] + @ObservationIgnored var activePreview: ActivePreview? /// Every known value of a suggested argument (`ActionSuggestions.source`), /// supplied by the App. @ObservationIgnored public var argumentSuggestions: (@MainActor (String) -> [ActionEnumCase])? @@ -306,6 +309,10 @@ public final class ActionRegistry { /// Performs with a target and typed arguments (palette, CLI, context menus). Fails when a required argument is missing. @discardableResult public func perform(_ id: ActionID, invocation: ActionInvocation) -> Bool { + settlingPreview(for: id) { performNow(id, invocation: invocation) } + } + + private func performNow(_ id: ActionID, invocation: ActionInvocation) -> Bool { if disabledFeature(for: id) != nil { return false } if let route = keyWindowRoute?(canonicalID(for: id), invocation) { return route.perform { refuse($0) } } guard let action = action(for: id), isAvailable(id, for: invocation), action.isEnabled() else { return false } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugPaneChrome.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugPaneChrome.swift index 98252114a9f1..130b5816f23e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugPaneChrome.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugPaneChrome.swift @@ -68,7 +68,7 @@ enum DebugPaneChrome { return .object([ "id": .string(controller.state.id), "scale": .number(Double(window.backingScaleFactor)), - "pane_padding": .number(Double(Metrics.panePadding)), + "pane_padding": .number(Double(Metrics.panePadding)), "chrome_style": .string(DesignSettings.shared.chromeStyle.rawValue), "panes": .array(panes), "window_controls": .object([ "sidebar_hidden": .bool(controller.root.sidebarHidden), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextPalette/PaletteController+Pages.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextPalette/PaletteController+Pages.swift index b4f0f91e200e..b41d137165ee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextPalette/PaletteController+Pages.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextPalette/PaletteController+Pages.swift @@ -35,13 +35,26 @@ extension PaletteController { scope: .root ) page.mergesSectionsWhenTyping = true + return previewingActions(page) + } + + /// The highlighted row previews its action live when the action has a + /// preview (`ActionRegistry.bindPreview`: a "Window Style: Card" row + /// shows that style); another row, leaving the page or closing the + /// palette puts the committed look back, and running the row keeps it. + func previewingActions(_ page: PalettePageSpec) -> PalettePageSpec { + var page = page + let registry = registry + page.onHighlight = { [weak registry] item in registry?.showPreview(item?.actionRefs.first?.action) } + page.onLeave = { [weak registry] in registry?.endPreview() } return page } /// Scope `commands` (`>`): catalog actions only. func commandsOnlyPage() -> PalettePageSpec { - PalettePageSpec(id: "commandsOnly", title: PaletteStrings.commandsTitle, placeholder: PaletteStrings.searchPlaceholder, - symbol: "command", providers: [makeRegistryProvider()], showsRecent: true, scope: PaletteScopeID.commands) + previewingActions(PalettePageSpec(id: "commandsOnly", title: PaletteStrings.commandsTitle, placeholder: PaletteStrings.searchPlaceholder, + symbol: "command", providers: [makeRegistryProvider()], showsRecent: true, + scope: PaletteScopeID.commands)) } /// Scope `scopes` (`?`): every scope, with its prefix and keyword. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChromeStyleSetting.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChromeStyleSetting.swift index 9cf19e46373e..106949a54581 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChromeStyleSetting.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChromeStyleSetting.swift @@ -55,9 +55,11 @@ extension SettingsApplier { } extension ChromeStyleSetting { - /// Binds every `appearance.chromeStyle.` palette action. + /// Binds every `appearance.chromeStyle.` palette action, each + /// previewing its style while its palette row is highlighted. @MainActor public static func bindActions(into registry: ActionRegistry, design: DesignSettings, save: @escaping SettingChoiceActions.Save) { - SettingChoiceActions.bind(ChromeStyle.allCases, into: registry, id: actionID, path: configPath, fileValue: fileValue, save: save) { + SettingChoiceActions.bind(ChromeStyle.allCases, into: registry, id: actionID, path: configPath, fileValue: fileValue, save: save, + current: { design.chromeStyle }) { design.chromeStyle = $0 } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingChoiceActions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingChoiceActions.swift index 65d5568f4c39..1cd0b7827c2c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingChoiceActions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingChoiceActions.swift @@ -12,19 +12,31 @@ public nonisolated struct SettingChoiceActions: Sendable { public init() {} /// Binds one action per value: a refused save is reported and changes - /// nothing; otherwise `apply` sets the value at once. + /// nothing; otherwise `apply` sets the value at once. With `current`, + /// each action also previews its value (`ActionRegistry.bindPreview`): + /// the palette's highlighted row applies it without a write, and + /// leaving the row applies `current()` as it was before the preview. @MainActor public static func bind( _ values: [Value], into registry: ActionRegistry, id: (Value) -> String, path: [String], - fileValue: @escaping (Value) -> JSONValue?, save: @escaping Save, apply: @escaping @MainActor (Value) -> Void + fileValue: @escaping (Value) -> JSONValue?, save: @escaping Save, + current: (@MainActor () -> Value)? = nil, apply: @escaping @MainActor (Value) -> Void ) { for value in values { - registry.bind(ActionID(rawValue: id(value)), invoke: { [weak registry] _ in + let actionID = ActionID(rawValue: id(value)) + registry.bind(actionID, invoke: { [weak registry] _ in if let refusal = save(path, fileValue(value)) { + registry?.endPreview() registry?.refuse(refusal) return } apply(value) }) + guard let current else { continue } + registry.bindPreview(actionID, ActionPreview(group: path.joined(separator: ".")) { + let before = current() + apply(value) + return { apply(before) } + }) } } } diff --git a/scripts/cmux-next/window-style-preview-live.py b/scripts/cmux-next/window-style-preview-live.py new file mode 100755 index 000000000000..58e9b416dc0e --- /dev/null +++ b/scripts/cmux-next/window-style-preview-live.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""GUI proof of the live Window Style preview in the command palette (Lawrence 2026-10-14: +"dont make me press enter to preview the style, just selecting it should show the style"). + +Launches the tagged app itself (no activation, a scratch cmux.json with no chromeStyle, an +isolated Chief home) on a GUI host, opens the palette (Cmd-Shift-P) and types "window style" +into it (every key is a `debug.key` into the palette panel; no pane gets typed text). Then: + + preview each Down moves the selection; when the selected row is a Window Style action, + the window shows that style at once (`debug.pane_chrome` chrome_style) and + cmux.json is byte-identical; a non-style row shows the committed style again + escape Escape until the palette closes: the committed style is back, cmux.json unchanged + commit reopen, select a non-default style, Return: cmux.json has that chromeStyle and the + window keeps it + +Each step writes the palette report, `debug.pane_chrome` and a `debug.window_snapshot` of the +main window to --out. The style table comes from schemas/settings/chrome-styles.json. + +Usage (nx-remote --needs gui --app-from-fleet cmux --tag T --ref SHA): + window-style-preview-live.py --app "$NX_APP" --tag "$NX_APP_TAG" --out "$NX_ARTIFACTS/style" +Stop the app afterwards with "$NX_APP_STOP". +""" +import argparse, json, os, socket, subprocess, sys, tempfile, time + +parser = argparse.ArgumentParser() +parser.add_argument("--app", required=True) +parser.add_argument("--tag", required=True) +parser.add_argument("--out", required=True) +opts = parser.parse_args() +os.makedirs(opts.out, exist_ok=True) +ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) +TABLE = json.load(open(os.path.join(ROOT, "schemas/settings/chrome-styles.json"))) +STYLE_OF_ACTION = {f"appearance.chromeStyle.{s['actionName']}": s["id"] for s in TABLE["styles"]} +DEFAULT = TABLE["default"] +SOCKET = f"/tmp/cmux-debug-{opts.tag}.sock" +SCRATCH = tempfile.mkdtemp(prefix=f"stylepreview-{opts.tag}-") +CONFIG = os.path.join(SCRATCH, "cmux.json") +GHOSTTY = os.path.join(SCRATCH, "ghostty") +INITIAL_CONFIG = json.dumps({"appearance": {"theme": "Catppuccin Latte"}}, indent=2) + "\n" +failures = [] +log = [] +step_no = [0] + + +def check(ok, what): + print(("ok " if ok else "FAIL ") + what, flush=True) + log.append({"ok": bool(ok), "what": what}) + if not ok: + failures.append(what) + + +def rpc(method, params=None, timeout=30): + try: + conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + conn.settimeout(timeout) + conn.connect(SOCKET) + conn.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = conn.recv(1 << 20) + if not chunk: + break + buf += chunk + conn.close() + reply = json.loads(buf) + except (OSError, ValueError) as error: + return {"error": str(error)} + return reply.get("result") if reply.get("ok") else {"error": reply.get("error")} + + +def wait(predicate, seconds, step=0.1): + end = time.time() + seconds + while time.time() < end: # test harness: bounded wait for the live app + try: + value = predicate() + except Exception: # the app may not answer yet + value = None + if value: + return value + time.sleep(step) + return None + + +def key(name, modifiers=None, target="palette"): + params = {"key": name, "modifiers": modifiers or []} + if target: + params["target"] = target + return rpc("debug.key", params) or {} + + +def palette_state(): + """The palette's report for a no-op key (forward delete at the end of the query).""" + return key("forwarddelete") + + +def settled(): + state = palette_state() + return state if state.get("palette_open") and not state.get("palette_ranking") and not state.get("palette_loading") else None + + +def chrome(): + windows = (rpc("debug.pane_chrome") or {}).get("windows") or [] + return windows[0] if windows else {} + + +def chrome_style(): + return chrome().get("chrome_style") + + +def config_text(): + return open(CONFIG).read() + + +def capture(label, state=None): + step_no[0] += 1 + name = f"{step_no[0]:02d}-{label}" + window = chrome() + record = {"palette": state, "chrome_style": window.get("chrome_style"), "config": config_text()} + with open(os.path.join(opts.out, f"{name}.json"), "w") as f: + json.dump(record, f, indent=1) + shot = rpc("debug.window_snapshot", {"window": window.get("id"), "path": os.path.join(opts.out, f"{name}.png")}) or {} + print(f"snapshot {name}: style={window.get('chrome_style')} {shot.get('path') or shot}", flush=True) + return record + + +def open_palette_with(query): + key("p", ["command", "shift"], target=None) + check(bool(wait(lambda: palette_state().get("palette_open"), 10)), "the palette opens") + for char in query: + key(char) + return wait(settled, 10) + + +def close_palette(): + for _ in range(6): + if not palette_state().get("palette_open"): + return True + key("escape") + return not palette_state().get("palette_open") + + +app = None +try: + with open(CONFIG, "w") as f: + f.write(INITIAL_CONFIG) + open(GHOSTTY, "w").write("") + if os.path.exists(SOCKET): + os.unlink(SOCKET) + env = {"HOME": os.environ["HOME"], "USER": os.environ.get("USER", ""), "TMPDIR": os.environ.get("TMPDIR", "/tmp"), + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "CMUX_NEXT_NO_ACTIVATE": "1", "CMUX_NEXT_SOCKET_MODE": "automation", + "CMUX_NEXT_CONFIG_FILE": CONFIG, "CMUX_NEXT_GHOSTTY_CONFIG": GHOSTTY, "CMUX_NEXT_CHIEF_ISOLATED": "1", + "CMUX_NEXT_CHIEF_HOME": os.path.join(SCRATCH, "chief"), "CMUX_NEXT_TEST_WINDOW_FRAME": "60,60,1100,760"} + app_log = open(os.path.join(opts.out, "app.log"), "a") + app = subprocess.Popen([os.path.join(opts.app, "Contents/MacOS/cmux DEV")], env=env, stdout=app_log, stderr=app_log, + stdin=subprocess.DEVNULL) + print("launched pid", app.pid, flush=True) + if not wait(lambda: os.path.exists(SOCKET) and chrome().get("chrome_style"), 180, 0.25): + sys.exit("the tagged app did not come up") + committed = chrome_style() + check(committed == DEFAULT, f"the committed style is the default ({committed})") + capture("committed") + + # Preview: walk the rows with Down; every style row shows its style, nothing is written. + state = open_palette_with("window style") + check(state is not None, "the palette lists rows for 'window style'") + seen_styles, left_styles = [], False + for move in range(len(STYLE_OF_ACTION) + 3): + state = wait(settled, 10) or palette_state() + selected = state.get("palette_selected") + expected = STYLE_OF_ACTION.get(selected, committed) + record = capture(f"move{move}-{STYLE_OF_ACTION.get(selected, 'other')}", state) + check(record["chrome_style"] == expected, + f"move {move}: selected {selected} shows style {expected} (shows {record['chrome_style']})") + check(record["config"] == INITIAL_CONFIG, f"move {move}: cmux.json is unchanged") + if selected in STYLE_OF_ACTION: + seen_styles.append(STYLE_OF_ACTION[selected]) + elif seen_styles: + left_styles = True + key("down") + check(len(set(seen_styles)) >= 2, f"the selection previewed at least two styles ({seen_styles})") + check(left_styles or len(set(seen_styles)) == len(STYLE_OF_ACTION), + f"every style was previewed or a non-style row was reached ({seen_styles})") + + # Escape: the committed style is back and nothing was written. + check(close_palette(), "Escape closes the palette") + record = capture("after-escape") + check(record["chrome_style"] == committed, f"after Escape the committed style is back ({record['chrome_style']})") + check(record["config"] == INITIAL_CONFIG, "after Escape cmux.json is unchanged") + + # Commit: select a non-default style and press Return. + state = open_palette_with("window style") + target = None + for _ in range(len(STYLE_OF_ACTION) + 3): + state = wait(settled, 10) or palette_state() + style = STYLE_OF_ACTION.get(state.get("palette_selected")) + if style and style != committed: + target = style + break + key("down") + check(target is not None, f"a non-default style row is selectable ({state.get('palette_selected')})") + capture(f"before-return-{target}", state) + check(chrome_style() == target, f"the selected row previews {target}") + key("return") + written = wait(lambda: (json.loads(config_text()).get("appearance") or {}).get("chromeStyle") == target, 10) + record = capture(f"after-return-{target}") + check(bool(written), f"Return writes appearance.chromeStyle = {target} to cmux.json ({record['config'].strip()})") + check(not palette_state().get("palette_open"), "Return closes the palette") + check(wait(lambda: chrome_style() == target, 5) is not None, f"the window keeps {target} after the write") +finally: + if app is not None: + rpc("action.run", {"action": "quitEndSessions", "args": {}, "focus": False, "origin": "script"}) + with open(os.path.join(opts.out, "summary.json"), "w") as f: + json.dump({"checks": log, "failures": failures}, f, indent=1) + print(f"{len(failures)} failure(s)", flush=True) +sys.exit(1 if failures else 0) From 00fcdbe4ef74e3f3f3752d5cbd0facc84fde90be Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:04:10 -0700 Subject: [PATCH 079/183] chief.profile.adopt review fixes: owner gate first, any page origin refused, brain directory collisions refused, tests for the event and the limit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cmux-tui-core/src/chief_registry.rs | 25 +++++++++++ .../src/server/chief_registry_resource.rs | 17 ++++---- .../cmux-tui-core/src/server/origin_gate.rs | 6 +++ .../crates/cmux-tui/tests/cli/chief_adopt.rs | 42 +++++++++++++++++++ cmux-tui/spec/resource-api-v2.md | 4 +- cmux-tui/spec/resource-operations-v2.json | 2 +- 6 files changed, 87 insertions(+), 9 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs index 19ef910b4095..3a6dd697468a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs @@ -98,6 +98,11 @@ pub(crate) enum Refusal { /// `chief.profile.adopt`: no agent participant of a local conversation /// has this id (or it is a person). NotAgentParticipant, + /// `chief.profile.adopt`: another Chief (live or deleted) has the same + /// brain directory (`chief_brains::brain_home`: the id's last 8 + /// characters, lowercased), so adopting it would share or revive that + /// brain and its memory. + BrainHomeTaken, } impl Refusal { @@ -113,6 +118,7 @@ impl Refusal { Self::DeletedChief => "deleted_chief", Self::DefaultChief => "default_chief", Self::NotAgentParticipant => "not_an_agent_participant", + Self::BrainHomeTaken => "brain_home_taken", } } } @@ -511,6 +517,25 @@ pub(crate) fn adopt( Some(None) => return Ok(Err(Refusal::DeletedChief)), None => {} } + // The brain directory is the id's last 8 characters (chief_brains.rs): + // a ULID's random bits for a created Chief, but any text for an adopted + // one. No two records, live or deleted, may share it. + let tail = |id: &str| id.get(id.len().saturating_sub(8)..).unwrap_or(id).to_ascii_lowercase(); + let mine = tail(id); + let taken = { + let mut statement = tx.prepare("SELECT id FROM chief")?; + let ids = statement.query_map([], |r| r.get::<_, String>(0))?; + let mut taken = false; + for other in ids { + if tail(&other?) == mine { + taken = true; + } + } + taken + }; + if taken { + return Ok(Err(Refusal::BrainHomeTaken)); + } let live: i64 = tx.query_row("SELECT COUNT(*) FROM chief WHERE record IS NOT NULL", [], |r| r.get(0))?; if usize::try_from(live).unwrap_or(usize::MAX) >= MAX_CHIEFS { diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs index 3da8faa5064a..bac4d1c84e90 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs @@ -216,6 +216,16 @@ fn mutate( key: String, adoptee: Option<&Adoptee>, ) -> Result { + // Owner only, from a terminal, before anything else is checked: no page + // (a WebSocket or the host's page relay, with or without a page id), no + // Chief, no paired or remote device (caller() refuses those). + if operation == Op::ChiefProfileAdopt + && (*caller != Caller::Owner + || super::origin_gate::connection_page(mux, client).is_some() + || super::origin_gate::is_page(mux, client)) + { + return Err(forbidden_adopt()); + } if key.is_empty() { return Err(ResourceError::validation_invalid( Some("idempotency_key"), @@ -257,13 +267,6 @@ fn mutate( .map(|avatar| avatar.sha256), _ => None, }; - if operation == Op::ChiefProfileAdopt - && (*caller != Caller::Owner || super::origin_gate::connection_page(mux, client).is_some()) - { - // Owner only, from a terminal: no page (a WebSocket or the host's page - // relay), no Chief, no paired or remote device (caller() refuses those). - return Err(forbidden_adopt()); - } if let Caller::Chief(me) = caller { self_edit_allowed(store, operation, me, &chief, &profile)?; } diff --git a/cmux-tui/crates/cmux-tui-core/src/server/origin_gate.rs b/cmux-tui/crates/cmux-tui-core/src/server/origin_gate.rs index 16c0edb27ad1..fc04ffe05a30 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/origin_gate.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/origin_gate.rs @@ -141,6 +141,12 @@ pub(super) fn is_user(mux: &Mux, client: u64) -> bool { derived_origin(mux, client) == RequestOrigin::User } +/// Whether `client` derives the page origin (a page WebSocket or the host's +/// page relay, with or without a page id). +pub(super) fn is_page(mux: &Mux, client: u64) -> bool { + derived_origin(mux, client) == RequestOrigin::Page +} + /// The origin `client` derives; a client with no record is an agent. fn derived_origin(mux: &Mux, client: u64) -> RequestOrigin { let state = mux.control_clients.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs index 6def1412e52d..63f4c2381c9e 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs @@ -104,6 +104,13 @@ fn the_person_adopts_an_agent_and_it_keeps_its_dm_history_and_credential() { say(&mut home, &dm, "q1", "Is the train ready?"); say(&mut home, &dm, "q2", "The 0.66 notes too, please."); let before = texts(&mut home, &dm); + // A subscriber that asked for Chief changes hears the adopt as a create. + let mut watcher = Conn::open(&server.socket); + watcher.request( + "set-client-info", + json!({"name": "watcher", "capabilities": ["chief-events-v1"]}), + ); + watcher.request("subscribe", json!({})); // The CLI adopts it: its id and name, a new Chief's defaults, the role given. let output = @@ -118,6 +125,15 @@ fn the_person_adopts_an_agent_and_it_keeps_its_dm_history_and_credential() { assert_eq!(record["instructions"], ""); assert_eq!(record["is_default"], false); + let change = loop { + let event = watcher.event(); + if event["event"] == "chief-changed" { + break event; + } + }; + assert_eq!(change["chief"], "agent_release", "{change}"); + assert_eq!(change["change"], "created", "{change}"); + // The DM and its history are the same, and the agent's credential still posts there. assert_eq!(texts(&mut home, &dm), before); say(&mut agent, &dm, "a2", "Adopted and still here."); @@ -168,6 +184,9 @@ fn the_person_adopts_an_agent_and_it_keeps_its_dm_history_and_credential() { assert_eq!(refusal(&mut home, "user_local", "r-person"), "not_an_agent_participant"); assert_eq!(refusal(&mut home, "agent_nobody", "r-unknown"), "not_an_agent_participant"); assert_eq!(refusal(&mut home, "agent_release", "r-live"), "already_chief"); + // Another agent whose brain directory (the last 8 characters) is agent_release's. + let _ = dm_with(&mut home, "agent_x_release", "Twin"); + assert_eq!(refusal(&mut home, "agent_x_release", "r-twin"), "brain_home_taken"); // A deleted Chief that was in a conversation: its id is never revived. let made = @@ -198,6 +217,29 @@ fn the_person_adopts_an_agent_and_it_keeps_its_dm_history_and_credential() { let _ = dm_design; } +#[test] +fn an_adopt_counts_toward_the_chief_limit() { + let server = HeadlessServer::start("chief-adopt-limit"); + let mut home = Conn::open(&server.socket); + let _ = dm_with(&mut home, "agent_release", "Release Chief"); + // The default Chief and 63 more: 64 live Chiefs, the limit. + for n in 0..63 { + ok(&v2( + &mut home, + "chief.profile.create", + json!({"display_name": format!("Chief {n}")}), + Some(&format!("fill-{n}")), + )); + } + let refused = v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_release"}), + Some("adopt-full"), + ); + assert_eq!(reason(&refused), "too_many_chiefs"); +} + const BRAIN_STUB: &str = r#"#!/bin/sh n=$(cat "$MUX_HOME/starts" 2>/dev/null || echo 0) n=$((n+1)) diff --git a/cmux-tui/spec/resource-api-v2.md b/cmux-tui/spec/resource-api-v2.md index 26936d0cd657..df7533d6eb57 100644 --- a/cmux-tui/spec/resource-api-v2.md +++ b/cmux-tui/spec/resource-api-v2.md @@ -1231,7 +1231,9 @@ the host's page relay), a paired or remote device and a bound agent get participant it takes its id and, without `display_name`, its display name. `agent_mux` is `default_chief`, a live record `already_chief`, a deleted Chief `deleted_chief` (an adopt never revives a deleted Chief or its kept -memory), and a person or an unknown id `not_an_agent_participant` +memory), an id whose brain directory (its last 8 characters, lowercased) +another live or deleted Chief already has `brain_home_taken`, and a person +or an unknown id `not_an_agent_participant` (`operation.failed` `details.reason`). It is idempotent per key like `chief.profile.create`, reaches the brain supervisor as a create does and publishes `chief-changed` with change `created`. diff --git a/cmux-tui/spec/resource-operations-v2.json b/cmux-tui/spec/resource-operations-v2.json index 8ab557f83a5d..9fb579576957 100644 --- a/cmux-tui/spec/resource-operations-v2.json +++ b/cmux-tui/spec/resource-operations-v2.json @@ -15320,7 +15320,7 @@ ], "constraints": [ "Owner only: a trusted local Unix caller acting as the person (a registered Unix client with no link peer record whose conversation principal is user_local). A page WebSocket, a paired or remote device and a bound agent (a Chief) are refused with origin.forbidden; no page route allows it.", - "The participant must be an agent participant (kind agent) of a local conversation that is not a Chief record: a live record is already_chief, a deleted Chief (its tombstone, and its memory while it is kept) is deleted_chief, agent_mux is default_chief, anything else is not_an_agent_participant (operation.failed details.reason). At most 64 live Chiefs: too_many_chiefs.", + "The participant must be an agent participant (kind agent) of a local conversation that is not a Chief record: a live record is already_chief, a deleted Chief (its tombstone, and its memory while it is kept) is deleted_chief, agent_mux is default_chief, an id whose brain directory (its last 8 characters, lowercased) another Chief, live or deleted, already has is brain_home_taken, anything else is not_an_agent_participant (operation.failed details.reason). At most 64 live Chiefs: too_many_chiefs.", "The record gets create's defaults (tools spawn, tell, workspaces; self_edit avatar; no engine) and only the fields given; from the participant it takes only its id and, without display_name, its display name. Its conversations, history and credentials stay as they are.", "The idempotency key is per caller, as for chief.profile.create: the same key with the same params answers the committed record with replayed true; other params under the key are idempotency.conflict. The committed write reaches the Chief brain supervisor and publishes chief-profile-changed as a create does." ] From 67ab9281307a2b73714c2ccc13d2f6ee6322f239 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:04:40 -0700 Subject: [PATCH 080/183] fix(cmux-next): review fixes for the Chief profile relay: a call the deadline or the page cancelled sends nothing more, a relay is used only for the owner it reached, 16 s deadline; proof checks a call during the restart (cx-ebm.59.5) --- .../ChiefProfilePageRelay.swift | 38 +++++++++++++------ .../CmuxNextHomeEngine/PageCallDeadline.swift | 34 ++++++++++------- scripts/cmux-next/chief-create-click-proof.py | 15 ++++++-- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift index 345bca608456..b83655f6f0b9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift @@ -42,9 +42,10 @@ public final class ChiefProfilePageRelay { private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "chief-profile-relay") private let clock: any Clock /// Every page call answers within this, also when a step below it never does: the page's - /// sheet then clears busy and shows the error (`cmux.protocol.timeout`). It covers a connect - /// (two 2 s handshake lines), a confirmation (2 s) and the call (2 s), and one reconnect. - nonisolated static let callDeadline: Duration = .seconds(10) + /// sheet then clears busy and shows the error (`cmux.protocol.timeout`). Each step has the + /// 2 s request deadline: a connect (identify, hello, subscribe: 6 s), a confirmation and the + /// call (4 s), so 10 s per attempt and 16 s leave room for the one reconnect's connect. + nonisolated static let callDeadline: Duration = .seconds(16) /// The owner process a relay connection serves: a restarted owner (same socket, new process) /// is another owner, and its page calls go on a connection to it. @@ -87,11 +88,8 @@ public final class ChiefProfilePageRelay { gestureEvent: TimeInterval?) async throws -> JSONValue { guard Self.operations.contains(operation) else { throw DaemonError.command(cmd: operation, message: "not a Chief profile operation", code: "unknown_op") } // One gesture backs one call; a reconnect inside this call may use it again. - var gesture = false - if Self.needsGesture(operation, params: params), let event = gestureEvent, event != spentGesture { - spentGesture = event - gesture = true - } + let gesture = Self.needsGesture(operation, params: params) && gestureEvent != nil && gestureEvent != spentGesture + if gesture { spentGesture = gestureEvent } let started = ContinuousClock.now let elapsed = { Int((ContinuousClock.now - started) / .milliseconds(1)) } logger.info("\(operation, privacy: .public): start, gesture \(gesture)") @@ -120,18 +118,24 @@ public final class ChiefProfilePageRelay { attempt += 1 guard let owner, let main = owner.connection, let socket = await owner.socketPath(), let identity = owner.identity else { throw DaemonError.notConnected } - let relay = try await relayConnection(OwnerKey(socket: socket, pid: identity.pid, generation: identity.generation)) + var used: DaemonConnection? do { + let relay = try await relayConnection(OwnerKey(socket: socket, pid: identity.pid, generation: identity.generation)) + used = relay.connection + // A call the deadline already answered sends nothing more: a late create after the + // page showed the timeout would make a Chief the person was told failed. + try Task.checkCancellation() var origin = JSONValue.object(["claim": .string("page")]) if gesture { let token = try await confirmationToken(main: main, relay: relay, operation: operation, params: params) origin = .object(["claim": .string("user"), "confirmation": .string(token)]) } + try Task.checkCancellation() return try await ResourceRelayClient(connection: relay.connection).send( operation: operation, params: params, idempotencyKey: idempotencyKey, origin: origin) } catch let error as DaemonError where attempt == 1 && Self.isConnectionLost(error) { logger.info("\(operation, privacy: .public): relay connection lost (\(error.description, privacy: .public)); again on a new one") - if self.relay?.connection === relay.connection { dropRelay() } + if let used, self.relay?.connection === used { dropRelay() } } } } @@ -168,12 +172,24 @@ public final class ChiefProfilePageRelay { // task-owner: one connect; ends with its handshake (each line has the request deadline) let task = Task { () async throws -> Relay in let connection = DaemonConnection(endpoint: DaemonEndpoint(socketPath: owner.socket), configuration: configuration) - try await connection.start() + let reached = try await connection.start() + // The socket may already serve another owner process than the one the app's main + // connection is attached to (it restarted in between): that is not this owner. + guard reached.pid == owner.pid, reached.generation == owner.generation else { + await connection.close() + throw DaemonError.notConnected + } return Relay(connection: connection, owner: owner, identity: identity) } starting = (task, owner) defer { if starting?.owner == owner { starting = nil } } let made = try await task.value + // A connect to an older owner that finished after a newer one started is not used: the + // call goes once more, to the owner the main connection is attached to now. + guard starting?.owner == owner else { + await made.connection.close() + throw DaemonError.notConnected + } let old = relay relay = made if let old, old.connection !== made.connection { await old.connection.close() } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift index 48588766d131..00d7927366fb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift @@ -3,8 +3,8 @@ import Foundation /// A page call that answers by a deadline, also when its work never ends: the first of the work's /// end and the deadline answers (once). The deadline is a ``DemandTimer`` on the injected clock, so -/// a test moves time without waiting. The work's task is cancelled when the deadline answers; a -/// late result is dropped. +/// a test moves time without waiting. The work's task is cancelled when the deadline or the +/// caller's cancel answers; a late result is dropped. @MainActor enum PageCallDeadline { static func answer(within deadline: Duration, clock: any Clock, @@ -13,18 +13,26 @@ enum PageCallDeadline { let once = Once() let timer = DemandTimer(owner: "PageCallDeadline", clock: clock) defer { timer.cancel() } - return try await withCheckedThrowingContinuation { continuation in - once.continuation = continuation - // task-owner: the call's work; cancelled when the deadline answers first - once.task = Task { @MainActor in - let result: Result - do { result = .success(try await work()) } catch { result = .failure(error) } - once.finish(result) - } - timer.schedule(after: deadline) { @MainActor in - guard once.isWaiting else { return } - once.finish(.failure(onTimeout())) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + // Cancelled before it started: the cancel handler found nothing to answer. + guard !Task.isCancelled else { return continuation.resume(throwing: CancellationError()) } + once.continuation = continuation + // task-owner: the call's work; cancelled when the deadline or the caller's cancel answers first + once.task = Task { @MainActor in + let result: Result + do { result = .success(try await work()) } catch { result = .failure(error) } + once.finish(result) + } + timer.schedule(after: deadline) { @MainActor in + guard once.isWaiting else { return } + once.finish(.failure(onTimeout())) + } } + } onCancel: { + // The page cancelled the call (navigation, tab close): its work stops sending too. + // task-owner: one hop onto the main actor to answer and cancel the work + Task { @MainActor in once.finish(.failure(CancellationError())) } } } diff --git a/scripts/cmux-next/chief-create-click-proof.py b/scripts/cmux-next/chief-create-click-proof.py index 42acd32fa881..aa73c86bb32d 100644 --- a/scripts/cmux-next/chief-create-click-proof.py +++ b/scripts/cmux-next/chief-create-click-proof.py @@ -10,7 +10,8 @@ 2. Page Chief create WITHOUT a gesture: refused (gesture_required). 3. New Chief through the page with a REAL pointer click on Create Chief: the Chief is created on the owner (chief list), the page shows no "needs a verified cmux app connection". -4. The owner restarts while the page is open: a page relay call answers within the relay deadline, and a second real +4. The owner restarts while the page is open: page relay calls during and after the restart answer within the relay + deadline (16 s, checked at 18 s), and a second real click on Create Chief makes a Chief on the new owner (no page call left in flight). Ends with quitEndSessions, endAgents, acpmux daemon shutdown + router shutdown, chief shutdown. Reads NX_APP, NX_APP_TAG, NX_APP_STOP, NX_APP_CLI, NX_CAPTURE, NX_ARTIFACTS.""" @@ -424,21 +425,29 @@ def owner(): {"click": R["create_click"], "list": (R.get("chief_list") or [{}])[-1], "page_tail": json.dumps(after_page)[-600:]}) # 4. The Chief owner restarts while the page is open (the app's main connection ensures a new owner process on - # the same socket). A page relay call then answers within the relay's deadline (10 s) plus margin, and a real + # the same socket). A page relay call then answers within the relay's deadline (16 s) plus margin, and a real # click on Create Chief makes a Chief on the NEW owner: its gesture token is bound to a relay connection on the # new owner, so a relay still on the old owner (or a token bound to another connection) cannot pass. before_restart = owner_status() + check("owner_running_before_restart", bool(before_restart.get("pid")), {"status": before_restart}) R["owner_stop"] = subprocess.run([CLI, "--session", owner_session(CHIEF), "--json", "server", "stop", "--force"], capture_output=True, text=True, timeout=60, env={**env_base(), **OWNER_ENV}).returncode def new_owner(): o = (res(rpc("debug.home", {}, 30)) or {}).get("chief_owner") or {} return o if o.get("connected") and o.get("daemon_pid") and o.get("daemon_pid") != before_restart.get("pid") else None + # A relay call while the owner is down or coming back: an answer (ok or a typed error) within the deadline. + t0 = time.time() + during = rpc("debug.page", {"page": "cmux.home-channels", "action": "call", "op": "cmux.chief.profile.list", + "params": {"machine": "current", "session": "current"}}, 30) + during_ms = int((time.time() - t0) * 1000) + check("relay_call_answers_while_owner_restarts", during_ms <= 18000 and (res(during) or {}).get("t") in ("ok", "err"), + {"ms": during_ms, "reply": json.dumps(during)[:600]}) restarted = until(new_owner, 120) t0 = time.time() relay_after = rpc("debug.page", {"page": "cmux.home-channels", "action": "call", "op": "cmux.chief.profile.list", "params": {"machine": "current", "session": "current"}}, 30) relay_ms = int((time.time() - t0) * 1000) - check("relay_call_answers_after_owner_restart", bool(restarted) and relay_ms <= 12000 + check("relay_call_answers_after_owner_restart", bool(restarted) and bool(before_restart.get("pid")) and relay_ms <= 18000 and (res(relay_after) or {}).get("t") == "ok", {"old_pid": before_restart.get("pid"), "owner": restarted, "ms": relay_ms, "reply": json.dumps(relay_after)[:600]}) name2 = "Second Chief %s" % MARK[-5:] From 6294505f36504b1eb6ec54b292695e236fc6cfbc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:04:55 -0700 Subject: [PATCH 081/183] cmux-next motion: drop ReduceMotionListFadeTests (Reduce Motion takes structural changes in one step, DESIGN P6) The stack removes the tab strip and sidebar row crossfades that stood in for movement under Reduce Motion (motion.md rule 7: a structural change is not replaced by a crossfade). These three tests (f23e54377dd7, cx-2grmt) asserted that crossfade. Reason (hq-1d decision, 2026-10-14): Lawrence asked to match Chrome's tab strip exactly, and Chrome uses 0 ms for strip bounds under Reduce Motion (hq-4e's Chrome tab motion spec, item 6); DESIGN P6 already replaces cx-2grmt's crossfade; and Lawrence's rule is no unit tests (a correct change deletes the unit test it breaks). Expert objection noted: Apple's HIG suggests a dissolve instead of motion under Reduce Motion; we follow Chrome because Lawrence asked for that exact match. hq-1d tells Leo. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ReduceMotionListFadeTests.swift | 81 ------------------- 1 file changed, 81 deletions(-) delete mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ReduceMotionListFadeTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ReduceMotionListFadeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ReduceMotionListFadeTests.swift deleted file mode 100644 index 8ec837c9fd9b..000000000000 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ReduceMotionListFadeTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import AppKit -@testable import CmuxNextDesign -@testable import CmuxNextSidebar -@testable import CmuxNextTabs -import Testing - -/// POLISH audit, batch 2 (cx-2grmt): under Reduce Motion a closed workspace's sidebar row and a -/// closed tab vanished in one frame, and new ones popped in: their fade ran inside the movement -/// spring, which Reduce Motion skips. The rows and tabs take their places at once and the change -/// crossfades (motion.md rule 7). -@MainActor @Suite(.serialized) struct ReduceMotionListFadeTests { - /// The crossfades running on `layer`. - static func crossfades(_ layer: CALayer?) -> [CATransition] { - (layer?.animationKeys() ?? []).compactMap { layer?.animation(forKey: $0) as? CATransition } - } - - @Test func aClosedWorkspaceRowCrossfadesUnderReduceMotion() throws { - // Synchronous from here to the reset: the override is process-wide. - Motion.reduceMotionOverride = true - defer { Motion.reduceMotionOverride = nil } - let model = SidebarMock.makeModel() - let list = SidebarListView(model: model) - let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 240, height: 900), styleMask: [.borderless], - backing: .buffered, defer: false) - defer { window.orderOut(nil) } - window.contentView = list - list.frame = NSRect(x: 0, y: 0, width: 240, height: 900) - list.reload(animated: false) - let section = try #require(model.sections.firstIndex { $0.id == .machine(.local) }) - let node = try #require(model.sections[section].nodes.firstIndex { - if case let .workspace(ws) = $0 { ws.id != model.activeWorkspaceID } else { false } - }) - guard case let .workspace(closed) = model.sections[section].nodes[node] else { return } - #expect(list.rowViews[.workspace(closed.id)] != nil) - - model.sections[section].nodes.remove(at: node) - list.reload(animated: true) - #expect(list.rowViews[.workspace(closed.id)] == nil) - #expect(!Self.crossfades(list.layer).isEmpty, "the closed row vanished in one frame") - } - - @Test func aClosedTabCrossfadesUnderReduceMotion() throws { - Motion.reduceMotionOverride = true - defer { Motion.reduceMotionOverride = nil } - let tabs = (0..<3).map { TabItem(id: TabID("tab\($0)"), title: "Tab \($0)") } - let model = TabStripModel(tabs: tabs, selectedID: TabID("tab0")) - let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 600, height: 40), styleMask: [.borderless], - backing: .buffered, defer: false) - defer { window.orderOut(nil) } - let strip = TabStripView(model: model) - strip.frame = NSRect(x: 0, y: 0, width: 600, height: TabStripView.preferredHeight) - window.contentView = strip - strip.layoutSubtreeIfNeeded() - - model.tabs.removeLast() - strip.sync(fromModel: true) - #expect(strip.cells[TabID("tab2")] == nil) - #expect(!Self.crossfades(strip.tabsClip.layer).isEmpty, "the closed tab vanished in one frame") - } - - /// Review of #19938: a tab synced without the strip's springs (`animating: false`, at full - /// width at once) crossfades in under Reduce Motion too. - @Test func aNewTabFromCmdTCrossfadesUnderReduceMotion() throws { - Motion.reduceMotionOverride = true - defer { Motion.reduceMotionOverride = nil } - let model = TabStripModel(tabs: [TabItem(id: TabID("tab0"), title: "Tab 0")], selectedID: TabID("tab0")) - let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 600, height: 40), styleMask: [.borderless], - backing: .buffered, defer: false) - defer { window.orderOut(nil) } - let strip = TabStripView(model: model) - strip.frame = NSRect(x: 0, y: 0, width: 600, height: TabStripView.preferredHeight) - window.contentView = strip - strip.layoutSubtreeIfNeeded() - - model.tabs.append(TabItem(id: TabID("tab1"), title: "New Tab")) - model.selectedID = TabID("tab1") - strip.sync(fromModel: true, animating: false) - #expect(strip.cells[TabID("tab1")] != nil) - #expect(!Self.crossfades(strip.tabsClip.layer).isEmpty, "Cmd-T's tab popped in") - } -} From 69ce520564636ea68848d1749f465151ac0e74f9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:05:34 -0700 Subject: [PATCH 082/183] chief brains: rustfmt the account home (cx-ebm.60.6) --- cmux-tui/crates/cmux-tui-core/src/chief_brains.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs b/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs index 1c7ab70e115c..cf83df88a71d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs +++ b/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs @@ -171,9 +171,7 @@ pub fn brain_environment( // inherited: an app a job runner started (nx-remote on mac26: HOME is // the job root) passed that on, and the brain then found no Claude // settings env (the person's API route) in it (hq-6d P1 2026-10-14). - let user_home = account_home() - .or_else(|| inherited("HOME")) - .unwrap_or_else(|| "/".into()); + let user_home = account_home().or_else(|| inherited("HOME")).unwrap_or_else(|| "/".into()); let mut env = BTreeMap::new(); let mut put = |key: &str, value: String| { env.insert(key.to_owned(), value); From 8e7ff1f6f7b7c0fc372cfbb79cb7f92d6fd566c7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:21:58 -0700 Subject: [PATCH 083/183] chief.profile.adopt: refuse a bound participant, revoke its earlier credentials in the same write, and only the brain (or, with no supervisor, the person's terminal) mints the Chief's credential The adopt claims the participant inside the registry write: under the bindings lock it refuses a participant with a bound connection (participant_bound, "stop its current agent session first") and otherwise revokes every credential issued for it. conversation-bind now checks the credential and binds under the same lock, so a revoked credential never binds after the adopt. For a Chief record other than agent_mux, conversation-agent-token is refused while a brain supervisor runs (the supervisor mints the brain's credential in process); with no supervisor only the person's own terminal mints it, never a page, a paired or remote device or an agent. Tests: a bound participant is refused; after it unbinds the adopt succeeds and the brain starts; the old credential is refused; the brain's credential binds; the person mints the post-adopt credential on a daemon with no supervisor; a page, a device and an agent cannot mint it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cmux-tui-core/src/chief_registry.rs | 11 +++ .../cmux-tui-core/src/conversation_store.rs | 8 ++ .../cmux-tui-core/src/mux/conversations.rs | 32 +++++++ .../src/server/chief_adopt_tests.rs | 82 +++++++++++++++-- .../src/server/chief_registry_resource.rs | 23 ++++- .../cmux-tui-core/src/server/conversations.rs | 24 ++++- .../crates/cmux-tui/tests/cli/chief_adopt.rs | 91 +++++++++++++++++-- cmux-tui/spec/commands.md | 9 ++ cmux-tui/spec/resource-api-v2.md | 8 +- cmux-tui/spec/resource-operations-v2.json | 2 +- 10 files changed, 272 insertions(+), 18 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs index 3a6dd697468a..547d13d38240 100644 --- a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs @@ -103,6 +103,9 @@ pub(crate) enum Refusal { /// characters, lowercased), so adopting it would share or revive that /// brain and its memory. BrainHomeTaken, + /// `chief.profile.adopt`: a connection is bound to the participant (its + /// current agent session); two processes never speak as one participant. + ParticipantBound, } impl Refusal { @@ -119,6 +122,7 @@ impl Refusal { Self::DefaultChief => "default_chief", Self::NotAgentParticipant => "not_an_agent_participant", Self::BrainHomeTaken => "brain_home_taken", + Self::ParticipantBound => "participant_bound", } } } @@ -506,6 +510,7 @@ pub(crate) fn adopt( id: &str, display_name: &str, fields: &Map, + claim: impl FnOnce() -> anyhow::Result, ) -> anyhow::Result> { if id == DEFAULT_CHIEF { return Ok(Err(Refusal::DefaultChief)); @@ -541,6 +546,12 @@ pub(crate) fn adopt( if usize::try_from(live).unwrap_or(usize::MAX) >= MAX_CHIEFS { return Ok(Err(Refusal::TooMany)); } + // Last, inside this write: the participant must have no bound connection, + // and every credential issued for it is revoked (`claim`), so the old + // agent session never speaks as the Chief. + if !claim()? { + return Ok(Err(Refusal::ParticipantBound)); + } let mut fields = fields.clone(); fields.entry("display_name").or_insert_with(|| Value::String(display_name.to_owned())); insert_new(tx, id, now_ms(), &fields).map(Ok) diff --git a/cmux-tui/crates/cmux-tui-core/src/conversation_store.rs b/cmux-tui/crates/cmux-tui-core/src/conversation_store.rs index 4c059f85b340..f108c270ed18 100644 --- a/cmux-tui/crates/cmux-tui-core/src/conversation_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/conversation_store.rs @@ -273,6 +273,14 @@ impl ConversationStore { Ok(token) } + /// Removes `participant`'s credential (`chief.profile.adopt`): no token + /// issued for it before binds again; the next credential is minted anew. + pub(crate) fn revoke_agent_token(&mut self, participant: &str) -> anyhow::Result<()> { + self.connection + .execute("DELETE FROM agent_token WHERE participant = ?1", params![participant])?; + Ok(()) + } + /// Whether `token` is the current credential of `participant`. pub(crate) fn verify_agent_token( &mut self, diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/conversations.rs b/cmux-tui/crates/cmux-tui-core/src/mux/conversations.rs index dbe67edac13d..66064e2a6099 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/conversations.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/conversations.rs @@ -99,6 +99,38 @@ impl Mux { Ok(()) } + /// Binds `client` to `participant` when `check` (the credential check) + /// passes, holding the bindings lock across both, so a credential revoked + /// by [`Mux::claim_unbound_participant`] never binds after it. Lock order: + /// bindings, then the conversation store. + pub(crate) fn bind_conversation_principal_if( + &self, + client: u64, + participant: String, + check: impl FnOnce() -> anyhow::Result, + ) -> anyhow::Result { + let mut bindings = lock_checked(&self.conversations.bindings, RelayLock::Bindings)?; + if !check()? { + return Ok(false); + } + bindings.insert(client, participant); + Ok(true) + } + + /// For `chief.profile.adopt`: when no connection is bound to + /// `participant`, revokes every credential issued for it and answers + /// true; a bound participant answers false and nothing changes. Under the + /// bindings lock, so no bind lands between the check and the revoke. + pub(crate) fn claim_unbound_participant(&self, participant: &str) -> anyhow::Result { + let bindings = lock_checked(&self.conversations.bindings, RelayLock::Bindings)?; + if bindings.values().any(|bound| bound == participant) { + return Ok(false); + } + self.with_conversations(|store| store.revoke_agent_token(participant))?; + drop(bindings); + Ok(true) + } + /// The bindings lock (tests poison it). #[cfg(test)] pub(crate) fn conversation_bindings(&self) -> &Mutex> { diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs index d641de17ee18..94aee89a8b4f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs @@ -172,16 +172,86 @@ fn only_the_owners_own_unix_connection_adopts_an_agent() { // Nothing was written by any of them. assert_eq!(chief_ids(&mux, &owner), vec!["agent_mux".to_owned()]); + // The agent's session is still bound: two processes never speak as one + // participant, so the adopt waits until that session is gone. + let bound = send(&mux, &owner, &v2("chief.profile.adopt", &adopt, Some("o0"), None)); + assert_eq!(bound["error"]["code"], "operation.failed", "{bound}"); + assert_eq!(bound["error"]["details"]["reason"], "participant_bound", "{bound}"); + assert_eq!(chief_ids(&mux, &owner), vec!["agent_mux".to_owned()]); + mux.unbind_conversation_participant("agent_release"); + // The owner's own terminal adopts it. let adopted = send(&mux, &owner, &v2("chief.profile.adopt", &adopt, Some("o1"), None)); assert_eq!(adopted["ok"], true, "{adopted}"); assert_eq!(adopted["result"]["value"]["id"], "agent_release"); assert_eq!(chief_ids(&mux, &owner), vec!["agent_mux".to_owned(), "agent_release".to_owned()]); - // Now a live Chief, bound on its own connection: still refused. - let mut again = adopt.clone(); - again["participant_id"] = json!("agent_release"); - assert_eq!( - forbidden(&send(&mux, &agent, &v2("chief.profile.adopt", &again, Some("a2"), None))), - "chief_adopt_owner_only" + + // The credential issued before the adopt is revoked: the old session cannot come back. + let old = connect(&mux, ClientTransport::Unix); + let command_result = |conn: &Conn, request: Value| { + let command: Command = serde_json::from_value(request).unwrap(); + handle_command(&mux, conn.client, command, &conn.writer) + }; + assert!( + command_result( + &old, + json!({"cmd": "conversation-bind", "participant": "agent_release", "token": token}) + ) + .is_err(), + "a pre-adopt credential bound" + ); + + // With no brain supervisor, only the person's own terminal mints the + // adopted Chief's new credential: never a page relay, a WebSocket page, a + // paired or remote device, or an agent. + let mint = json!({"cmd": "conversation-agent-token", "participant": "agent_release"}); + let relay_mint = connect(&mux, ClientTransport::Unix); + set_role_for_test(&mux, relay_mint.client, "page_relay"); + set_page(&mux, relay_mint.client, "home-channels".to_owned()); + let page_mint = connect(&mux, ClientTransport::WebSocket); + set_scoped_page(&mux, page_mint.client, "home-channels".to_owned()); + let device_mint = connect(&mux, ClientTransport::Remote); + let paired_mint = connect(&mux, ClientTransport::WebSocket); + mux.control_clients.mark_person(paired_mint.client); + let other_token = command( + &mux, + &owner, + json!({"cmd": "conversation-create", "idempotency_key": "dm-helper", "actor": "user_local", + "title": "Helper", "participants": [ + {"id": "user_local", "kind": "human", "display_name": "Me"}, + {"id": "agent_helper", "kind": "agent", "display_name": "Helper", + "agent_class": "mux"}]}), + ); + assert!(other_token["conversation"]["id"].is_string()); + let helper_token = command( + &mux, + &owner, + json!({"cmd": "conversation-agent-token", "participant": "agent_helper"}), + )["token"] + .as_str() + .unwrap() + .to_owned(); + let agent_mint = connect(&mux, ClientTransport::Unix); + command( + &mux, + &agent_mint, + json!({"cmd": "conversation-bind", "participant": "agent_helper", "token": helper_token}), + ); + for (who, conn) in [ + ("page relay", &relay_mint), + ("page websocket", &page_mint), + ("remote device", &device_mint), + ("paired device", &paired_mint), + ("agent", &agent_mint), + ] { + assert!(command_result(conn, mint.clone()).is_err(), "{who} minted a Chief credential"); + } + let fresh = command(&mux, &owner, mint.clone())["token"].as_str().unwrap().to_owned(); + let chief = connect(&mux, ClientTransport::Unix); + let bound = command( + &mux, + &chief, + json!({"cmd": "conversation-bind", "participant": "agent_release", "token": fresh}), ); + assert_eq!(bound["participant"], "agent_release", "{bound}"); } diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs index bac4d1c84e90..8dfaa02a5524 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs @@ -286,7 +286,9 @@ fn mutate( Op::ChiefProfileCreate => chief_registry::create(tx, &profile), Op::ChiefProfileAdopt => match adoptee { Some(Adoptee { id, display_name: Some(name) }) => { - chief_registry::adopt(tx, id, name, &profile) + chief_registry::adopt(tx, id, name, &profile, || { + mux.claim_unbound_participant(id) + }) } Some(Adoptee { id, .. }) if id == chief_registry::DEFAULT_CHIEF => { Ok(Err(Refusal::DefaultChief)) @@ -335,6 +337,12 @@ fn mutate( Err(Refusal::KeyConflict(committed)) => { Err(ResourceError::idempotency_conflict(&key, &committed)) } + Err(Refusal::ParticipantBound) => Err(ResourceError::operation_failed( + operation.wire_name(), + Refusal::ParticipantBound.reason(), + json!({"message": "stop its current agent session first", + "hint": "stop its current agent session first"}), + )), Err(refusal) => Err(refused(operation, &refusal)), } } @@ -494,6 +502,19 @@ fn with_store( run(store.as_mut().expect("chief registry opened above")) } +/// Whether `participant` is a live Chief record other than the default Chief +/// (its credential is its brain's, or the person's on a daemon with no brain +/// supervisor). An unreadable registry answers true: fail closed. +pub(super) fn is_owned_chief(mux: &Mux, participant: &str) -> bool { + if participant == chief_registry::DEFAULT_CHIEF || !participant.starts_with("agent_") { + return false; + } + with_store(mux, Op::ChiefProfileGet, |store| { + store.get(participant).map_err(|e| internal(Op::ChiefProfileGet, &e)) + }) + .map_or(true, |record| record.is_some()) +} + /// The participant `chief.profile.adopt` names: its id, and its display name /// when it is an agent participant of a local conversation. struct Adoptee { diff --git a/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs b/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs index 68ac69035e9d..4e48dcac4e26 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs @@ -579,9 +579,12 @@ pub(super) fn bind(mux: &Mux, client: u64, params: BindParams) -> anyhow::Result crate::conversation_store::credentialed(&participant), "only agent participants and other people bind with a token" ); - let valid = mux.with_conversations(|store| store.verify_agent_token(&participant, &token))?; + // The check and the binding under one lock: a credential that + // chief.profile.adopt revoked never binds after the adopt. + let valid = mux.bind_conversation_principal_if(client, participant.clone(), || { + mux.with_conversations(|store| store.verify_agent_token(&participant, &token)) + })?; anyhow::ensure!(valid, "conversation agent token is not valid for {participant}"); - mux.bind_conversation_principal(client, participant.clone())?; Ok(json!({"participant": participant})) } @@ -599,6 +602,23 @@ pub(super) fn agent_token( crate::conversation_store::credentialed(¶ms.participant), "participant tokens are only for agents and other people" ); + // A Chief record's credential (other than the default Chief's): with a + // brain supervisor only its brain gets one (minted in process); without + // one, only the person's own terminal mints it, never a page, a paired or + // remote device or an agent (chief.profile.adopt, hq-6d 2026-10-14). + if super::chief_registry_resource::is_owned_chief(mux, ¶ms.participant) { + anyhow::ensure!( + crate::chief_brains::taken().is_none(), + "{} is a Chief: its brain holds its credential", + params.participant + ); + anyhow::ensure!( + !mux.is_remote_client(client) + && super::origin_gate::connection_page(mux, client).is_none() + && !super::origin_gate::is_page(mux, client), + "only the person, from a terminal on this machine, mints a Chief's credential" + ); + } let token = mux.with_conversations(|store| store.mint_agent_token(¶ms.participant))?; // A replaced token also ends the connections bound with the old one. mux.unbind_conversation_participant(¶ms.participant); diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs index 63f4c2381c9e..f87c117dd2ad 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs @@ -59,6 +59,36 @@ fn dm_with(home: &mut Conn, agent: &str, name: &str) -> String { created["conversation"]["id"].as_str().unwrap().to_owned() } +/// One v1 request; the whole reply, refused or not. +fn try_request(conn: &mut Conn, cmd: &str, params: Value) -> Value { + let id = json!(format!("try-{}", conn.next)); + conn.next += 1; + let mut body = params.as_object().cloned().unwrap_or_default(); + body.insert("id".into(), id.clone()); + body.insert("cmd".into(), json!(cmd)); + writeln!(conn.writer, "{}", Value::Object(body)).unwrap(); + loop { + let value = conn.line(); + if value.get("event").is_none() && value["id"] == id { + return value; + } + } +} + +/// `chief.profile.adopt` on `home`, retried while the participant's old +/// connection is still being closed by the daemon (`participant_bound`). +fn adopt_once_unbound(home: &mut Conn, params: Value, key: &str) -> Value { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let response = v2(home, "chief.profile.adopt", params.clone(), Some(key)); + if response["ok"] == true || reason(&response) != "participant_bound" { + return response; + } + assert!(Instant::now() < deadline, "the old connection never went away: {response}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + fn say(conn: &mut Conn, conversation: &str, key: &str, text: &str) { conn.request( "conversation-op", @@ -112,11 +142,20 @@ fn the_person_adopts_an_agent_and_it_keeps_its_dm_history_and_credential() { ); watcher.request("subscribe", json!({})); - // The CLI adopts it: its id and name, a new Chief's defaults, the role given. + // While its agent session is bound, the adopt is refused with a hint: + // two processes never speak as one participant. let output = chief_cli(&server, &["adopt", "agent_release", "--role", "Release trains", "--json"], ""); - assert_success(&output); - let record: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("participant_bound"), "{output:?}"); + // The session stops; the adopt goes through. + drop(agent); + let record = adopt_once_unbound( + &mut home, + json!({"participant_id": "agent_release", "role": "Release trains"}), + "adopt-release", + ); + let record = ok(&record)["value"].clone(); assert_eq!(record["id"], "agent_release", "{record}"); assert_eq!(record["display_name"], "Release Chief"); assert_eq!(record["role"], "Release trains"); @@ -134,8 +173,24 @@ fn the_person_adopts_an_agent_and_it_keeps_its_dm_history_and_credential() { assert_eq!(change["chief"], "agent_release", "{change}"); assert_eq!(change["change"], "created", "{change}"); - // The DM and its history are the same, and the agent's credential still posts there. + // The DM and its history are the same. The credential issued before the + // adopt is revoked; the person mints the Chief's new one (no brain + // supervisor here) and it posts in the same DM. assert_eq!(texts(&mut home, &dm), before); + let mut old = Conn::open(&server.socket); + let refused = try_request( + &mut old, + "conversation-bind", + json!({"participant": "agent_release", "token": token}), + ); + assert_eq!(refused["ok"], false, "a pre-adopt credential bound: {refused}"); + let fresh = + home.request("conversation-agent-token", json!({"participant": "agent_release"}))["token"] + .as_str() + .unwrap() + .to_owned(); + let mut agent = Conn::open(&server.socket); + agent.request("conversation-bind", json!({"participant": "agent_release", "token": fresh})); say(&mut agent, &dm, "a2", "Adopted and still here."); let after = texts(&mut home, &dm); assert_eq!(after.len(), before.len() + 1); @@ -271,12 +326,26 @@ fn an_adopted_chief_reaches_the_brain_supervisor_like_a_create() { ); let mut home = Conn::open(&server.socket); let _ = dm_with(&mut home, "agent_release", "Release Chief"); - ok(&v2( + let token = + home.request("conversation-agent-token", json!({"participant": "agent_release"}))["token"] + .as_str() + .unwrap() + .to_owned(); + let mut session = Conn::open(&server.socket); + session.request("conversation-bind", json!({"participant": "agent_release", "token": token})); + let bound = v2( &mut home, "chief.profile.adopt", json!({"participant_id": "agent_release"}), - Some("adopt-release"), - )); + Some("a0"), + ); + assert_eq!(reason(&bound), "participant_bound"); + drop(session); + ok(&adopt_once_unbound(&mut home, json!({"participant_id": "agent_release"}), "adopt-release")); + // With a brain supervisor, only the brain holds the Chief's credential. + let mint = + try_request(&mut home, "conversation-agent-token", json!({"participant": "agent_release"})); + assert_eq!(mint["ok"], false, "the person minted a supervised Chief's credential: {mint}"); let id = "agent_release"; let brain = chief_home.join("c").join(id[id.len() - 8..].to_ascii_lowercase()); let starts = brain.join("starts"); @@ -285,6 +354,14 @@ fn an_adopted_chief_reaches_the_brain_supervisor_like_a_create() { assert!(Instant::now() < deadline, "the adopted Chief's brain never started"); std::thread::sleep(Duration::from_millis(25)); } + // The brain's credential (minted by the supervisor) binds; the old one does not. + let brain_token = fs::read_to_string(brain.join("agent-token")).unwrap(); + let mut chief = Conn::open(&server.socket); + chief.request("conversation-bind", json!({"participant": id, "token": brain_token.trim()})); + let mut old = Conn::open(&server.socket); + let refused = + try_request(&mut old, "conversation-bind", json!({"participant": id, "token": token})); + assert_eq!(refused["ok"], false, "{refused}"); let daemon = i32::try_from(server.child.id()).unwrap(); // SAFETY: the test's own daemon child. unsafe { libc::kill(daemon, libc::SIGTERM) }; diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index 252cbec2a05a..44a9bb58dd79 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -6490,6 +6490,15 @@ connection whose principal is `user_local` may call it. The owner stores the token's SHA-256; a new token replaces the old one and ends the bindings made with it. With `conversation-person-credentials-v1`, `participant` may also be a person other than `user_local` (a `user_` id), never a `remote_` device. +For a Chief record other than `agent_mux` (`chief.profile.create` or +`chief.profile.adopt`), only its brain holds the credential when the daemon +runs a Chief brain supervisor (the supervisor mints it in process; this +command is refused), and on a daemon with no supervisor only the person's own +terminal mints it: a page (WebSocket or the host's page relay), a paired or +remote device and an agent are refused. `chief.profile.adopt` revokes every +credential issued for the participant before the adopt, and `conversation-bind` +checks the credential and binds under one lock, so a revoked credential never +binds after it. Params: `participant` (required). diff --git a/cmux-tui/spec/resource-api-v2.md b/cmux-tui/spec/resource-api-v2.md index df7533d6eb57..ac44d4cd5b32 100644 --- a/cmux-tui/spec/resource-api-v2.md +++ b/cmux-tui/spec/resource-api-v2.md @@ -1233,7 +1233,13 @@ participant it takes its id and, without `display_name`, its display name. Chief `deleted_chief` (an adopt never revives a deleted Chief or its kept memory), an id whose brain directory (its last 8 characters, lowercased) another live or deleted Chief already has `brain_home_taken`, and a person -or an unknown id `not_an_agent_participant` +or an unknown id `not_an_agent_participant`. A participant with a bound +connection is `participant_bound` ("stop its current agent session first"): +two processes never speak as one participant. In the same write every +credential issued for the participant is revoked; afterwards only the +supervisor-started brain's credential binds, or, on a daemon with no brain +supervisor, one the person mints with `conversation-agent-token` from a local +terminal (never a page, a paired or remote device or an agent) (`operation.failed` `details.reason`). It is idempotent per key like `chief.profile.create`, reaches the brain supervisor as a create does and publishes `chief-changed` with change `created`. diff --git a/cmux-tui/spec/resource-operations-v2.json b/cmux-tui/spec/resource-operations-v2.json index 9fb579576957..d3c0acbbfe7e 100644 --- a/cmux-tui/spec/resource-operations-v2.json +++ b/cmux-tui/spec/resource-operations-v2.json @@ -15320,7 +15320,7 @@ ], "constraints": [ "Owner only: a trusted local Unix caller acting as the person (a registered Unix client with no link peer record whose conversation principal is user_local). A page WebSocket, a paired or remote device and a bound agent (a Chief) are refused with origin.forbidden; no page route allows it.", - "The participant must be an agent participant (kind agent) of a local conversation that is not a Chief record: a live record is already_chief, a deleted Chief (its tombstone, and its memory while it is kept) is deleted_chief, agent_mux is default_chief, an id whose brain directory (its last 8 characters, lowercased) another Chief, live or deleted, already has is brain_home_taken, anything else is not_an_agent_participant (operation.failed details.reason). At most 64 live Chiefs: too_many_chiefs.", + "The participant must be an agent participant (kind agent) of a local conversation that is not a Chief record: a live record is already_chief, a deleted Chief (its tombstone, and its memory while it is kept) is deleted_chief, agent_mux is default_chief, an id whose brain directory (its last 8 characters, lowercased) another Chief, live or deleted, already has is brain_home_taken, a participant with a bound connection (its current agent session) is participant_bound with the hint to stop that session first, anything else is not_an_agent_participant (operation.failed details.reason). At most 64 live Chiefs: too_many_chiefs. In the same write the daemon revokes every credential issued for the participant before the adopt; after it, only the Chief brain's credential binds (with a brain supervisor), or on a daemon without one a credential the person mints from a local terminal (conversation-agent-token) after the adopt.", "The record gets create's defaults (tools spawn, tell, workspaces; self_edit avatar; no engine) and only the fields given; from the participant it takes only its id and, without display_name, its display name. Its conversations, history and credentials stay as they are.", "The idempotency key is per caller, as for chief.profile.create: the same key with the same params answers the committed record with replayed true; other params under the key are idempotency.conflict. The committed write reaches the Chief brain supervisor and publishes chief-profile-changed as a create does." ] From d21ef25018d7c6b6a36370551d386e90789f456a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:28:37 -0700 Subject: [PATCH 084/183] Remove main files added under directories feat-cmux-next deleted (main sync 2026-10-14, cx-q1lnl) Orphan check of the 9d01f956c51c merge: main added 73 files under the legacy app (Sources/, cmuxTests/, cmuxUITests/) and legacy Swift packages that feat-cmux-next deleted (CmuxMobileBilling, CMUXAgentLaunch, CmuxCloud, CmuxCore, CmuxSettingsUI, CmuxSidebar, CmuxWorkspaces) plus the new legacy-only package CmuxProductAnalytics (#20571). tests/test_opencode_server_entry.py reads the deleted CLI/cmux.swift and Resources/opencode-plugin.js. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxMobileBilling/BillingPeriod.swift | 25 -- .../ClaudeResumeTranscriptLocator.swift | 98 ------ .../CodexWriterLockSettle.swift | 62 ---- .../ClaudeResumeTranscriptLocatorTests.swift | 98 ------ .../Operations/CloudPlanRefusal.swift | 197 ----------- ...oudDiagnosticFailurePlanRefusalTests.swift | 160 --------- .../CmuxCloudTests/CloudLinkTestClock.swift | 124 ------- ...CloudMachineLinkTimeoutRecoveryTests.swift | 223 ------------ ...RemoteConfigurationFileTransferTests.swift | 175 ---------- .../macOS/CmuxProductAnalytics/Package.swift | 39 --- Packages/macOS/CmuxProductAnalytics/README.md | 35 -- .../ActivationMilestone.swift | 19 -- .../CmuxProductAnalytics/AgentHookPhase.swift | 9 - .../CmuxProductAnalytics/AgentKind.swift | 24 -- .../CmuxProductAnalytics/AnalyticsPlan.swift | 7 - .../AppUpdateDirection.swift | 5 - .../CloudButtonAction.swift | 7 - .../CloudEnableResult.swift | 10 - .../CloudErrorClass.swift | 35 -- .../CmuxProductAnalytics/CloudPlanLimit.swift | 24 -- .../CloudRequestObservation.swift | 22 -- .../CloudWelcomeButton.swift | 19 -- .../CloudWelcomeOutcome.swift | 21 -- .../CloudWelcomeStep.swift | 11 - .../CloudWelcomeTrigger.swift | 13 - .../CloudWorkspaceMachineChoice.swift | 7 - .../CmuxProductAnalytics/DurationBucket.swift | 31 -- .../ProductAnalytics.swift | 156 --------- .../ProductAnalyticsCommand.swift | 17 - .../ProductAnalyticsContext.swift | 68 ---- .../ProductAnalyticsContextChange.swift | 7 - .../ProductAnalyticsEvent.swift | 223 ------------ .../ProductAnalyticsLedger.swift | 104 ------ .../ProductAnalyticsPipeline.swift | 304 ----------------- .../CmuxProductAnalytics/ProductFeature.swift | 16 - .../SignInFailureReason.swift | 13 - .../UpgradeDestination.swift | 7 - .../ProductAnalyticsEventCatalogTests.swift | 124 ------- .../ProductAnalyticsPipelineTests.swift | 301 ---------------- .../RecordingAnalyticsSink.swift | 40 --- .../ProUpgradeCardModelTests.swift | 44 --- .../Status/ProgramStatusAgentSettlement.swift | 52 --- .../ProgramStatusAgentSettlementTests.swift | 69 ---- .../RestorableAgentProcessEvidence.swift | 42 --- .../RestorableAgentProcessMatcher.swift | 40 --- .../RestorableAgentProcessPresence.swift | 10 - .../RestorableAgentProcessMatcherTests.swift | 117 ------- Sources/AccountAnalyticsState.swift | 69 ---- Sources/AppDelegate+ProductAnalytics.swift | 42 --- Sources/Auth/PostHogIdentityObserver.swift | 50 --- Sources/Cloud/CloudVMSyncSubscriber.swift | 238 ------------- .../Cloud/CloudWelcomeAnalyticsSession.swift | 77 ----- Sources/Cloud/SidebarCloudButtonAction.swift | 24 -- Sources/HostAccountFlow+Analytics.swift | 38 --- Sources/PostHogContextCalls.swift | 13 - Sources/PostHogProductAnalyticsSink.swift | 29 -- Sources/ProBadgeActions.swift | 27 -- Sources/ProductAnalytics+App.swift | 37 -- Sources/ProductAnalyticsEvent+Upgrade.swift | 19 -- Sources/RemoteTui/SSHTuiAttachRetry.swift | 136 -------- ...sionIndex+ClaudeTranscriptlessResume.swift | 75 ---- Sources/RightSidebarMode+ProductFeature.swift | 16 - Sources/UpgradePromptImpression.swift | 16 - cmuxTests/AccountUpgradeOfferTests.swift | 68 ---- ...entAcknowledgedTurnNotificationTests.swift | 205 ----------- .../ClaudeTranscriptlessRestoreTests.swift | 255 -------------- ...udStaleInventoryMaterializationTests.swift | 61 ---- cmuxTests/CloudWelcomePresentationTests.swift | 104 ------ cmuxTests/SSHTuiAttachRetryTests.swift | 203 ----------- cmuxTests/SidebarCloudButtonActionTests.swift | 94 ----- cmuxTests/UpgradeAnalyticsTests.swift | 320 ------------------ .../BrowserFixtures/return-submit.html | 28 -- .../BrowserReturnSubmissionUITests.swift | 24 -- tests/test_opencode_server_entry.py | 232 ------------- 74 files changed, 5754 deletions(-) delete mode 100644 Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingPeriod.swift delete mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeResumeTranscriptLocator.swift delete mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockSettle.swift delete mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeResumeTranscriptLocatorTests.swift delete mode 100644 Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudPlanRefusal.swift delete mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudDiagnosticFailurePlanRefusalTests.swift delete mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudLinkTestClock.swift delete mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineLinkTimeoutRecoveryTests.swift delete mode 100644 Packages/macOS/CmuxCore/Tests/CmuxCoreTests/WorkspaceRemoteConfigurationFileTransferTests.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Package.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/README.md delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ActivationMilestone.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentHookPhase.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentKind.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AnalyticsPlan.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AppUpdateDirection.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudButtonAction.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudEnableResult.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudErrorClass.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudPlanLimit.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudRequestObservation.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeButton.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeOutcome.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeStep.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeTrigger.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWorkspaceMachineChoice.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/DurationBucket.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalytics.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsCommand.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContext.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContextChange.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsEvent.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsLedger.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsPipeline.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductFeature.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/SignInFailureReason.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/UpgradeDestination.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsEventCatalogTests.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsPipelineTests.swift delete mode 100644 Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/RecordingAnalyticsSink.swift delete mode 100644 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ProUpgradeCardModelTests.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/ProgramStatusAgentSettlement.swift delete mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/ProgramStatusAgentSettlementTests.swift delete mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessEvidence.swift delete mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessMatcher.swift delete mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessPresence.swift delete mode 100644 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestorableAgentProcessMatcherTests.swift delete mode 100644 Sources/AccountAnalyticsState.swift delete mode 100644 Sources/AppDelegate+ProductAnalytics.swift delete mode 100644 Sources/Auth/PostHogIdentityObserver.swift delete mode 100644 Sources/Cloud/CloudVMSyncSubscriber.swift delete mode 100644 Sources/Cloud/CloudWelcomeAnalyticsSession.swift delete mode 100644 Sources/Cloud/SidebarCloudButtonAction.swift delete mode 100644 Sources/HostAccountFlow+Analytics.swift delete mode 100644 Sources/PostHogContextCalls.swift delete mode 100644 Sources/PostHogProductAnalyticsSink.swift delete mode 100644 Sources/ProBadgeActions.swift delete mode 100644 Sources/ProductAnalytics+App.swift delete mode 100644 Sources/ProductAnalyticsEvent+Upgrade.swift delete mode 100644 Sources/RemoteTui/SSHTuiAttachRetry.swift delete mode 100644 Sources/RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift delete mode 100644 Sources/RightSidebarMode+ProductFeature.swift delete mode 100644 Sources/UpgradePromptImpression.swift delete mode 100644 cmuxTests/AccountUpgradeOfferTests.swift delete mode 100644 cmuxTests/AgentAcknowledgedTurnNotificationTests.swift delete mode 100644 cmuxTests/ClaudeTranscriptlessRestoreTests.swift delete mode 100644 cmuxTests/CloudStaleInventoryMaterializationTests.swift delete mode 100644 cmuxTests/CloudWelcomePresentationTests.swift delete mode 100644 cmuxTests/SSHTuiAttachRetryTests.swift delete mode 100644 cmuxTests/SidebarCloudButtonActionTests.swift delete mode 100644 cmuxTests/UpgradeAnalyticsTests.swift delete mode 100644 cmuxUITests/BrowserFixtures/return-submit.html delete mode 100644 cmuxUITests/BrowserReturnSubmissionUITests.swift delete mode 100644 tests/test_opencode_server_entry.py diff --git a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingPeriod.swift b/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingPeriod.swift deleted file mode 100644 index 5b8ed09e17de..000000000000 --- a/Packages/iOS/CmuxMobileBilling/Sources/CmuxMobileBilling/BillingPeriod.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -/// How often a subscription renews. The server sells each plan monthly and, -/// for some plans, yearly (`docs/billing/ios-in-app-purchases.md`). -public enum BillingPeriod: String, Sendable, Equatable, Hashable, Codable, CaseIterable, Comparable { - case monthly - case yearly - - /// The period of a `..` product id, or nil when the - /// suffix names neither period. - /// - Parameter productID: An App Store product id. - public init?(productID: String) { - if productID.hasSuffix(".yearly") { - self = .yearly - } else if productID.hasSuffix(".monthly") { - self = .monthly - } else { - return nil - } - } - - public static func < (lhs: BillingPeriod, rhs: BillingPeriod) -> Bool { - lhs == .monthly && rhs == .yearly - } -} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeResumeTranscriptLocator.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeResumeTranscriptLocator.swift deleted file mode 100644 index 5e143f23e8ee..000000000000 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeResumeTranscriptLocator.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation - -/// Finds the transcript Claude Code needs to resume a session. -/// -/// Claude writes `/projects//.jsonl` only after the first -/// message, so a session that was quit before any prompt has nothing to -/// resume and `claude --resume ` fails with "No conversation found". -public struct ClaudeResumeTranscriptLocator: Sendable { - private let homeDirectory: String - - /// Creates a locator that resolves the default config roots under `homeDirectory`. - public init(homeDirectory: String = NSHomeDirectory()) { - self.homeDirectory = homeDirectory - } - - /// Returns the config roots to search: each configured `CLAUDE_CONFIG_DIR` - /// first, then the cmux account roots, `~/.claude`, and the legacy - /// Subrouter root. Duplicates are removed. - public func configRoots(configuredDirectories: [String]) -> [String] { - var roots: [String] = [] - var seen: Set = [] - func appendRoot(_ path: String) { - let standardized = ((path as NSString).expandingTildeInPath as NSString).standardizingPath - guard seen.insert(standardized).inserted else { return } - roots.append(standardized) - } - - for directory in configuredDirectories { - let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - appendRoot(ClaudeConfigDirectoryPath.preferredPath(trimmed, homeDirectory: homeDirectory)) - } - let accountRoot = (homeDirectory as NSString).appendingPathComponent(".codex-accounts/claude") - if directoryExists(atPath: accountRoot), - let accountDirectories = try? FileManager.default.contentsOfDirectory(atPath: accountRoot) { - for accountDirectory in accountDirectories.sorted() { - appendRoot((accountRoot as NSString).appendingPathComponent(accountDirectory)) - } - } - appendRoot((homeDirectory as NSString).appendingPathComponent(".claude")) - appendRoot( - ClaudeConfigDirectoryPath.preferredPath( - (homeDirectory as NSString).appendingPathComponent(".subrouter/codex/claude"), - homeDirectory: homeDirectory - ) - ) - return roots - } - - /// Whether a non-empty transcript for `sessionId` exists in any project - /// under ``configRoots(configuredDirectories:)``. - public func transcriptExists(sessionId: String, configuredDirectories: [String]) -> Bool { - let sessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !sessionId.isEmpty, - sessionId != ".", - sessionId != "..", - !sessionId.contains("/"), - !sessionId.contains("\\") else { - return false - } - for root in configRoots(configuredDirectories: configuredDirectories) { - let projectsRoot = (root as NSString).appendingPathComponent("projects") - guard directoryExists(atPath: projectsRoot), - let projectDirectories = try? FileManager.default.contentsOfDirectory(atPath: projectsRoot) else { - continue - } - for projectDirectory in projectDirectories { - let projectRoot = (projectsRoot as NSString).appendingPathComponent(projectDirectory) - let directPath = (projectRoot as NSString).appendingPathComponent("\(sessionId).jsonl") - let nestedMessagesPath = (((projectRoot as NSString) - .appendingPathComponent(sessionId) as NSString) - .appendingPathComponent("messages") as NSString) - .appendingPathComponent("\(sessionId).jsonl") - if regularNonEmptyFileExists(atPath: directPath) - || regularNonEmptyFileExists(atPath: nestedMessagesPath) { - return true - } - } - } - return false - } - - private func regularNonEmptyFileExists(atPath path: String) -> Bool { - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), - !isDirectory.boolValue, - let attributes = try? FileManager.default.attributesOfItem(atPath: path), - let size = attributes[.size] as? NSNumber else { - return false - } - return size.intValue > 0 - } - - private func directoryExists(atPath path: String) -> Bool { - var isDirectory: ObjCBool = false - return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue - } -} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockSettle.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockSettle.swift deleted file mode 100644 index 12a41f6bb767..000000000000 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockSettle.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Darwin -import Foundation - -/// Re-probes Codex's writer lock while Codex is changing its lock namespace. -/// -/// `.changing` means another Codex held the shared coordination lock or -/// swapped a lock file during the probe. A relaunch resumes dozens of threads -/// in the same second, each holding coordination briefly, so a single probe -/// often lands in that window even though nobody owns this conversation. -/// Only `.changing` is retried; `.active` and `.unavailable` return at once. -public struct CodexWriterLockSettle: Sendable { - /// Creates a stateless settling policy. - public init() {} - - /// About 3.5 seconds, front-loaded: one Codex holds coordination for - /// milliseconds, while a whole relaunch burst can take a few seconds. - public static let delays: [TimeInterval] = [0.02, 0.05, 0.1, 0.2, 0.4, 0.8, 1, 1] - - /// Probes until the lock is not `.changing`, or the delay budget is spent. - /// - /// - Parameters: - /// - delays: the longest wait before each re-probe; its count bounds the probes. - /// - awaitChange: waits at most the given interval for the namespace to change. - /// - probe: one nonblocking lock inspection. - /// - Returns: The first settled observation, or the last `.changing` one. - public func inspection( - delays: [TimeInterval] = Self.delays, - awaitChange: (CodexWriterLockInspection, TimeInterval) -> Void = Self.awaitNamespaceChange, - probe: () -> CodexWriterLockInspection - ) -> CodexWriterLockInspection { - var inspection = probe() - for delay in delays where inspection.state == .changing { - awaitChange(inspection, delay) - inspection = probe() - } - return inspection - } - - /// Returns when Codex creates, renames, or unlinks a thread lock, or after `timeout`. - /// - /// Releasing a flock produces no file event, so the timeout is the - /// re-probe bound rather than a guess at when the writer finishes. - public static func awaitNamespaceChange(_ inspection: CodexWriterLockInspection, timeout: TimeInterval) { - let directory = URL(fileURLWithPath: inspection.lockPath).deletingLastPathComponent().path - let descriptor = open(directory, O_EVTONLY | O_CLOEXEC) - // `.changing` implies the directory exists; without an event source - // the bound alone paces the next probe. - guard descriptor >= 0 else { - _ = DispatchSemaphore(value: 0).wait(timeout: .now() + timeout) - return - } - let changed = DispatchSemaphore(value: 0) - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: descriptor, eventMask: [.write, .delete, .rename], queue: .global(qos: .utility) - ) - source.setEventHandler { changed.signal() } - source.setCancelHandler { close(descriptor) } - source.resume() - defer { source.cancel() } - _ = changed.wait(timeout: .now() + timeout) - } -} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeResumeTranscriptLocatorTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeResumeTranscriptLocatorTests.swift deleted file mode 100644 index 6edd6fbb35a1..000000000000 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeResumeTranscriptLocatorTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -import CMUXAgentLaunch -import Foundation -import Testing - -@Suite("Claude resume transcript lookup") -struct ClaudeResumeTranscriptLocatorTests { - private let sessionID = "8b1f3c2e-5d4a-4e6f-9a7b-0c1d2e3f4a5b" - - private func makeHome() throws -> URL { - let home = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-claude-transcript-locator-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) - return home - } - - private func writeTranscript( - configRoot: URL, - project: String = "-tmp-repo", - nested: Bool = false, - contents: String = "{\"type\":\"user\"}\n" - ) throws { - var directory = configRoot - .appendingPathComponent("projects", isDirectory: true) - .appendingPathComponent(project, isDirectory: true) - if nested { - directory = directory - .appendingPathComponent(sessionID, isDirectory: true) - .appendingPathComponent("messages", isDirectory: true) - } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try contents.write( - to: directory.appendingPathComponent("\(sessionID).jsonl"), - atomically: true, - encoding: .utf8 - ) - } - - @Test("A session started but never prompted has no transcript") - func missingTranscript() throws { - let home = try makeHome() - defer { try? FileManager.default.removeItem(at: home) } - let configRoot = home.appendingPathComponent("proxy-home", isDirectory: true) - try FileManager.default.createDirectory( - at: configRoot.appendingPathComponent("projects/-tmp-repo", isDirectory: true), - withIntermediateDirectories: true - ) - - let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) - #expect(!locator.transcriptExists(sessionId: sessionID, configuredDirectories: [configRoot.path])) - } - - @Test("An empty transcript file is not resumable") - func emptyTranscript() throws { - let home = try makeHome() - defer { try? FileManager.default.removeItem(at: home) } - try writeTranscript(configRoot: home.appendingPathComponent(".claude"), contents: "") - - let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) - #expect(!locator.transcriptExists(sessionId: sessionID, configuredDirectories: [])) - } - - @Test("A transcript under the configured config directory is found") - func configuredDirectory() throws { - let home = try makeHome() - defer { try? FileManager.default.removeItem(at: home) } - let configRoot = home.appendingPathComponent(".subrouter/codex/claude-proxy/abc", isDirectory: true) - try writeTranscript(configRoot: configRoot) - - let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) - #expect(locator.transcriptExists(sessionId: sessionID, configuredDirectories: [configRoot.path])) - #expect(!locator.transcriptExists(sessionId: sessionID, configuredDirectories: [])) - } - - @Test("Default roots cover ~/.claude, cmux accounts, and nested message transcripts") - func defaultRoots() throws { - let home = try makeHome() - defer { try? FileManager.default.removeItem(at: home) } - let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) - - try writeTranscript(configRoot: home.appendingPathComponent(".codex-accounts/claude/work"), nested: true) - #expect(locator.transcriptExists(sessionId: sessionID, configuredDirectories: [])) - try FileManager.default.removeItem(at: home.appendingPathComponent(".codex-accounts")) - - try writeTranscript(configRoot: home.appendingPathComponent(".claude")) - #expect(locator.transcriptExists(sessionId: sessionID, configuredDirectories: [])) - } - - @Test("Path-like session ids never match a transcript") - func unsafeSessionID() throws { - let home = try makeHome() - defer { try? FileManager.default.removeItem(at: home) } - try writeTranscript(configRoot: home.appendingPathComponent(".claude")) - - let locator = ClaudeResumeTranscriptLocator(homeDirectory: home.path) - #expect(!locator.transcriptExists(sessionId: "../\(sessionID)", configuredDirectories: [])) - #expect(!locator.transcriptExists(sessionId: "..", configuredDirectories: [])) - } -} diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudPlanRefusal.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudPlanRefusal.swift deleted file mode 100644 index 64906cbb6b47..000000000000 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Operations/CloudPlanRefusal.swift +++ /dev/null @@ -1,197 +0,0 @@ -import CmuxCloudResizeCore -import Foundation - -/// A `402` the control plane explained with a stable `error` code, read so a -/// person sees what actually stopped the request instead of a status-only -/// "your plan does not allow this" sentence. The server enforces every limit; -/// this type only names the one it reported. -public struct CloudPlanRefusal: Equatable, Sendable { - /// What the server refused, from its `error` code. - public enum Kind: Equatable, Sendable { - /// `vm_resource_pool_exceeded`: running machines already hold the - /// team's shared vCPUs or memory. Carries the overflow when the answer - /// named the pool, what is in use, and the request. - case resourcePool(CloudVMResourcePool.Shortfall?) - /// `vm_active_limit_exceeded` with a nonzero limit: every active slot is taken. - case activeLimit - /// `vm_create_credits_insufficient`. - case createCredits - /// `vm_memory_requires_plan`: the size needs a larger plan. - case memoryRequiresPlan - /// Any other code; the server's own message explains it. - case other - } - - /// The server's `error` code. - public let code: String - public let kind: Kind - /// The server's one-line explanation (`ui.message`, else `message`). - public let serverMessage: String? - /// The plan the answer offers as an upgrade (`upgradePlanId`), if any. - public let upgradePlanId: String? - - /// Reads a `402` answer; nil for other statuses or a body without a code. - /// - Parameters: - /// - status: The HTTP status. - /// - body: The response body. - public init?(status: Int, body: String) { - guard status == 402, - let data = body.data(using: .utf8), - let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], - let code = Self.string(object["error"]) else { return nil } - let ui = object["ui"] as? [String: Any] - self.code = code - self.serverMessage = Self.string(ui?["message"]) ?? Self.string(object["message"]) - self.upgradePlanId = Self.string(object["upgradePlanId"]) - switch code { - case Self.resourcePoolExceededCode: - kind = .resourcePool(Self.shortfall(object)) - case Self.activeLimitExceededCode: - // A zero limit is the subscribe gate, not a full set of slots. - let limit = (object["limit"] as? NSNumber)?.intValue - kind = limit == 0 ? .other : .activeLimit - case "vm_create_credits_insufficient": - kind = .createCredits - case "vm_memory_requires_plan": - kind = .memoryRequiresPlan - default: - kind = .other - } - } - - /// Reads the refusal from a Cloud client error; nil for anything else. - /// - Parameter error: The thrown error. - public init?(_ error: Error) { - if case let .httpStatus(status, body)? = error as? VMClientError { - self.init(status: status, body: body) - } else if case let .httpStatus(status, body)? = error as? MachineUsageClientError { - self.init(status: status, body: body) - } else { - return nil - } - } - - /// The diagnostic class: a full pool or full set of active slots is a - /// limit the team's own machines hold; the rest is what the plan includes. - /// Only classes the telemetry contract accepts may be returned here. - public var diagnosticFailure: CloudDiagnosticFailure { - switch kind { - case .resourcePool, .activeLimit: return .resourceLimit - case .createCredits, .memoryRequiresPlan, .other: return .plan - } - } - - /// Whether the answer names a larger plan that would let the request through. - public var offersUpgrade: Bool { upgradePlanId != nil } - - /// Whether a failed-create row can offer a larger plan for this refusal. - /// - /// - Parameters: - /// - code: The refusal code carried by the failed create. - /// - plan: The currently loaded plan, or nil while it is unavailable. - /// - Returns: Whether the row should show its upgrade action. - public static func offersUpgrade(for code: String?, plan: MachinePlanSnapshot?) -> Bool { - if code == activeLimitExceededCode { return plan?.hasHigherPlan ?? true } - return code == resourcePoolExceededCode && plan?.hasHigherResourcePoolPlan == true - } - - /// The localized sentence for a person: what stopped the request and what - /// to do, with an upgrade only as the secondary option. - public var message: String { - switch kind { - case .resourcePool(let shortfall?): - return CloudVMResourcePool.shortfallText(shortfall, offersUpgrade: offersUpgrade) - case .resourcePool(nil): - return Self.capacityMessage - case .activeLimit: - return String( - localized: "cloud.operation.failure.activeLimit", - defaultValue: "Every active machine your plan allows is in use. Pause or delete a running machine, then try again." - ) - case .createCredits: - if serverMessage?.hasPrefix("Your account has no Cloud VM create credits left.") == true { - return String( - localized: "cloud.operation.failure.createCredits.account", - defaultValue: "Your account has no machine create credits left. Add Cloud VM create credits or upgrade your plan." - ) - } - return String( - localized: "cloud.operation.failure.createCredits", - defaultValue: "Your team has no machine create credits left. Ask a team admin to add credits or upgrade the plan." - ) - case .memoryRequiresPlan: - return String( - localized: "cloud.operation.failure.memoryRequiresPlan", - defaultValue: "This size is larger than your plan includes. Choose a smaller size, or upgrade the plan." - ) - case .other: - return serverMessage ?? CloudDiagnosticFailure.plan.label - } - } - - /// `vm_resource_pool_exceeded`: running machines hold the shared pool. - public static let resourcePoolExceededCode = "vm_resource_pool_exceeded" - /// `vm_active_limit_exceeded`: every active-machine slot is taken. - public static let activeLimitExceededCode = "vm_active_limit_exceeded" - - /// The stable reference a failure transcript carries for a refusal, as - /// both the CLI and the in-process create print it. - /// - Parameter code: The server's `error` code. - public static func reference(code: String) -> String { "(HTTP 402: \(code))" } - - /// The refusal code the `cmux` CLI named in its own error header, the - /// `Error: (HTTP 402: <code>)` line it writes when a request - /// fails; nil when the transcript has no such header. Only the first - /// `Error:` line is read and only the reference that ends it, so the - /// server's message on the lines after it can never stand in for the code. - /// - Parameter output: The CLI's failure transcript. - public static func code(inCLIFailureOutput output: String) -> String? { - guard let header = output.split(whereSeparator: \.isNewline) - .first(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("Error:") }) else { return nil } - let line = header.trimmingCharacters(in: .whitespaces) - guard let match = line.range(of: #"\(HTTP 402: [A-Za-z0-9_.-]+\)$"#, options: .regularExpression) else { - return nil - } - return String(line[match].dropFirst("(HTTP 402: ".count).dropLast()) - } - - /// A full pool whose answer did not name the numbers. - public static var capacityMessage: String { - String( - localized: "cloud.operation.failure.capacity", - defaultValue: "Your team's running machines are using all of the plan's Cloud capacity. Pause or delete a running machine, then try again." - ) - } - - /// The overflow named by a pool answer's `resource`, `pool`, `used`, and - /// `requested`; nil when any part is missing. - private static func shortfall(_ object: [String: Any]) -> CloudVMResourcePool.Shortfall? { - guard let pool = object["pool"] as? [String: Any], - let used = object["used"] as? [String: Any], - let requested = object["requested"] as? [String: Any] else { return nil } - switch string(object["resource"]) { - case "memoryMb": - guard let poolMb = int(pool["memoryMb"]), let usedMb = int(used["memoryMb"]), - let requestedMb = int(requested["memoryMb"]) else { return nil } - return .memory(requestedMb: requestedMb, freeMb: max(0, poolMb - usedMb), poolMb: poolMb) - case "vcpus": - guard let poolVcpus = int(pool["vcpus"]), let usedVcpus = int(used["vcpus"]), - let requestedVcpus = int(requested["vcpus"]) else { return nil } - return .vcpus(requested: requestedVcpus, free: max(0, poolVcpus - usedVcpus), pool: poolVcpus) - default: - return nil - } - } - - private static func string(_ raw: Any?) -> String? { - guard let value = (raw as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty else { return nil } - return value - } - - private static func int(_ raw: Any?) -> Int? { - guard let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), - number.doubleValue.isFinite, number.doubleValue >= 0 else { return nil } - return Int(exactly: number.doubleValue) - } -} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudDiagnosticFailurePlanRefusalTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudDiagnosticFailurePlanRefusalTests.swift deleted file mode 100644 index 46d50ff9cd6b..000000000000 --- a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudDiagnosticFailurePlanRefusalTests.swift +++ /dev/null @@ -1,160 +0,0 @@ -@testable import CmuxCloud -import CmuxCloudResizeCore -import Foundation -import Testing - -/// A `402` names what stopped the request in its `error` code. A full shared -/// pool on a paid team is not a missing plan, and must not read as one. -@Suite("Cloud diagnostic failure for plan refusals") -struct CloudDiagnosticFailurePlanRefusalTests { - static let poolExceededBody = """ - {"error":"vm_resource_pool_exceeded","message":"Your VMs already use 20 of 20 vCPUs. This VM needs 4 vCPUs.", - "resource":"vcpus","pool":{"vcpus":20,"memoryMb":40960},"used":{"vcpus":20,"memoryMb":40960}, - "requested":{"vcpus":4,"memoryMb":8192},"upgradePlanId":"max"} - """ - - @Test("A full shared pool is classified as a resource limit, not as the plan") - func poolExceededIsNotPlan() { - let error = VMClientError.httpStatus(402, Self.poolExceededBody) - let failure = CloudDiagnosticFailure.classify(error) - #expect(failure != .plan) - #expect(failure.label != CloudDiagnosticFailure.plan.label) - } -} - -extension CloudDiagnosticFailurePlanRefusalTests { - private static func body(_ object: [String: Any]) -> String { - String(decoding: try! JSONSerialization.data(withJSONObject: object), as: UTF8.self) - } - - @Test("A full vCPU pool names what is free and the request, with Max as the secondary way through") - func poolExceededMessageNamesTheVcpuShortfall() throws { - let refusal = try #require(CloudPlanRefusal(VMClientError.httpStatus(402, Self.poolExceededBody))) - #expect(refusal.code == "vm_resource_pool_exceeded") - #expect(refusal.kind == .resourcePool(.vcpus(requested: 4, free: 0, pool: 20))) - #expect(refusal.offersUpgrade) - #expect(refusal.message == CloudVMResourcePool.shortfallText(.vcpus(requested: 4, free: 0, pool: 20), offersUpgrade: true)) - #expect(CloudDiagnosticFailure.message(for: VMClientError.httpStatus(402, Self.poolExceededBody)) == refusal.message) - } - - @Test("A full memory pool on Max names the memory and offers no upgrade") - func poolExceededMemoryOnMax() throws { - let body = Self.body([ - "error": "vm_resource_pool_exceeded", "resource": "memoryMb", - "pool": ["vcpus": 80, "memoryMb": 163_840], "used": ["vcpus": 40, "memoryMb": 147_456], - "requested": ["vcpus": 8, "memoryMb": 32_768], "upgradePlanId": NSNull(), - ]) - let refusal = try #require(CloudPlanRefusal(status: 402, body: body)) - #expect(refusal.kind == .resourcePool(.memory(requestedMb: 32_768, freeMb: 16_384, poolMb: 163_840))) - #expect(!refusal.offersUpgrade) - #expect(refusal.diagnosticFailure == .resourceLimit) - } - - @Test("A pool refusal without numbers still names the pool") - func poolExceededWithoutNumbers() throws { - let refusal = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_resource_pool_exceeded"}"#)) - #expect(refusal.kind == .resourcePool(nil)) - #expect(refusal.message == CloudPlanRefusal.capacityMessage) - } - - @Test("Each recognised 402 code maps to its own class and sentence") - func recognisedCodes() throws { - let active = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_active_limit_exceeded","limit":5}"#)) - #expect(active.kind == .activeLimit) - #expect(active.diagnosticFailure == .resourceLimit) - let credits = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_create_credits_insufficient","amount":1}"#)) - #expect(credits.kind == .createCredits) - #expect(credits.diagnosticFailure == .plan) - #expect(credits.message != CloudDiagnosticFailure.plan.label) - let memory = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_memory_requires_plan","upgradePlanId":"max"}"#)) - #expect(memory.kind == .memoryRequiresPlan) - #expect(memory.diagnosticFailure == .plan) - #expect(memory.message != CloudDiagnosticFailure.plan.label) - } - - @Test("A zero active limit is the subscribe gate and keeps the server's sentence") - func zeroActiveLimitIsTheSubscribeGate() throws { - let refusal = try #require(CloudPlanRefusal(status: 402, body: Self.body([ - "error": "vm_active_limit_exceeded", "limit": 0, - "message": "Cloud VMs require a cmux Pro subscription.", - ]))) - #expect(refusal.kind == .other) - #expect(refusal.message == "Cloud VMs require a cmux Pro subscription.") - } - - @Test("Pool upgrades follow the capacity ladder, including Founders and Team") - func resourcePoolUpgradeActions() { - let poolCode = CloudPlanRefusal.resourcePoolExceededCode - for planID in ["pro", "founders", " Team "] { - let plan = MachinePlanSnapshot(activeCount: 1, planId: planID) - #expect(CloudPlanRefusal.offersUpgrade(for: poolCode, plan: plan), "\(planID)") - } - let maxPlan = MachinePlanSnapshot(activeCount: 1, planId: " MAX ") - #expect(!CloudPlanRefusal.offersUpgrade(for: poolCode, plan: maxPlan)) - #expect(!CloudPlanRefusal.offersUpgrade(for: poolCode, plan: nil)) - #expect(!CloudPlanRefusal.offersUpgrade(for: "vm_other", plan: maxPlan)) - #expect(CloudPlanRefusal.offersUpgrade(for: CloudPlanRefusal.activeLimitExceededCode, plan: nil)) - for planID in ["max", "founders", "team"] { - let plan = MachinePlanSnapshot(activeCount: 5, planId: planID) - #expect(!CloudPlanRefusal.offersUpgrade(for: CloudPlanRefusal.activeLimitExceededCode, plan: plan)) - } - } - - @Test("Create-credit refusals keep the server's account or team billing scope") - func createCreditsPreserveBillingScope() throws { - let account = try #require(CloudPlanRefusal(status: 402, body: Self.body([ - "error": "vm_create_credits_insufficient", - "message": "Your account has no Cloud VM create credits left.", - ]))) - #expect(account.message == String( - localized: "cloud.operation.failure.createCredits.account", - defaultValue: "Your account has no machine create credits left. Add Cloud VM create credits or upgrade your plan." - )) - let team = try #require(CloudPlanRefusal(status: 402, body: Self.body([ - "error": "vm_create_credits_insufficient", - "message": "This team has no Cloud VM create credits left.", - ]))) - #expect(team.message == String( - localized: "cloud.operation.failure.createCredits", - defaultValue: "Your team has no machine create credits left. Ask a team admin to add credits or upgrade the plan." - )) - } - - @Test("An unrecognised 402 prefers the server's sentence, else the plan sentence") - func unrecognisedCodes() throws { - let explained = try #require(CloudPlanRefusal(status: 402, body: Self.body([ - "error": "vm_hours_limit_reached", "message": "fallback", "ui": ["message": "You used this month's hours."], - ]))) - #expect(explained.message == "You used this month's hours.") - let bare = try #require(CloudPlanRefusal(status: 402, body: #"{"error":"vm_new_gate"}"#)) - #expect(bare.message == CloudDiagnosticFailure.plan.label) - } - - @Test("A 402 without a code keeps the status classification") - func statusOnlyFallback() { - #expect(CloudPlanRefusal(status: 402, body: "not json") == nil) - #expect(CloudPlanRefusal(status: 402, body: #"{"message":"no code"}"#) == nil) - #expect(CloudPlanRefusal(status: 409, body: #"{"error":"vm_resource_pool_exceeded"}"#) == nil) - #expect(CloudDiagnosticFailure.classify(VMClientError.httpStatus(402, "")) == .plan) - #expect(CloudDiagnosticFailure.message(for: VMClientError.httpStatus(402, "")) == CloudDiagnosticFailure.plan.label) - #expect(CloudDiagnosticFailure.classify(MachineUsageClientError.httpStatus(402, Self.poolExceededBody)) == .resourceLimit) - #expect(CloudDiagnosticFailure.classify(VMClientError.httpStatus(503, Self.poolExceededBody)) == .server) - } -} - -extension CloudDiagnosticFailurePlanRefusalTests { - @Test("The CLI's refusal code is read only from its own error header") - func cliFailureCodeComesFromTheHeader() { - #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: Cloud VM limit reached (HTTP 402: vm_active_limit_exceeded)\nThis plan allows 5.") == "vm_active_limit_exceeded") - #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: Refused (HTTP 402: vm_resource_pool_exceeded_v2)") == "vm_resource_pool_exceeded_v2") - // The server's message follows the header; a reference inside it never counts. - let spoofed = "Error: Refused (HTTP 402: vm_new_gate)\nSee (HTTP 402: vm_resource_pool_exceeded)" - #expect(CloudPlanRefusal.code(inCLIFailureOutput: spoofed) == "vm_new_gate") - // A reference inside the title is not the one that ends the header. - #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: (HTTP 402: vm_resource_pool_exceeded) x (HTTP 402: vm_new_gate)") == "vm_new_gate") - #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Full.\n(HTTP 402: vm_resource_pool_exceeded)") == nil) - #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: Busy (HTTP 409: vm_resource_pool_exceeded)") == nil) - #expect(CloudPlanRefusal.code(inCLIFailureOutput: "Error: Refused (HTTP 402: vm_resource_pool_exceeded) later") == nil) - #expect(CloudPlanRefusal.reference(code: "vm_x") == "(HTTP 402: vm_x)") - } -} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudLinkTestClock.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudLinkTestClock.swift deleted file mode 100644 index 574cd02dcd7d..000000000000 --- a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudLinkTestClock.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation - -/// A manually-advanced virtual clock for deadline tests: time only moves when -/// the test calls ``advance(by:)``, so timeout behavior is exercised without -/// real waiting. Sleeps are cancellation-responsive (resuming with -/// `CancellationError`), matching the request and process deadline lifecycle. -// Clock requires synchronous now; its test-only mutable state is protected by the lock. -final class CloudLinkTestClock: Clock, @unchecked Sendable { - struct Instant: InstantProtocol, Sendable { - var offset: Duration - - func advanced(by duration: Duration) -> Instant { Instant(offset: offset + duration) } - func duration(to other: Instant) -> Duration { other.offset - offset } - static func < (lhs: Instant, rhs: Instant) -> Bool { lhs.offset < rhs.offset } - } - - private struct Sleeper { - let deadline: Instant - let continuation: CheckedContinuation<Void, any Error> - } - - private let lock = NSLock() - private var currentInstant = Instant(offset: .zero) - private var sleepers: [UUID: Sleeper] = [:] - /// Sleeps cancelled before their continuation was parked (the - /// `withTaskCancellationHandler` handler can run first). - private var cancelledSleeperIDs: Set<UUID> = [] - private var parkWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = [] - - var now: Instant { - lock.lock() - defer { lock.unlock() } - return currentInstant - } - - var minimumResolution: Duration { .zero } - - func sleep(until deadline: Instant, tolerance: Duration?) async throws { - let id = UUID() - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in - lock.lock() - if cancelledSleeperIDs.remove(id) != nil { - lock.unlock() - continuation.resume(throwing: CancellationError()) - return - } - if deadline <= currentInstant { - lock.unlock() - continuation.resume() - return - } - sleepers[id] = Sleeper(deadline: deadline, continuation: continuation) - let satisfied = takeSatisfiedParkWaitersLocked() - lock.unlock() - for waiter in satisfied { waiter.resume() } - } - } onCancel: { - lock.lock() - let sleeper = sleepers.removeValue(forKey: id) - if sleeper == nil { cancelledSleeperIDs.insert(id) } - lock.unlock() - sleeper?.continuation.resume(throwing: CancellationError()) - } - } - - /// Suspends until at least `count` sleepers are parked, so a test advances - /// time only once the deadline under test is actually waiting (instead of - /// racing its start). - func waitUntilSleepers(count: Int = 1) async { - await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in - lock.lock() - if sleepers.count >= count { - lock.unlock() - continuation.resume() - return - } - parkWaiters.append((count, continuation)) - lock.unlock() - } - } - - /// Suspends until a sleeper is parked whose deadline is at most `window` - /// from now, so a test can target one short backoff sleep while longer - /// phase deadlines are also parked. - func waitUntilSleeper(dueWithin window: Duration) async { - while !hasSleeper(dueWithin: window) { - await waitUntilSleepers() - await Task.yield() - } - } - - private func hasSleeper(dueWithin window: Duration) -> Bool { - lock.lock() - defer { lock.unlock() } - let limit = currentInstant.advanced(by: window) - return sleepers.values.contains { $0.deadline <= limit } - } - - /// Advance virtual time, resuming every sleeper whose deadline has passed. - func advance(by duration: Duration) { - lock.lock() - currentInstant = currentInstant.advanced(by: duration) - var due: [Sleeper] = [] - for (id, sleeper) in sleepers where sleeper.deadline <= currentInstant { - sleepers[id] = nil - due.append(sleeper) - } - lock.unlock() - for sleeper in due.sorted(by: { $0.deadline < $1.deadline }) { - sleeper.continuation.resume() - } - } - - private func takeSatisfiedParkWaitersLocked() -> [CheckedContinuation<Void, Never>] { - var satisfied: [CheckedContinuation<Void, Never>] = [] - parkWaiters.removeAll { waiter in - guard sleepers.count >= waiter.count else { return false } - satisfied.append(waiter.continuation) - return true - } - return satisfied - } -} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineLinkTimeoutRecoveryTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineLinkTimeoutRecoveryTests.swift deleted file mode 100644 index 63e7b14da258..000000000000 --- a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudMachineLinkTimeoutRecoveryTests.swift +++ /dev/null @@ -1,223 +0,0 @@ -@testable import CmuxCloud -import CmuxCloudTui -import Darwin -import Foundation -import Testing - -@Suite("Cloud machine link timeout recovery", .timeLimit(.minutes(1))) -struct CloudMachineLinkTimeoutRecoveryTests { - @Test("a request deadline does not drop a healthy shared link") - func requestTimeoutKeepsHealthyLink() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-link-request-timeout-\(UUID().uuidString.lowercased())", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let socketPath = "/tmp/cmux-link-request-timeout-\(UUID().uuidString.lowercased()).sock" - let listener = try Self.makeListener(at: socketPath) - defer { Darwin.close(listener); unlink(socketPath) } - let client = root.appendingPathComponent("fake-cmux-tui") - try """ - #!/bin/sh - printf '%s\\n' '{"event":"connection-snapshot","local_socket":"\(socketPath)"}' - exec tail -f /dev/null - """.write(to: client, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: client.path) - let requestReceived = CloudLinkFirstValue<Bool>() - let server = Task.detached { Self.serve(listener: listener, respondsToPing: true, requestReceived: requestReceived) } - let clock = CloudLinkTestClock() - let link = CloudMachineLink(machineID: "healthy-timeout-machine", clientURL: client, paths: CloudTuiClientPaths(home: root), deadlineClock: clock) - _ = try await link.connect(route: "ws://127.0.0.1:1337/v1/link", session: "cloud", carrier: true, timeout: .seconds(2)) - let request = Task { try await link.run(arguments: CloudTuiRequest("terminal.wait"), timeout: .seconds(1)) } - _ = await requestReceived.result - await clock.waitUntilSleeper(dueWithin: .seconds(1)) - clock.advance(by: .seconds(1)) - do { - _ = try await request.value - Issue.record("the intentionally short request must time out") - } catch CloudMachineLink.LinkError.requestTimedOut { - // Expected: the health probe below receives a response on the same channel. - } - #expect(await link.isConnected) - _ = try await link.run(arguments: CloudTuiRequest("session.ping"), timeout: .seconds(1)) - #expect(await link.isConnected) - await link.disconnect() - server.cancel() - await server.value - } - - @Test("a timed out control request drops the wedged link for replacement") - func timeoutDisconnectsLink() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-link-timeout-\(UUID().uuidString.lowercased())", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let socketPath = "/tmp/cmux-link-timeout-\(UUID().uuidString.lowercased()).sock" - let listener = try Self.makeListener(at: socketPath) - defer { - Darwin.close(listener) - unlink(socketPath) - } - - let client = root.appendingPathComponent("fake-cmux-tui") - try """ - #!/bin/sh - printf '%s\\n' '{"event":"connection-snapshot","local_socket":"\(socketPath)"}' - exec tail -f /dev/null - """.write(to: client, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: client.path) - - let requestReceived = CloudLinkFirstValue<Bool>() - let probeReceived = CloudLinkFirstValue<Bool>() - let server = Task.detached(priority: .userInitiated) { - Self.serve(listener: listener, respondsToPing: false, requestReceived: requestReceived, probeReceived: probeReceived) - } - let clock = CloudLinkTestClock() - let link = CloudMachineLink( - machineID: "timeout-machine", - clientURL: client, - paths: CloudTuiClientPaths(home: root), - deadlineClock: clock - ) - _ = try await link.connect( - route: "ws://127.0.0.1:1337/v1/link", - session: "cloud", - carrier: true, - timeout: .seconds(2) - ) - - let request = Task { try await link.run(arguments: CloudTuiRequest("session.ping"), timeout: .seconds(1)) } - _ = await requestReceived.result - await clock.waitUntilSleeper(dueWithin: .seconds(1)) - clock.advance(by: .seconds(1)) - _ = await probeReceived.result - await clock.waitUntilSleeper(dueWithin: .seconds(2)) - clock.advance(by: .seconds(2)) - do { - _ = try await request.value - Issue.record("the unresponsive control request must time out") - } catch CloudMachineLink.LinkError.requestTimedOut { - // Expected: the timeout is the health boundary for this local link. - } - #expect(await link.isConnected == false) - #expect(await link.state == .unavailable) - - server.cancel() - await server.value - } - - @Test("disconnect escalates a child that ignores TERM on the injected deadline") - func disconnectKillsWedgedChild() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-link-shutdown-\(UUID())", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let socketPath = "/tmp/cmux-link-shutdown-\(UUID()).sock" - let listener = try Self.makeListener(at: socketPath) - defer { Darwin.close(listener); unlink(socketPath) } - let client = root.appendingPathComponent("fake-cmux-tui") - let pidFile = root.appendingPathComponent("pid") - try """ - #!/bin/sh - trap '' TERM - echo $$ > '\(pidFile.path)' - printf '%s\\n' '{"event":"connection-snapshot","local_socket":"\(socketPath)"}' - exec tail -f /dev/null - """.write(to: client, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: client.path) - let server = Task.detached { Self.serve(listener: listener, respondsToPing: true) } - let clock = CloudLinkTestClock() - let link = CloudMachineLink(machineID: "wedged-child", clientURL: client, - paths: CloudTuiClientPaths(home: root), deadlineClock: clock) - _ = try await link.connect(route: "ws://127.0.0.1:1337/v1/link", session: "cloud", carrier: true, timeout: .seconds(2)) - let pid = try #require(Int32(String(contentsOf: pidFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines))) - let disconnect = Task { await link.disconnect() } - await clock.waitUntilSleeper(dueWithin: .seconds(5)) - #expect(Darwin.kill(pid, 0) == 0) - clock.advance(by: .seconds(5)) - await disconnect.value - #expect(Darwin.kill(pid, 0) != 0) - #expect(await link.isConnected == false) - server.cancel() - await server.value - } - - private static func makeListener(at path: String) throws -> Int32 { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { throw POSIXError(.EIO) } - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let bytes = Array(path.utf8CString) - guard bytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { - Darwin.close(fd) - throw POSIXError(.ENAMETOOLONG) - } - withUnsafeMutableBytes(of: &address.sun_path) { destination in - bytes.withUnsafeBytes { source in destination.copyBytes(from: source) } - } - let result = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) - } - } - guard result == 0, listen(fd, 1) == 0 else { - Darwin.close(fd) - throw POSIXError(.EADDRINUSE) - } - return fd - } - - private static func serve(listener: Int32, respondsToPing: Bool, requestReceived: CloudLinkFirstValue<Bool>? = nil, probeReceived: CloudLinkFirstValue<Bool>? = nil) { - let peer = accept(listener, nil, nil) - guard peer >= 0 else { return } - defer { Darwin.close(peer) } - var line = Data() - var requestCount = 0 - var byte: UInt8 = 0 - while !Task.isCancelled { - let count = withUnsafeMutableBytes(of: &byte) { Darwin.read(peer, $0.baseAddress, 1) } - guard count == 1 else { return } - if byte == 0x0A { - guard - let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], - let id = object["id"] as? String - else { - line.removeAll(keepingCapacity: true) - continue - } - if let operation = object["operation"] as? String, operation == "session.ping" || operation == "terminal.wait" { - requestCount += 1 - if requestCount == 1 { requestReceived?.resolve(true) } - if requestCount == 2 { probeReceived?.resolve(true) } - } - if (object["operation"] as? String == "session.ping" && respondsToPing) { - let response: [String: Any] = [ - "protocol": "cmux.protocol/2", "type": "response", "id": id, - "ok": true, "result": [:], - ] - if let data = try? JSONSerialization.data(withJSONObject: response) { - _ = data.withUnsafeBytes { Darwin.write(peer, $0.baseAddress, data.count) } - var newline = UInt8(0x0A) - _ = withUnsafeBytes(of: &newline) { Darwin.write(peer, $0.baseAddress, 1) } - } - } else if object["operation"] as? String == "session.events", - let params = object["params"] as? [String: Any], - let streamID = params["stream_id"] as? String - { - let response: [String: Any] = [ - "protocol": "cmux.protocol/2", "type": "response", "id": id, - "ok": true, "result": ["stream_id": streamID], - ] - if let data = try? JSONSerialization.data(withJSONObject: response) { - _ = data.withUnsafeBytes { Darwin.write(peer, $0.baseAddress, data.count) } - var newline = UInt8(0x0A) - _ = withUnsafeBytes(of: &newline) { Darwin.write(peer, $0.baseAddress, 1) } - } - } - line.removeAll(keepingCapacity: true) - } else { - line.append(byte) - } - } - } -} diff --git a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/WorkspaceRemoteConfigurationFileTransferTests.swift b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/WorkspaceRemoteConfigurationFileTransferTests.swift deleted file mode 100644 index c9f2ca3a9dd0..000000000000 --- a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/WorkspaceRemoteConfigurationFileTransferTests.swift +++ /dev/null @@ -1,175 +0,0 @@ -import Foundation -import Testing -import CmuxCore -import CmuxFoundation - -// A workspace opened by `cmux ssh` can be handed `__cmux_route_sensitive=<digest>` -// in its options. It is cmux's own note about which shared connection the -// route may use, not an OpenSSH option, and `scp` rejects it: dropping a file -// into such a workspace failed with "Bad configuration option: -// __cmux_route_sensitive" (https://github.com/manaflow-ai/cmux/issues/20220). -// The remote file explorer, file preview, reachability probe and legacy -// uploads write the same options out, so the marker must never be in them. -@Suite("WorkspaceRemoteConfiguration keeps the route marker out of its OpenSSH options") -struct WorkspaceRemoteConfigurationFileTransferTests { - private let digest = String(repeating: "a1", count: 32) - private var routeMarker: String { "__cmux_route_sensitive=" + digest } - private let sharing = SSHConnectionSharingOptions( - userID: 501, - controlSocketDirectoryPath: "/tmp/cmux-route-marker-tests" - ) - - private func configuration( - sshOptions: [String], - identityFile: String? = nil, - agentSocketPath: String? = nil - ) -> WorkspaceRemoteConfiguration { - WorkspaceRemoteConfiguration( - destination: "dev@my-host", - port: nil, - identityFile: identityFile, - sshOptions: sshOptions, - localProxyPort: nil, - relayPort: nil, - relayID: nil, - relayToken: nil, - localSocketPath: nil, - terminalStartupCommand: nil, - agentSocketPath: agentSocketPath - ) - } - - @Test("a handed-off marker becomes the route identifier, not an option") - func markerMovesOutOfOptions() { - let configuration = configuration( - sshOptions: ["ControlMaster=auto", routeMarker, "ControlPersist=600"] - ) - - #expect(configuration.sshOptions == ["ControlMaster=auto", "ControlPersist=600"]) - #expect(configuration.routeSensitiveIdentifier == digest) - #expect(configuration.routeSensitivity == .identified(digest)) - } - - @Test("scp, ssh and sftp never see the marker", arguments: [ - "__cmux_route_sensitive=" + String(repeating: "a1", count: 32), - "__cmux_route_sensitive=true", - "__CMUX_ROUTE_SENSITIVE=true", - " __cmux_route_sensitive=true ", - "__cmux_route_sensitive true", - "__cmux_route_sensitive", - ]) - func markerNeverReachesOpenSSH(marker: String) { - let configuration = configuration( - sshOptions: ["ProxyJump=bastion", marker, "UserKnownHostsFile=/dev/null"], - agentSocketPath: "/tmp/agent.sock" - ) - - #expect(configuration.sshOptions == ["ProxyJump=bastion", "UserKnownHostsFile=/dev/null"]) - for options in [ - configuration.sshOptions, - configuration.mergingConnectionSharingDefaults(sharing), - ] { - #expect(!options.contains { $0.lowercased().contains("__cmux_route_sensitive") }) - } - } - - @Test("options that only mention the marker are kept, in order") - func otherOptionsAreKept() { - let sshOptions = [ - "IgnoreUnknown=__cmux_*", - "ProxyCommand=/bin/sh -c 'echo __cmux_route_sensitive=1'", - "SetEnv=NOTE=__cmux_route_sensitive", - "StrictHostKeyChecking=accept-new", - ] - - #expect(configuration(sshOptions: sshOptions).sshOptions == sshOptions) - #expect(configuration(sshOptions: sshOptions + [routeMarker]).sshOptions == sshOptions) - } - - @Test("the connection-sharing merge still picks the route's private master") - func connectionSharingMatchesTheMarkerInOptions() { - for identityFile in [nil, "~/.ssh/id_work"] as [String?] { - let routeSensitiveOptions = identityFile.map { ["IdentityFile=\($0)"] } ?? [] - let configuration = configuration( - sshOptions: ["ProxyJump=bastion", routeMarker], - identityFile: identityFile - ) - // The merge before this change, with the marker inside the options. - let previous = sharing.mergingDefaults( - into: ["ProxyJump=bastion", routeMarker], - routeSensitiveOptions: routeSensitiveOptions - ) - - let merged = configuration.mergingConnectionSharingDefaults( - sharing, - routeSensitiveOptions: routeSensitiveOptions - ) - #expect(merged == previous) - let controlPath = merged.first { $0.hasPrefix("ControlPath=") } - #expect(controlPath?.hasPrefix("ControlPath=/tmp/cmux-route-marker-tests/") == true) - #expect(controlPath?.hasSuffix("/%C") == false) - } - // Without a marker the route keeps the shared `%C` master. - #expect(configuration(sshOptions: []).mergingConnectionSharingDefaults(sharing) - == sharing.mergingDefaults(into: [])) - } - - @Test("a sensitive route without a digest never shares a master", arguments: [ - "__cmux_route_sensitive=true", - "__CMUX_ROUTE_SENSITIVE=true", - "__cmux_route_sensitive true", - ]) - func unidentifiedSensitiveRouteStaysUnshared(marker: String) throws { - let configuration = configuration(sshOptions: ["ProxyJump=bastion", marker]) - - #expect(configuration.routeSensitivity == .unidentified) - #expect(configuration.routeSensitiveIdentifier == nil) - #expect(configuration.sshOptions == ["ProxyJump=bastion"]) - let merged = configuration.mergingConnectionSharingDefaults(sharing) - #expect(merged == sharing.mergingDefaults(into: ["ProxyJump=bastion", marker])) - #expect(merged.contains("ControlPath=none")) - #expect(!merged.contains { $0.hasSuffix("/%C") }) - - // Saving and restoring keeps the route unshared. - let snapshot = try #require(configuration.sessionSnapshot()) - #expect(self.configuration(sshOptions: snapshot.sshOptions).routeSensitivity == .unidentified) - #expect(configuration != self.configuration(sshOptions: ["ProxyJump=bastion"])) - } - - @Test("a saved workspace keeps its route across save and restore") - func snapshotRoundTripKeepsTheRoute() throws { - let configuration = configuration(sshOptions: ["ProxyJump=bastion", routeMarker]) - - let snapshot = try #require(configuration.sessionSnapshot()) - // The saved format is unchanged, so older and newer cmux read it alike. - #expect(snapshot.sshOptions.contains(routeMarker)) - - let restored = self.configuration(sshOptions: snapshot.sshOptions) - #expect(restored.routeSensitiveIdentifier == digest) - #expect(!restored.sshOptions.contains(routeMarker)) - #expect(restored.proxyBrokerTransportKey == configuration.proxyBrokerTransportKey) - } - - @Test("copies keep the route identifier") - func copiesKeepTheRoute() { - let configuration = configuration(sshOptions: [routeMarker]) - - for copy in [ - configuration.scopedToOwnerWorkspace(UUID()), - configuration.withDaemonWebSocketEndpoint(nil), - configuration.withSSHControlMasterLeaseGeneration(UUID()), - configuration.withResolvedSSHControlPath("/tmp/cmux-route-marker-tests/abc"), - ] { - #expect(copy.routeSensitivity == .identified(digest)) - } - } - - @Test("routes that differ only by marker stay distinct") - func routesThatDifferOnlyByMarkerStayDistinct() { - let plain = configuration(sshOptions: ["ProxyJump=bastion"]) - let routed = configuration(sshOptions: ["ProxyJump=bastion", routeMarker]) - - #expect(plain != routed) - #expect(plain.proxyBrokerTransportKey != routed.proxyBrokerTransportKey) - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Package.swift b/Packages/macOS/CmuxProductAnalytics/Package.swift deleted file mode 100644 index 8c16a15afe9e..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Package.swift +++ /dev/null @@ -1,39 +0,0 @@ -// swift-tools-version: 6.0 - -import PackageDescription - -let package = Package( - name: "CmuxProductAnalytics", - platforms: [ - .macOS(.v14), - ], - products: [ - .library( - name: "CmuxProductAnalytics", - targets: ["CmuxProductAnalytics"] - ), - ], - dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), - ], - targets: [ - .target( - name: "CmuxProductAnalytics", - dependencies: ["CMUXMobileCore"], - swiftSettings: [ - .swiftLanguageMode(.v6), - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault"), - ] - ), - .testTarget( - name: "CmuxProductAnalyticsTests", - dependencies: ["CmuxProductAnalytics", "CMUXMobileCore"], - swiftSettings: [ - .swiftLanguageMode(.v6), - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault"), - ] - ), - ] -) diff --git a/Packages/macOS/CmuxProductAnalytics/README.md b/Packages/macOS/CmuxProductAnalytics/README.md deleted file mode 100644 index be119f819c5e..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# CmuxProductAnalytics - -The macOS product analytics catalog and emitter. Every product event the Mac -app sends is a case of `ProductAnalyticsEvent`; `ProductAnalytics` queues it -off the calling thread, deduplicates, derives funnel events, merges the shared -context and hands it to an `AnalyticsEmitting` sink. The app's sink forwards to -the PostHog SDK it already embeds, which owns batching and the offline queue. - -The sink is `AnalyticsEmitting` from `CMUXMobileCore`, the seam the iOS -emitter uses, so both apps share one analytics vocabulary. - -The event catalog, the questions each event answers and the insights built on -them are documented in [docs/posthog/mac-product-analytics.md](../../../docs/posthog/mac-product-analytics.md). - -## Testing - -```swift -let sink = RecordingAnalyticsSink() -let analytics = ProductAnalytics( - isEnabled: true, - sink: sink, - ledger: ProductAnalyticsLedger(defaults: UserDefaults(suiteName: "test")!), - context: ProductAnalyticsContext(appVersion: "1.0", appBuild: "1", channel: "dev", osVersion: "15.0") -) -analytics.track(.signInStarted) -await analytics.drain() -#expect(sink.events.map(\.name).contains("cmux_sign_in_started")) -``` - -`ProductAnalytics(isEnabled: false, ...)` and `ProductAnalytics.disabled` -enqueue nothing. - -```bash -swift test --package-path Packages/macOS/CmuxProductAnalytics -``` diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ActivationMilestone.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ActivationMilestone.swift deleted file mode 100644 index 78b988031732..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ActivationMilestone.swift +++ /dev/null @@ -1,19 +0,0 @@ -/// A first-time action that marks a step of activation. Each fires at most -/// once per install. -/// -/// There is no "first terminal" milestone: every launch opens a terminal, so -/// it would only duplicate ``firstLaunch``. -public enum ActivationMilestone: String, Sendable, CaseIterable { - case firstLaunch = "first_launch" - /// A workspace the user created, beyond the one every launch opens. - case firstWorkspace = "first_workspace" - case firstSplit = "first_split" - case firstBrowserPane = "first_browser_pane" - case firstAgentSession = "first_agent_session" - case firstSignIn = "first_sign_in" - case firstUpgradeClick = "first_upgrade_click" - case firstCloudEnable = "first_cloud_enable" - case firstCloudMachine = "first_cloud_machine" - case firstCloudWorkspace = "first_cloud_workspace" - case firstPaidPlan = "first_paid_plan" -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentHookPhase.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentHookPhase.swift deleted file mode 100644 index b5b33d8d29ab..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentHookPhase.swift +++ /dev/null @@ -1,9 +0,0 @@ -/// The part of an agent session lifecycle a hook reports. -public enum AgentHookPhase: Sendable, Equatable { - case sessionStart - case sessionEnd - /// Any other hook (prompt, tool use, stop). Tracks a session the app first - /// sees mid-flight, such as one that outlived a relaunch, without - /// counting it as started. - case activity -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentKind.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentKind.swift deleted file mode 100644 index 04ad719940d2..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AgentKind.swift +++ /dev/null @@ -1,24 +0,0 @@ -/// The coding agent behind an agent hook, from its hook source token. -public enum AgentKind: String, Sendable, CaseIterable { - case claudeCode = "claude_code" - case codex - case opencode - case gemini - case cursor - case pi - case other - - /// Maps a hook `source` token. Unknown tokens become ``other`` so a new - /// integration can never leak an arbitrary string. - public init(hookSource: String) { - switch hookSource.lowercased() { - case "claude", "claude-code", "claude_code", "claudecode": self = .claudeCode - case "codex": self = .codex - case "opencode", "open-code": self = .opencode - case "gemini", "gemini-cli": self = .gemini - case "cursor", "cursor-agent": self = .cursor - case "pi", "cmux-pi-extension": self = .pi - default: self = .other - } - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AnalyticsPlan.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AnalyticsPlan.swift deleted file mode 100644 index ea94d535b0c7..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AnalyticsPlan.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// The account's plan as the app knows it. -public enum AnalyticsPlan: String, Sendable, CaseIterable { - case free - case paid - /// Signed out, or the plan has not loaded. - case unknown -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AppUpdateDirection.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AppUpdateDirection.swift deleted file mode 100644 index 4933a1b4712f..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/AppUpdateDirection.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Whether a launch runs a newer or older build than the previous one. -public enum AppUpdateDirection: String, Sendable, CaseIterable { - case upgrade - case downgrade -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudButtonAction.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudButtonAction.swift deleted file mode 100644 index 73247e18e2c6..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudButtonAction.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// What a press of the sidebar footer Cloud button did. -public enum CloudButtonAction: String, Sendable, CaseIterable { - /// Cloud was on: the press revealed Cloud Machines. - case openCloud = "open_cloud" - /// Cloud was off or signed out: the press also opened the welcome card. - case introduceCloud = "introduce_cloud" -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudEnableResult.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudEnableResult.swift deleted file mode 100644 index e2b76162a7a5..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudEnableResult.swift +++ /dev/null @@ -1,10 +0,0 @@ -/// How a Cloud enable attempt settled. -public enum CloudEnableResult: String, Sendable, CaseIterable { - case success - case requiresPaidPlan = "requires_paid_plan" - case signInRequired = "sign_in_required" - case serviceUnavailable = "service_unavailable" - /// Managed policy or rollout made Cloud unavailable. - case unavailable - case cancelled -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudErrorClass.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudErrorClass.swift deleted file mode 100644 index 7d3f51ca3d01..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudErrorClass.swift +++ /dev/null @@ -1,35 +0,0 @@ -/// A coarse failure class for a Cloud request, safe to aggregate on. -public enum CloudErrorClass: String, Sendable, CaseIterable { - case planLimit = "plan_limit" - case auth - case network - case server - case client - case cancelled - - /// Classifies a client error code: a server `error` token, `http_<status>`, - /// or a transport failure kind. - public init(errorCode: String) { - if CloudPlanLimit(errorCode: errorCode) != nil { - self = .planLimit - return - } - switch errorCode { - case "not_signed_in", "session_refresh_failed", "http_401", "http_403", "vm_access_revoked", - "vm_owner_mismatch", "vm_bound_token_required": - self = .auth - case "backend_unreachable", "url_error": - self = .network - case "cancelled": - self = .cancelled - case "malformed_response", "unknown", "vm_error", "vm_cloud_service_unavailable": - self = .server - default: - if errorCode.hasPrefix("http_5") { - self = .server - } else { - self = .client - } - } - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudPlanLimit.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudPlanLimit.swift deleted file mode 100644 index d30ba06bfd05..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudPlanLimit.swift +++ /dev/null @@ -1,24 +0,0 @@ -/// Which plan limit a Cloud request ran into. -public enum CloudPlanLimit: String, Sendable, CaseIterable { - case requiresPaidPlan = "requires_paid_plan" - case activeMachines = "active_machines" - case machineMemory = "machine_memory" - case resourcePool = "resource_pool" - case resizeSize = "resize_size" - case credits - case teamBilling = "team_billing" - - /// The limit named by a server error code, or nil when it is not a plan limit. - public init?(errorCode: String) { - switch errorCode { - case "vm_requires_pro": self = .requiresPaidPlan - case "vm_active_limit_exceeded": self = .activeMachines - case "vm_memory_requires_plan": self = .machineMemory - case "vm_resource_pool_exceeded": self = .resourcePool - case "vm_resize_plan_limit": self = .resizeSize - case "vm_create_credits_insufficient": self = .credits - case "vm_billing_team_required", "vm_billing_team_not_found": self = .teamBilling - default: return nil - } - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudRequestObservation.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudRequestObservation.swift deleted file mode 100644 index fb86f52dae33..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudRequestObservation.swift +++ /dev/null @@ -1,22 +0,0 @@ -/// One finished Cloud VM API request, as the client's request telemetry saw it. -/// ``ProductAnalytics`` derives machine create, delete, attach and plan-limit -/// events from these, so no Cloud call site needs its own analytics. -public struct CloudRequestObservation: Sendable, Equatable { - /// `GET`, `POST`, `DELETE`, ... - public let method: String - /// The normalized route, machine ids replaced by `{id}` (`/api/vm/{id}/attach-endpoint`). - public let route: String - public let success: Bool - /// The machine-readable failure token, nil on success. - public let errorCode: String? - public let durationMs: Double - - /// Creates an observation. - public init(method: String, route: String, success: Bool, errorCode: String?, durationMs: Double) { - self.method = method.uppercased() - self.route = route - self.success = success - self.errorCode = errorCode - self.durationMs = durationMs - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeButton.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeButton.swift deleted file mode 100644 index 10fd925e5553..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeButton.swift +++ /dev/null @@ -1,19 +0,0 @@ -/// A button on the welcome card. -public enum CloudWelcomeButton: String, Sendable, CaseIterable { - case upgrade - case enable - case signIn = "sign_in" - case openCloud = "open_cloud" - /// Not Now, the card's own close button, or Escape. - case notNow = "not_now" - - /// The button that performs `step`. - public init(step: CloudWelcomeStep) { - switch step { - case .upgrade: self = .upgrade - case .enable: self = .enable - case .signIn: self = .signIn - case .openCloud: self = .openCloud - } - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeOutcome.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeOutcome.swift deleted file mode 100644 index 03f2fb2cf83d..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeOutcome.swift +++ /dev/null @@ -1,21 +0,0 @@ -/// How a shown welcome card ended. -public enum CloudWelcomeOutcome: String, Sendable, CaseIterable { - case upgrade - case enable - case signIn = "sign_in" - case openCloud = "open_cloud" - case notNow = "not_now" - /// The window closed with no button (Cmd-W, or the app quit). - case windowClosed = "window_closed" - - /// The outcome recorded when `button` closed the card. - public init(button: CloudWelcomeButton) { - switch button { - case .upgrade: self = .upgrade - case .enable: self = .enable - case .signIn: self = .signIn - case .openCloud: self = .openCloud - case .notNow: self = .notNow - } - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeStep.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeStep.swift deleted file mode 100644 index 324f0eb9926a..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeStep.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// The welcome card's primary action, decided by sign-in and plan. -public enum CloudWelcomeStep: String, Sendable, CaseIterable { - /// Free or signed out: Upgrade opens pricing. - case upgrade - /// Paid plan or a shared team: Enable turns Cloud on. - case enable - /// Sign in first. - case signIn = "sign_in" - /// The plan is not known yet: open the Cloud sidebar, which decides. - case openCloud = "open_cloud" -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeTrigger.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeTrigger.swift deleted file mode 100644 index 9fdc0dcff43c..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWelcomeTrigger.swift +++ /dev/null @@ -1,13 +0,0 @@ -/// What opened the "Introducing cmux Cloud" welcome card. -public enum CloudWelcomeTrigger: String, Sendable, CaseIterable { - /// The one-time release announcement on the first main window. - case launchAnnouncement = "launch_announcement" - /// New Cloud Workspace (shortcut, menu or palette) while Cloud is off or signed out. - case newCloudWorkspace = "new_cloud_workspace" - /// New Cloud Machine (shortcut, menu or palette) while Cloud is off or signed out. - case newCloudMachine = "new_cloud_machine" - /// The sidebar footer Cloud button while Cloud is off or signed out. - case sidebarCloudButton = "sidebar_cloud_button" - /// The Debug menu preview. Debug builds only. - case debugMenu = "debug_menu" -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWorkspaceMachineChoice.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWorkspaceMachineChoice.swift deleted file mode 100644 index e68747e71440..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/CloudWorkspaceMachineChoice.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// How a new Cloud workspace picked its machine. -public enum CloudWorkspaceMachineChoice: String, Sendable, CaseIterable { - /// New Cloud Workspace chose the window's last or first machine. - case resolved - /// The user picked the machine (its row, context menu, or palette entry). - case explicit -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/DurationBucket.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/DurationBucket.swift deleted file mode 100644 index c4570db68896..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/DurationBucket.swift +++ /dev/null @@ -1,31 +0,0 @@ -/// A coarse duration, so latency and dwell time aggregate without raw timings. -public enum DurationBucket: String, Sendable, CaseIterable { - case under1s = "lt_1s" - case from1to3s = "1s_3s" - case from3to10s = "3s_10s" - case from10to30s = "10s_30s" - case from30to60s = "30s_60s" - case from1to3m = "1m_3m" - case from3to10m = "3m_10m" - case from10to60m = "10m_60m" - case from1to24h = "1h_24h" - case from1to7d = "1d_7d" - case over7d = "gt_7d" - - /// The bucket containing `seconds`. Negative values count as zero. - public init(seconds: Double) { - switch max(0, seconds) { - case ..<1: self = .under1s - case ..<3: self = .from1to3s - case ..<10: self = .from3to10s - case ..<30: self = .from10to30s - case ..<60: self = .from30to60s - case ..<180: self = .from1to3m - case ..<600: self = .from3to10m - case ..<3_600: self = .from10to60m - case ..<86_400: self = .from1to24h - case ..<604_800: self = .from1to7d - default: self = .over7d - } - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalytics.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalytics.swift deleted file mode 100644 index 381947417d27..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalytics.swift +++ /dev/null @@ -1,156 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// The macOS product analytics emitter. -/// -/// Every method is synchronous, non-throwing and returns immediately: it -/// enqueues a command and an off-main actor does the work in call order, so -/// call sites never block typing, rendering or drag and drop. When disabled -/// (telemetry opt-out, managed `DisableTelemetry`, XCTest), every method is a -/// no-op and nothing is enqueued. -/// -/// The app builds one instance at its composition root with a PostHog sink. -/// Tests build their own with a recording sink and a suite `UserDefaults`: -/// -/// ```swift -/// let sink = RecordingAnalyticsSink() -/// let analytics = ProductAnalytics( -/// isEnabled: true, -/// sink: sink, -/// ledger: ProductAnalyticsLedger(defaults: UserDefaults(suiteName: #function)!), -/// context: ProductAnalyticsContext(appVersion: "1.0", appBuild: "1", channel: "dev", osVersion: "15.0") -/// ) -/// analytics.track(.signInStarted) -/// await analytics.drain() -/// ``` -public final class ProductAnalytics: Sendable { - /// Whether this instance sends anything. Frozen for its lifetime, like - /// the telemetry setting it comes from. - public let isEnabled: Bool - private let continuation: AsyncStream<ProductAnalyticsCommand>.Continuation? - - /// An instance that drops everything, for previews and tests that do not - /// observe analytics. - public static let disabled = ProductAnalytics() - - private init() { - isEnabled = false - continuation = nil - } - - /// Creates an emitter and records this launch (first launch, version change). - /// - /// - Parameters: - /// - isEnabled: False for opt-out, managed telemetry off, or tests. - /// - sink: Where events go. - /// - ledger: Persistent dedupe and attribution state. - /// - context: The shared properties at launch. - /// - isPreexistingInstall: True when the app ran before this catalog - /// existed, so its first launch is not reported now. - /// - now: The clock. Defaults to the system clock. - public init( - isEnabled: Bool, - sink: any AnalyticsEmitting, - ledger: ProductAnalyticsLedger, - context: ProductAnalyticsContext, - isPreexistingInstall: Bool = false, - now: @escaping @Sendable () -> Date = { Date() } - ) { - self.isEnabled = isEnabled - guard isEnabled else { - continuation = nil - return - } - let (stream, continuation) = AsyncStream.makeStream(of: ProductAnalyticsCommand.self) - self.continuation = continuation - let pipeline = ProductAnalyticsPipeline( - sink: sink, - ledger: ledger, - context: context, - isPreexistingInstall: isPreexistingInstall, - now: now - ) - Task.detached(priority: .utility) { - await pipeline.run(stream) - } - continuation.yield(.launch) - } - - deinit { - continuation?.finish() - } - - /// Records `event`. Impressions (`cmux_upgrade_prompt_shown` per source, - /// `cmux_cloud_button_shown`) are deduplicated per launch. - public func track(_ event: ProductAnalyticsEvent) { - continuation?.yield(.track(event)) - } - - /// Records `milestone` the first time it is reached on this install. - public func reach(_ milestone: ActivationMilestone) { - continuation?.yield(.milestone(milestone)) - } - - /// Records a use of `feature`, at most once per UTC day. - public func featureUsed(_ feature: ProductFeature) { - continuation?.yield(.feature(feature)) - } - - /// Updates one shared property for this and later events. - public func updateContext(_ change: ProductAnalyticsContextChange) { - continuation?.yield(.context(change)) - } - - /// Marks later events signed in and tells the sink the account once per - /// change. The sink may own the person identity or, like the Mac app's, - /// leave identity and team groups to the host's sign-in identity - /// observer, the only place that also resets them on sign-out. - /// - /// - Parameter userID: The account's Stack user id, the same distinct id - /// the server's billing events use. - public func identify(userID: String) { - continuation?.yield(.identify(userID: userID)) - } - - /// Records `cmux_signed_out` once and tells the sink the account is gone. - /// Call it before the sign-out runs so the event joins the signed-in person. - public func signedOut() { - continuation?.yield(.signOut) - } - - /// Reports the signed-in account's plan; fires `cmux_plan_activated` or - /// `cmux_plan_deactivated` on a change. - public func notePlan(accountID: String, isPaid: Bool) { - continuation?.yield(.plan(accountID: accountID, isPaid: isPaid)) - } - - /// Reports one agent hook. The session id is used only for dedupe and is - /// never sent. - public func noteAgentHook(source: String, sessionID: String, phase: AgentHookPhase) { - continuation?.yield(.agentHook(source: source, sessionID: sessionID, phase: phase)) - } - - /// Reports one finished Cloud VM API request. - public func noteCloudRequest(_ request: CloudRequestObservation) { - continuation?.yield(.cloudRequest(request)) - } - - /// Reports a finished Cloud machine create from any launcher. - /// - /// - Parameters: - /// - succeeded: The machine was created (even if opening it then failed). - /// - errorCode: The server's refusal code on failure, or nil when unknown. - /// - seconds: From submit to finish. - public func noteMachineCreateFinished(succeeded: Bool, errorCode: String?, seconds: Double) { - continuation?.yield(.machineCreateFinished(succeeded: succeeded, errorCode: errorCode, seconds: seconds)) - } - - /// Waits until every command enqueued before this call has been processed. - /// For tests and shutdown; never call on a hot path. - public func drain() async { - guard let continuation else { return } - await withCheckedContinuation { (resume: CheckedContinuation<Void, Never>) in - continuation.yield(.barrier(resume)) - } - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsCommand.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsCommand.swift deleted file mode 100644 index f154a2b9417f..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsCommand.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -/// Work queued from ``ProductAnalytics`` to its pipeline, in call order. -enum ProductAnalyticsCommand: Sendable { - case launch - case track(ProductAnalyticsEvent) - case milestone(ActivationMilestone) - case feature(ProductFeature) - case context(ProductAnalyticsContextChange) - case identify(userID: String) - case signOut - case plan(accountID: String, isPaid: Bool) - case agentHook(source: String, sessionID: String, phase: AgentHookPhase) - case cloudRequest(CloudRequestObservation) - case machineCreateFinished(succeeded: Bool, errorCode: String?, seconds: Double) - case barrier(CheckedContinuation<Void, Never>) -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContext.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContext.swift deleted file mode 100644 index 8e3884500208..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContext.swift +++ /dev/null @@ -1,68 +0,0 @@ -public import CMUXMobileCore - -/// The shared properties attached to every product event and registered as -/// PostHog super properties, so older events (`cmux_daily_active`, -/// `cmux_cloud_vm_request`) carry them too. -public struct ProductAnalyticsContext: Sendable, Equatable { - /// `CFBundleShortVersionString`. - public var appVersion: String - /// `CFBundleVersion`. - public var appBuild: String - /// Release channel: `stable`, `nightly`, `rc` or `dev`. - public var channel: String - /// `major.minor` of macOS. - public var osVersion: String - public var plan: AnalyticsPlan - public var isSignedIn: Bool - public var cloudEnabled: Bool - /// Open workspaces across windows, refreshed when the app becomes active. - public var workspacesOpen: Int - - /// Creates a context. Account and Cloud state start unknown and are - /// updated through ``ProductAnalytics/updateContext(_:)``. - public init( - appVersion: String, - appBuild: String, - channel: String, - osVersion: String, - plan: AnalyticsPlan = .unknown, - isSignedIn: Bool = false, - cloudEnabled: Bool = false, - workspacesOpen: Int = 0 - ) { - self.appVersion = appVersion - self.appBuild = appBuild - self.channel = channel - self.osVersion = osVersion - self.plan = plan - self.isSignedIn = isSignedIn - self.cloudEnabled = cloudEnabled - self.workspacesOpen = workspacesOpen - } - - /// The context as event properties. - public var properties: [String: AnalyticsValue] { - [ - "app_version": .string(appVersion), - "app_build": .string(appBuild), - "channel": .string(channel), - "os_version": .string(osVersion), - "account_plan": .string(plan.rawValue), - "is_signed_in": .bool(isSignedIn), - "cloud_enabled": .bool(cloudEnabled), - "workspaces_open": .string(Self.countBucket(workspacesOpen)), - ] - } - - /// `0`, `1`, `2_3`, `4_7`, `8_15` or `16_plus`. - static func countBucket(_ count: Int) -> String { - switch count { - case ..<1: "0" - case 1: "1" - case 2...3: "2_3" - case 4...7: "4_7" - case 8...15: "8_15" - default: "16_plus" - } - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContextChange.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContextChange.swift deleted file mode 100644 index ecc571ac75ef..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsContextChange.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// One field of ``ProductAnalyticsContext`` that changed. -public enum ProductAnalyticsContextChange: Sendable, Equatable { - case signedIn(Bool) - case plan(AnalyticsPlan) - case cloudEnabled(Bool) - case workspacesOpen(Int) -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsEvent.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsEvent.swift deleted file mode 100644 index 113265085760..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsEvent.swift +++ /dev/null @@ -1,223 +0,0 @@ -public import CMUXMobileCore - -/// One product analytics event from the macOS app. This enum is the event -/// catalog: every name and property the app sends is declared here, pinned by -/// `ProductAnalyticsEventCatalogTests`, and documented in -/// `docs/posthog/mac-product-analytics.md`. -/// -/// Names follow `cmux_<object>_<action>` in snake_case, matching the server's -/// `cmux_billing_*` events so client and server steps share one funnel. -/// Properties are enums, counts, booleans and buckets only: never terminal -/// content, paths, commands, prompts, hostnames, emails or raw error text. -/// -/// The shared context (``ProductAnalyticsContext``) is merged into every event -/// by ``ProductAnalytics``, so cases carry only what is specific to them. -/// -/// ```swift -/// analytics.track(.upgradePromptShown(source: "mac_sidebar_badge", plan: "pro")) -/// ``` -public enum ProductAnalyticsEvent: Sendable, Equatable { - // MARK: Upgrade funnel - - /// An upgrade entrypoint became visible. Deduplicated per source per launch. - case upgradePromptShown(source: String, plan: String) - /// An upgrade entrypoint was clicked. `source` is the `ProUpgradeSource` - /// token that also travels to checkout as `cmux_source`. - case upgradeEntrypointOpened(source: String, plan: String, destination: UpgradeDestination) - /// The user hid an upgrade badge. - case upgradeBadgeDismissed(source: String) - /// The signed-in account's plan became paid. `upgradeSource` is the last - /// upgrade click within the attribution window, or nil. - case planActivated(upgradeSource: String?, sinceUpgradeClick: DurationBucket?) - /// The signed-in account's plan went from paid to free. - case planDeactivated - - // MARK: Sidebar Cloud button - - /// The sidebar footer Cloud button became visible. Deduplicated per launch. - case cloudButtonShown - /// The sidebar footer Cloud button was pressed. - case cloudButtonClicked(action: CloudButtonAction) - - // MARK: Cloud welcome card - - /// The welcome card was shown. - case cloudWelcomeShown(trigger: CloudWelcomeTrigger, step: CloudWelcomeStep) - /// A button on the welcome card was clicked. - case cloudWelcomeButtonClicked(trigger: CloudWelcomeTrigger, step: CloudWelcomeStep, button: CloudWelcomeButton) - /// The welcome card closed, with how and after how long. - case cloudWelcomeClosed(trigger: CloudWelcomeTrigger, step: CloudWelcomeStep, outcome: CloudWelcomeOutcome, visibleFor: DurationBucket) - - // MARK: Account - - case signInStarted - case signInSucceeded - case signInFailed(reason: SignInFailureReason) - case signedOut - - // MARK: Cloud - - /// A Cloud enable attempt settled. - case cloudEnableCompleted(result: CloudEnableResult) - case cloudDisabled - /// The New Machine sheet submitted a create. `memoryGB` is nil when the - /// request sends no size (Base setup, forks). - case cloudMachineCreateStarted(memoryGB: Int?) - case cloudMachineCreateSucceeded(duration: DurationBucket) - /// `consecutiveFailures` counts create failures since the last success. - case cloudMachineCreateFailed(errorClass: CloudErrorClass, errorCode: String, consecutiveFailures: Int) - case cloudMachineDeleted - /// The attach endpoint request for a machine finished. - case cloudMachineAttached(success: Bool, latency: DurationBucket, errorClass: CloudErrorClass?) - case cloudWorkspaceCreated(machineChoice: CloudWorkspaceMachineChoice, duration: DurationBucket) - /// A Cloud request was refused by a plan limit. - case cloudPlanLimitHit(limit: CloudPlanLimit, operation: String) - - // MARK: Activation and engagement - - /// A first-time action. `isNewInstall` is false for installs that predate - /// this catalog, whose real first use was never recorded. - case activationMilestoneReached(milestone: ActivationMilestone, isNewInstall: Bool, daysSinceInstall: Int) - case agentSessionStarted(agent: AgentKind) - case agentSessionEnded(agent: AgentKind) - /// A key surface was used. Deduplicated per feature per UTC day. - case featureUsed(feature: ProductFeature) - /// This launch runs a different version than the previous launch. - case appUpdated(direction: AppUpdateDirection, fromVersion: String, toVersion: String) - - /// The PostHog event name. - public var name: String { - switch self { - case .upgradePromptShown: "cmux_upgrade_prompt_shown" - case .upgradeEntrypointOpened: "cmux_upgrade_entrypoint_opened" - case .upgradeBadgeDismissed: "cmux_upgrade_badge_dismissed" - case .planActivated: "cmux_plan_activated" - case .planDeactivated: "cmux_plan_deactivated" - case .cloudButtonShown: "cmux_cloud_button_shown" - case .cloudButtonClicked: "cmux_cloud_button_clicked" - case .cloudWelcomeShown: "cmux_cloud_welcome_shown" - case .cloudWelcomeButtonClicked: "cmux_cloud_welcome_button_clicked" - case .cloudWelcomeClosed: "cmux_cloud_welcome_closed" - case .signInStarted: "cmux_sign_in_started" - case .signInSucceeded: "cmux_sign_in_succeeded" - case .signInFailed: "cmux_sign_in_failed" - case .signedOut: "cmux_signed_out" - case .cloudEnableCompleted: "cmux_cloud_enable_completed" - case .cloudDisabled: "cmux_cloud_disabled" - case .cloudMachineCreateStarted: "cmux_cloud_machine_create_started" - case .cloudMachineCreateSucceeded: "cmux_cloud_machine_create_succeeded" - case .cloudMachineCreateFailed: "cmux_cloud_machine_create_failed" - case .cloudMachineDeleted: "cmux_cloud_machine_deleted" - case .cloudMachineAttached: "cmux_cloud_machine_attached" - case .cloudWorkspaceCreated: "cmux_cloud_workspace_created" - case .cloudPlanLimitHit: "cmux_cloud_plan_limit_hit" - case .activationMilestoneReached: "cmux_activation_milestone_reached" - case .agentSessionStarted: "cmux_agent_session_started" - case .agentSessionEnded: "cmux_agent_session_ended" - case .featureUsed: "cmux_feature_used" - case .appUpdated: "cmux_app_updated" - } - } - - /// The event-specific properties, before the shared context is merged. - public var properties: [String: AnalyticsValue] { - switch self { - case let .upgradePromptShown(source, plan): - return ["source": .string(Self.token(source)), "plan": .string(Self.token(plan))] - case let .upgradeEntrypointOpened(source, plan, destination): - // `source`, `client` and `plan` keep the keys this event had before - // the catalog existed, so existing insights keep working. - return [ - "source": .string(Self.token(source)), - "client": .string("mac"), - "plan": .string(Self.token(plan)), - "destination": .string(destination.rawValue), - ] - case let .upgradeBadgeDismissed(source): - return ["source": .string(Self.token(source))] - case let .planActivated(upgradeSource, since): - return [ - "upgrade_source": .string(upgradeSource.map(Self.token) ?? "none"), - "since_upgrade_click": .string(since?.rawValue ?? "none"), - ] - case .planDeactivated, .signInStarted, .signInSucceeded, .signedOut, .cloudDisabled, .cloudMachineDeleted, - .cloudButtonShown: - return [:] - case let .cloudButtonClicked(action): - return ["action": .string(action.rawValue)] - case let .cloudWelcomeShown(trigger, step): - return ["trigger": .string(trigger.rawValue), "next_step": .string(step.rawValue)] - case let .cloudWelcomeButtonClicked(trigger, step, button): - return [ - "trigger": .string(trigger.rawValue), - "next_step": .string(step.rawValue), - "button": .string(button.rawValue), - ] - case let .cloudWelcomeClosed(trigger, step, outcome, visibleFor): - return [ - "trigger": .string(trigger.rawValue), - "next_step": .string(step.rawValue), - "outcome": .string(outcome.rawValue), - "visible_for": .string(visibleFor.rawValue), - ] - case let .signInFailed(reason): - return ["reason": .string(reason.rawValue)] - case let .cloudEnableCompleted(result): - return ["result": .string(result.rawValue)] - case let .cloudMachineCreateStarted(memoryGB): - return memoryGB.map { ["memory_gb": .int($0)] } ?? [:] - case let .cloudMachineCreateSucceeded(duration): - return ["duration": .string(duration.rawValue)] - case let .cloudMachineCreateFailed(errorClass, errorCode, consecutiveFailures): - return [ - "error_class": .string(errorClass.rawValue), - "error_code": .string(Self.token(errorCode)), - "consecutive_failures": .int(consecutiveFailures), - ] - case let .cloudMachineAttached(success, latency, errorClass): - var properties: [String: AnalyticsValue] = [ - "success": .bool(success), - "latency": .string(latency.rawValue), - ] - if let errorClass { properties["error_class"] = .string(errorClass.rawValue) } - return properties - case let .cloudWorkspaceCreated(machineChoice, duration): - return ["machine_choice": .string(machineChoice.rawValue), "duration": .string(duration.rawValue)] - case let .cloudPlanLimitHit(limit, operation): - return ["limit": .string(limit.rawValue), "operation": .string(Self.token(operation))] - case let .activationMilestoneReached(milestone, isNewInstall, daysSinceInstall): - return [ - "milestone": .string(milestone.rawValue), - "is_new_install": .bool(isNewInstall), - "days_since_install": .int(daysSinceInstall), - ] - case let .agentSessionStarted(agent), let .agentSessionEnded(agent): - return ["agent": .string(agent.rawValue)] - case let .featureUsed(feature): - return ["feature": .string(feature.rawValue)] - case let .appUpdated(direction, fromVersion, toVersion): - return [ - "direction": .string(direction.rawValue), - "from_version": .string(Self.version(fromVersion)), - "to_version": .string(Self.version(toVersion)), - ] - } - } - - /// Lowercase `[a-z0-9_]` tokens up to 64 characters pass; anything else - /// becomes `other`, so a caller can never send free text by mistake. - static func token(_ raw: String) -> String { - let allowed = raw.utf8.allSatisfy { byte in - (byte >= 0x61 && byte <= 0x7A) || (byte >= 0x30 && byte <= 0x39) || byte == 0x5F - } - return allowed && !raw.isEmpty && raw.utf8.count <= 64 ? raw : "other" - } - - /// Dotted version strings (`0.65.2`) pass; anything else becomes `unknown`. - static func version(_ raw: String) -> String { - let allowed = raw.utf8.allSatisfy { byte in - (byte >= 0x30 && byte <= 0x39) || byte == 0x2E || (byte >= 0x61 && byte <= 0x7A) || byte == 0x2D - } - return allowed && !raw.isEmpty && raw.utf8.count <= 32 ? raw : "unknown" - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsLedger.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsLedger.swift deleted file mode 100644 index 5fb91c9af73a..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsLedger.swift +++ /dev/null @@ -1,104 +0,0 @@ -public import Foundation - -/// The analytics state that must survive relaunch: install cohort, reached -/// milestones, per-day feature dedupe, the last version, the last upgrade -/// click and the last known plan. Stored in an injected `UserDefaults` under -/// `cmux.analytics.*`. -/// -/// Confined to the ``ProductAnalytics`` pipeline actor; never read elsewhere. -// `UserDefaults` is documented thread-safe; the ledger is only touched from -// the pipeline actor, so `@unchecked Sendable` adds no shared mutation. -public struct ProductAnalyticsLedger: @unchecked Sendable { - private let defaults: UserDefaults - - /// - Parameter defaults: The store. The app passes `.standard`; tests pass a suite. - public init(defaults: UserDefaults) { - self.defaults = defaults - } - - private enum Key { - static let installFirstSeenAt = "cmux.analytics.installFirstSeenAt" - static let installIsNew = "cmux.analytics.installIsNew" - static let milestones = "cmux.analytics.milestones" - static let featureDays = "cmux.analytics.featureDays" - static let lastVersion = "cmux.analytics.lastVersion" - static let lastUpgradeSource = "cmux.analytics.lastUpgradeSource" - static let lastUpgradeAt = "cmux.analytics.lastUpgradeAt" - static let lastUpgradeAccount = "cmux.analytics.lastUpgradeAccount" - static let lastPlanAccount = "cmux.analytics.lastPlanAccount" - static let lastPlanPaid = "cmux.analytics.lastPlanPaid" - } - - /// When this install was first seen by the catalog, recording it on the - /// first call. Returns whether that first call happened now. - mutating func registerInstall(now: Date, isPreexisting: Bool) -> (firstSeenAt: Date, isNewInstall: Bool, isFirstRun: Bool) { - if let date = defaults.object(forKey: Key.installFirstSeenAt) as? Date { - return (date, defaults.bool(forKey: Key.installIsNew), false) - } - defaults.set(now, forKey: Key.installFirstSeenAt) - defaults.set(!isPreexisting, forKey: Key.installIsNew) - return (now, !isPreexisting, true) - } - - var installFirstSeenAt: Date? { defaults.object(forKey: Key.installFirstSeenAt) as? Date } - var installIsNew: Bool { defaults.bool(forKey: Key.installIsNew) } - - /// Marks `milestone` reached; true only the first time. - func claimMilestone(_ milestone: ActivationMilestone) -> Bool { - var reached = Set(defaults.stringArray(forKey: Key.milestones) ?? []) - guard reached.insert(milestone.rawValue).inserted else { return false } - defaults.set(reached.sorted(), forKey: Key.milestones) - return true - } - - /// Marks `feature` used on `day`; true only for the first use that day. - func claimFeatureDay(_ feature: ProductFeature, day: String) -> Bool { - var days = defaults.dictionary(forKey: Key.featureDays) as? [String: String] ?? [:] - guard days[feature.rawValue] != day else { return false } - days[feature.rawValue] = day - defaults.set(days, forKey: Key.featureDays) - return true - } - - /// Records `version` and returns the previous launch's version. Writes - /// only on a change: every defaults write notifies app-wide observers. - func swapLastVersion(_ version: String) -> String? { - let previous = defaults.string(forKey: Key.lastVersion) - if previous != version { defaults.set(version, forKey: Key.lastVersion) } - return previous - } - - /// Records an upgrade click. `account` is the signed-in account, or nil - /// when signed out (the click may then lead to any account's checkout). - func recordUpgradeClick(source: String, account: String?, at date: Date) { - defaults.set(source, forKey: Key.lastUpgradeSource) - defaults.set(date, forKey: Key.lastUpgradeAt) - defaults.set(account, forKey: Key.lastUpgradeAccount) - } - - /// The last unattributed click that may apply to `account`. - func upgradeClick(for account: String) -> (source: String, at: Date)? { - guard let source = defaults.string(forKey: Key.lastUpgradeSource), - let date = defaults.object(forKey: Key.lastUpgradeAt) as? Date else { return nil } - if let clickAccount = defaults.string(forKey: Key.lastUpgradeAccount), clickAccount != account { return nil } - return (source, date) - } - - /// Forgets the click once a plan activation used it. - func clearUpgradeClick() { - defaults.removeObject(forKey: Key.lastUpgradeSource) - defaults.removeObject(forKey: Key.lastUpgradeAt) - defaults.removeObject(forKey: Key.lastUpgradeAccount) - } - - /// Records the plan for `account` and returns what was known for that - /// account before, or nil when it was a different or no account. Writes - /// only on a change. - func swapPlan(account: String, isPaid: Bool) -> Bool? { - let previousAccount = defaults.string(forKey: Key.lastPlanAccount) - let previous = previousAccount == account ? defaults.object(forKey: Key.lastPlanPaid) as? Bool : nil - if previousAccount != account { defaults.set(account, forKey: Key.lastPlanAccount) } - if previous != isPaid { defaults.set(isPaid, forKey: Key.lastPlanPaid) } - return previous - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsPipeline.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsPipeline.swift deleted file mode 100644 index a034429d1caf..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductAnalyticsPipeline.swift +++ /dev/null @@ -1,304 +0,0 @@ -import CMUXMobileCore -import Foundation - -/// Processes ``ProductAnalyticsCommand``s in order, off the caller's thread: -/// deduplicates, derives funnel events, merges the shared context and hands -/// the result to the sink. -actor ProductAnalyticsPipeline { - /// How long after an upgrade click a newly paid plan is attributed to it. - static let upgradeAttributionWindow: TimeInterval = 7 * 24 * 60 * 60 - static let maximumTrackedAgentSessions = 1_024 - - private let sink: any AnalyticsEmitting - private var ledger: ProductAnalyticsLedger - private var context: ProductAnalyticsContext - private let isPreexistingInstall: Bool - private let now: @Sendable () -> Date - private var shownThisLaunch: Set<String> = [] - /// Sessions seen this launch, with whether their start was counted. - private var agentSessions: [String: (agent: AgentKind, counted: Bool)] = [:] - /// Session ids in first-seen order, for oldest-first eviction. - private var agentSessionOrder: [String] = [] - private var identifiedUserID: String? - private var consecutiveCreateFailures = 0 - - init( - sink: any AnalyticsEmitting, - ledger: ProductAnalyticsLedger, - context: ProductAnalyticsContext, - isPreexistingInstall: Bool, - now: @escaping @Sendable () -> Date - ) { - self.sink = sink - self.ledger = ledger - self.context = context - self.isPreexistingInstall = isPreexistingInstall - self.now = now - } - - func run(_ commands: AsyncStream<ProductAnalyticsCommand>) async { - for await command in commands { - handle(command) - } - } - - private func handle(_ command: ProductAnalyticsCommand) { - switch command { - case .launch: - launch() - case let .track(event): - track(event) - case let .milestone(milestone): - reach(milestone) - case let .feature(feature): - guard ledger.claimFeatureDay(feature, day: Self.utcDay(now())) else { return } - track(.featureUsed(feature: feature)) - case let .context(change): - apply(change) - case let .identify(userID): - identify(userID: userID) - case .signOut: - signOut() - case let .plan(accountID, isPaid): - notePlan(accountID: accountID, isPaid: isPaid) - case let .agentHook(source, sessionID, phase): - noteAgentHook(source: source, sessionID: sessionID, phase: phase) - case let .cloudRequest(observation): - noteCloudRequest(observation) - case let .machineCreateFinished(succeeded, errorCode, seconds): - noteMachineCreateFinished(succeeded: succeeded, errorCode: errorCode, seconds: seconds) - case let .barrier(continuation): - continuation.resume() - } - } - - // MARK: - Capture - - private func track(_ event: ProductAnalyticsEvent) { - switch event { - case let .upgradePromptShown(source, _): - guard shownThisLaunch.insert("\(event.name)|\(source)").inserted else { return } - case .cloudButtonShown: - guard shownThisLaunch.insert(event.name).inserted else { return } - default: - break - } - var properties = context.properties - properties.merge(event.properties) { _, eventValue in eventValue } - sink.capture(event.name, properties) - followUp(after: event) - } - - /// Milestones and ledger updates implied by an event, so call sites - /// report the action once. - private func followUp(after event: ProductAnalyticsEvent) { - switch event { - case let .upgradeEntrypointOpened(source, _, _): - ledger.recordUpgradeClick(source: source, account: identifiedUserID, at: now()) - reach(.firstUpgradeClick) - case .signInSucceeded: - reach(.firstSignIn) - case .cloudEnableCompleted(.success): - reach(.firstCloudEnable) - case .cloudMachineCreateSucceeded: - reach(.firstCloudMachine) - case .cloudWorkspaceCreated: - reach(.firstCloudWorkspace) - case .agentSessionStarted: - reach(.firstAgentSession) - case .planActivated: - reach(.firstPaidPlan) - case .featureUsed(.split): - reach(.firstSplit) - case .featureUsed(.browserPane): - reach(.firstBrowserPane) - default: - break - } - } - - private func reach(_ milestone: ActivationMilestone) { - guard ledger.claimMilestone(milestone) else { return } - let firstSeen = ledger.installFirstSeenAt ?? now() - let days = max(0, Int(now().timeIntervalSince(firstSeen) / 86_400)) - track(.activationMilestoneReached(milestone: milestone, isNewInstall: ledger.installIsNew, daysSinceInstall: days)) - } - - // MARK: - Launch and context - - private func launch() { - sink.setSuperProperties(context.properties) - let install = ledger.registerInstall(now: now(), isPreexisting: isPreexistingInstall) - if install.isNewInstall { - reach(.firstLaunch) - } else if install.isFirstRun { - // An install from before this catalog: its first launch already - // happened, unrecorded. Claim it silently so it never fires late. - _ = ledger.claimMilestone(.firstLaunch) - } - let previous = ledger.swapLastVersion(context.appVersion) - if let previous, previous != context.appVersion, - let direction = Self.direction(from: previous, to: context.appVersion) { - track(.appUpdated(direction: direction, fromVersion: previous, toVersion: context.appVersion)) - } - } - - private func apply(_ change: ProductAnalyticsContextChange) { - var next = context - switch change { - case let .signedIn(value): next.isSignedIn = value - case let .plan(value): next.plan = value - case let .cloudEnabled(value): next.cloudEnabled = value - case let .workspacesOpen(value): next.workspacesOpen = value - } - guard next != context else { return } - context = next - sink.setSuperProperties(context.properties) - } - - // MARK: - Identity - - private func identify(userID: String) { - if identifiedUserID != userID { - identifiedUserID = userID - sink.identify(userId: userID, alias: nil, properties: ["stack_user_id": .string(userID)]) - } - apply(.signedIn(true)) - } - - private func signOut() { - guard identifiedUserID != nil || context.isSignedIn else { return } - // Captured before the reset so the sign-out joins the signed-in person. - track(.signedOut) - identifiedUserID = nil - context.isSignedIn = false - context.plan = .unknown - sink.identify(userId: nil, alias: nil, properties: [:]) - sink.setSuperProperties(context.properties) - } - - private func notePlan(accountID: String, isPaid: Bool) { - apply(.plan(isPaid ? .paid : .free)) - let previous = ledger.swapPlan(account: accountID, isPaid: isPaid) - if isPaid, previous != true { - let click = ledger.upgradeClick(for: accountID).flatMap { click -> (source: String, at: Date)? in - now().timeIntervalSince(click.at) <= Self.upgradeAttributionWindow ? click : nil - } - // A first sighting of a paid account is an activation only right - // after an upgrade click; otherwise it is an existing subscriber. - guard previous == false || click != nil else { return } - // One click attributes one activation. - if click != nil { ledger.clearUpgradeClick() } - track(.planActivated( - upgradeSource: click?.source, - sinceUpgradeClick: click.map { DurationBucket(seconds: now().timeIntervalSince($0.at)) } - )) - } else if !isPaid, previous == true { - track(.planDeactivated) - } - } - - // MARK: - Agents - - private func noteAgentHook(source: String, sessionID: String, phase: AgentHookPhase) { - guard !sessionID.isEmpty else { return } - switch phase { - case .sessionEnd: - guard let session = agentSessions.removeValue(forKey: sessionID) else { return } - agentSessionOrder.removeAll { $0 == sessionID } - // Ends pair with counted starts, so started minus ended stays meaningful. - if session.counted { track(.agentSessionEnded(agent: session.agent)) } - case .sessionStart, .activity: - guard agentSessions[sessionID] == nil else { return } - if agentSessionOrder.count >= Self.maximumTrackedAgentSessions { - agentSessions.removeValue(forKey: agentSessionOrder.removeFirst()) - } - let agent = AgentKind(hookSource: source) - agentSessions[sessionID] = (agent, phase == .sessionStart) - agentSessionOrder.append(sessionID) - // Only a SessionStart is a new session. Activity from a session the - // app first sees mid-flight (it outlived a relaunch) is tracked so - // its end counts, but it is not started again. - if phase == .sessionStart { - track(.agentSessionStarted(agent: agent)) - } - } - } - - // MARK: - Cloud - - /// Creates are reported by ``noteMachineCreateFinished(succeeded:errorCode:seconds:)``, - /// which sees every launcher; their requests are skipped here so a create - /// counts once. - private func noteCloudRequest(_ request: CloudRequestObservation) { - let errorCode = request.errorCode ?? (request.success ? nil : "unknown") - if errorCode == "cancelled" { return } - if request.method == "POST", request.route == "/api/vm" { return } - let duration = DurationBucket(seconds: request.durationMs / 1_000) - let isMachineSubresource = request.route.hasPrefix("/api/vm/{id}") - switch (request.method, request.route) { - case ("DELETE", "/api/vm/{id}"): - if request.success { track(.cloudMachineDeleted) } - case (_, "/api/vm/{id}/attach-endpoint"): - track(.cloudMachineAttached( - success: request.success, - latency: duration, - errorClass: errorCode.map(CloudErrorClass.init(errorCode:)) - )) - default: - break - } - if let errorCode, let limit = CloudPlanLimit(errorCode: errorCode) { - let operation: String - switch (request.method, request.route) { - case (_, "/api/vm/{id}/resize"): operation = "resize" - case (_, "/api/vm/restore"): operation = "restore" - case (_, "/api/vm/{id}/fork"): operation = "fork" - default: operation = isMachineSubresource ? "machine_action" : "other" - } - track(.cloudPlanLimitHit(limit: limit, operation: operation)) - } - } - - private func noteMachineCreateFinished(succeeded: Bool, errorCode: String?, seconds: Double) { - if succeeded { - consecutiveCreateFailures = 0 - track(.cloudMachineCreateSucceeded(duration: DurationBucket(seconds: seconds))) - return - } - let code = errorCode ?? "unknown" - consecutiveCreateFailures += 1 - track(.cloudMachineCreateFailed( - errorClass: CloudErrorClass(errorCode: code), - errorCode: code, - consecutiveFailures: consecutiveCreateFailures - )) - if let limit = CloudPlanLimit(errorCode: code) { - track(.cloudPlanLimitHit(limit: limit, operation: "create")) - } - } - - // MARK: - Helpers - - static func utcDay(_ date: Date) -> String { - var calendar = Calendar(identifier: .iso8601) - calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current - let parts = calendar.dateComponents([.year, .month, .day], from: date) - return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) - } - - /// Compares dotted numeric versions; nil when either is not one. - static func direction(from previous: String, to current: String) -> AppUpdateDirection? { - let lhs = previous.split(separator: ".").map { Int($0) } - let rhs = current.split(separator: ".").map { Int($0) } - guard !lhs.isEmpty, !rhs.isEmpty, !lhs.contains(nil), !rhs.contains(nil) else { return nil } - let left = lhs.compactMap { $0 } - let right = rhs.compactMap { $0 } - for index in 0..<max(left.count, right.count) { - let a = index < left.count ? left[index] : 0 - let b = index < right.count ? right[index] : 0 - if a != b { return a < b ? .upgrade : .downgrade } - } - return nil - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductFeature.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductFeature.swift deleted file mode 100644 index fd95a4a94a21..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/ProductFeature.swift +++ /dev/null @@ -1,16 +0,0 @@ -/// A key product surface, counted at most once per day by ``ProductAnalytics/featureUsed(_:)``. -public enum ProductFeature: String, Sendable, CaseIterable { - case commandPalette = "command_palette" - case filesExplorer = "files_explorer" - case find - case vault - case feed - case dock - case cloudSidebar = "cloud_sidebar" - case customSidebar = "custom_sidebar" - /// The Cloud sidebar's Devices section. - case devices - case cmuxSSH = "cmux_ssh" - case browserPane = "browser_pane" - case split -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/SignInFailureReason.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/SignInFailureReason.swift deleted file mode 100644 index 642151de5d5a..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/SignInFailureReason.swift +++ /dev/null @@ -1,13 +0,0 @@ -/// Why a sign-in attempt failed. Never the raw error text. -public enum SignInFailureReason: String, Sendable, CaseIterable { - case offline - case network - case timedOut = "timed_out" - case server - case invalidLink = "invalid_link" - case browserUnavailable = "browser_unavailable" - case unauthorized - case rejected - case cancelled - case rateLimited = "rate_limited" -} diff --git a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/UpgradeDestination.swift b/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/UpgradeDestination.swift deleted file mode 100644 index a885331ae927..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Sources/CmuxProductAnalytics/UpgradeDestination.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// Where an upgrade click sends the user. -public enum UpgradeDestination: String, Sendable, CaseIterable { - /// The in-app pricing page, which links to checkout. - case pricingPage = "pricing_page" - /// The billing checkout route directly. - case checkout -} diff --git a/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsEventCatalogTests.swift b/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsEventCatalogTests.swift deleted file mode 100644 index 8d3d82b72de0..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsEventCatalogTests.swift +++ /dev/null @@ -1,124 +0,0 @@ -import CMUXMobileCore -import Testing - -@testable import CmuxProductAnalytics - -/// Pins every event name and property key. Renaming one breaks saved -/// insights; change this test, `docs/posthog/mac-product-analytics.md` and the insights -/// together. -@Suite("Product analytics event catalog") -struct ProductAnalyticsEventCatalogTests { - static let catalog: [(ProductAnalyticsEvent, String, Set<String>)] = [ - (.upgradePromptShown(source: "mac_sidebar_badge", plan: "pro"), "cmux_upgrade_prompt_shown", ["source", "plan"]), - (.upgradeEntrypointOpened(source: "mac_cloud_welcome", plan: "pro", destination: .pricingPage), - "cmux_upgrade_entrypoint_opened", ["source", "client", "plan", "destination"]), - (.upgradeBadgeDismissed(source: "mac_sidebar_badge"), "cmux_upgrade_badge_dismissed", ["source"]), - (.planActivated(upgradeSource: "mac_sidebar_badge", sinceUpgradeClick: .from1to3m), "cmux_plan_activated", - ["upgrade_source", "since_upgrade_click"]), - (.planDeactivated, "cmux_plan_deactivated", []), - (.cloudButtonShown, "cmux_cloud_button_shown", []), - (.cloudButtonClicked(action: .introduceCloud), "cmux_cloud_button_clicked", ["action"]), - (.cloudWelcomeShown(trigger: .launchAnnouncement, step: .upgrade), "cmux_cloud_welcome_shown", ["trigger", "next_step"]), - (.cloudWelcomeButtonClicked(trigger: .newCloudMachine, step: .enable, button: .enable), - "cmux_cloud_welcome_button_clicked", ["trigger", "next_step", "button"]), - (.cloudWelcomeClosed(trigger: .newCloudWorkspace, step: .upgrade, outcome: .notNow, visibleFor: .from3to10s), - "cmux_cloud_welcome_closed", ["trigger", "next_step", "outcome", "visible_for"]), - (.signInStarted, "cmux_sign_in_started", []), - (.signInSucceeded, "cmux_sign_in_succeeded", []), - (.signInFailed(reason: .timedOut), "cmux_sign_in_failed", ["reason"]), - (.signedOut, "cmux_signed_out", []), - (.cloudEnableCompleted(result: .success), "cmux_cloud_enable_completed", ["result"]), - (.cloudDisabled, "cmux_cloud_disabled", []), - (.cloudMachineCreateStarted(memoryGB: 8), "cmux_cloud_machine_create_started", ["memory_gb"]), - (.cloudMachineCreateStarted(memoryGB: nil), "cmux_cloud_machine_create_started", []), - (.cloudMachineCreateSucceeded(duration: .from10to30s), "cmux_cloud_machine_create_succeeded", ["duration"]), - (.cloudMachineCreateFailed(errorClass: .server, errorCode: "http_500", consecutiveFailures: 2), - "cmux_cloud_machine_create_failed", ["error_class", "error_code", "consecutive_failures"]), - (.cloudMachineDeleted, "cmux_cloud_machine_deleted", []), - (.cloudMachineAttached(success: false, latency: .from1to3s, errorClass: .network), - "cmux_cloud_machine_attached", ["success", "latency", "error_class"]), - (.cloudWorkspaceCreated(machineChoice: .resolved, duration: .from3to10s), "cmux_cloud_workspace_created", - ["machine_choice", "duration"]), - (.cloudPlanLimitHit(limit: .activeMachines, operation: "create"), "cmux_cloud_plan_limit_hit", ["limit", "operation"]), - (.activationMilestoneReached(milestone: .firstSplit, isNewInstall: true, daysSinceInstall: 0), - "cmux_activation_milestone_reached", ["milestone", "is_new_install", "days_since_install"]), - (.agentSessionStarted(agent: .codex), "cmux_agent_session_started", ["agent"]), - (.agentSessionEnded(agent: .claudeCode), "cmux_agent_session_ended", ["agent"]), - (.featureUsed(feature: .commandPalette), "cmux_feature_used", ["feature"]), - (.appUpdated(direction: .upgrade, fromVersion: "0.65.1", toVersion: "0.65.2"), "cmux_app_updated", - ["direction", "from_version", "to_version"]), - ] - - @Test("every event has its pinned name and property keys") - func pinnedNamesAndKeys() { - for (event, name, keys) in Self.catalog { - #expect(event.name == name) - #expect(Set(event.properties.keys) == keys, "\(name)") - } - } - - @Test("names are unique, snake_case and cmux_ prefixed") - func namingConvention() { - let names = Set(Self.catalog.map(\.1)) - #expect(names.count == 28) - for name in names { - #expect(name.hasPrefix("cmux_")) - #expect(name.allSatisfy { $0.isLowercase || $0 == "_" }) - } - } - - @Test("the shared context keys are pinned and never collide with event keys") - func contextKeys() { - let context = ProductAnalyticsContext(appVersion: "0.65.2", appBuild: "1", channel: "nightly", osVersion: "15.5") - #expect(Set(context.properties.keys) == [ - "app_version", "app_build", "channel", "os_version", - "account_plan", "is_signed_in", "cloud_enabled", "workspaces_open", - ]) - for (event, name, _) in Self.catalog { - #expect(Set(event.properties.keys).isDisjoint(with: context.properties.keys), "\(name)") - } - } - - @Test("free text cannot reach a token property") - func tokensRejectFreeText() { - let event = ProductAnalyticsEvent.cloudMachineCreateFailed( - errorClass: .client, - errorCode: "Machine my-host.example.com failed: /Users/me", - consecutiveFailures: 1 - ) - #expect(event.properties["error_code"] == .string("other")) - let upgrade = ProductAnalyticsEvent.upgradePromptShown(source: "user@example.com", plan: "pro") - #expect(upgrade.properties["source"] == .string("other")) - let update = ProductAnalyticsEvent.appUpdated(direction: .upgrade, fromVersion: "/tmp/x", toVersion: "0.65.2") - #expect(update.properties["from_version"] == .string("unknown")) - } - - @Test("agent sources map to a closed set") - func agentKinds() { - #expect(AgentKind(hookSource: "claude") == .claudeCode) - #expect(AgentKind(hookSource: "codex") == .codex) - #expect(AgentKind(hookSource: "opencode") == .opencode) - #expect(AgentKind(hookSource: "cmux-pi-extension") == .pi) - #expect(AgentKind(hookSource: "my secret agent") == .other) - } - - @Test("Cloud error codes classify without the raw message") - func errorClasses() { - #expect(CloudErrorClass(errorCode: "vm_active_limit_exceeded") == .planLimit) - #expect(CloudErrorClass(errorCode: "not_signed_in") == .auth) - #expect(CloudErrorClass(errorCode: "backend_unreachable") == .network) - #expect(CloudErrorClass(errorCode: "http_503") == .server) - #expect(CloudErrorClass(errorCode: "vm_not_found") == .client) - #expect(CloudPlanLimit(errorCode: "vm_memory_requires_plan") == .machineMemory) - #expect(CloudPlanLimit(errorCode: "vm_not_found") == nil) - } - - @Test("durations bucket at their boundaries") - func durationBuckets() { - #expect(DurationBucket(seconds: -1) == .under1s) - #expect(DurationBucket(seconds: 0.999) == .under1s) - #expect(DurationBucket(seconds: 1) == .from1to3s) - #expect(DurationBucket(seconds: 59) == .from30to60s) - #expect(DurationBucket(seconds: 7 * 86_400) == .over7d) - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsPipelineTests.swift b/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsPipelineTests.swift deleted file mode 100644 index 633bc49fc5f7..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/ProductAnalyticsPipelineTests.swift +++ /dev/null @@ -1,301 +0,0 @@ -import CMUXMobileCore -import Foundation -import os -import Testing - -@testable import CmuxProductAnalytics - -/// A clock tests can move. -final class TestClock: Sendable { - private let state = OSAllocatedUnfairLock(initialState: Date(timeIntervalSince1970: 1_800_000_000)) - var now: Date { state.withLock { $0 } } - func advance(_ seconds: TimeInterval) { state.withLock { $0 += seconds } } -} - -@Suite("Product analytics pipeline") -struct ProductAnalyticsPipelineTests { - /// A class so the suite it created is removed when the test drops it. - final class Harness { - let sink: RecordingAnalyticsSink - let clock: TestClock - let defaults: UserDefaults - let analytics: ProductAnalytics - private let ownedSuiteName: String? - - init( - enabled: Bool = true, - preexisting: Bool = false, - version: String = "0.66.0", - defaults: UserDefaults? = nil - ) { - let suiteName = defaults == nil ? "product-analytics-\(UUID().uuidString)" : nil - let defaults = defaults ?? UserDefaults(suiteName: suiteName)! - ownedSuiteName = suiteName - let sink = RecordingAnalyticsSink() - let clock = TestClock() - self.defaults = defaults - self.sink = sink - self.clock = clock - analytics = ProductAnalytics( - isEnabled: enabled, - sink: sink, - ledger: ProductAnalyticsLedger(defaults: defaults), - context: ProductAnalyticsContext(appVersion: version, appBuild: "100", channel: "dev", osVersion: "15.5"), - isPreexistingInstall: preexisting, - now: { clock.now } - ) - } - - deinit { - if let ownedSuiteName { defaults.removePersistentDomain(forName: ownedSuiteName) } - } - - func milestones() -> [String] { - sink.events.filter { $0.name == "cmux_activation_milestone_reached" }.compactMap { - if case let .string(value)? = $0.properties["milestone"] { return value } - return nil - } - } - } - - @Test("opt-out sends nothing at all") - func optOutStopsAllCapture() async { - let harness = Harness(enabled: false) - harness.analytics.track(.signInStarted) - harness.analytics.track(.upgradeEntrypointOpened(source: "mac_sidebar_badge", plan: "pro", destination: .pricingPage)) - harness.analytics.featureUsed(.commandPalette) - harness.analytics.identify(userID: "user-1") - harness.analytics.notePlan(accountID: "user-1", isPaid: true) - harness.analytics.noteAgentHook(source: "codex", sessionID: "s", phase: .sessionStart) - harness.analytics.signedOut() - await harness.analytics.drain() - #expect(harness.sink.calls.isEmpty) - #expect(ProductAnalytics.disabled.isEnabled == false) - } - - @Test("events carry the shared context; event keys win") - func contextMerged() async throws { - let harness = Harness() - harness.analytics.updateContext(.cloudEnabled(true)) - harness.analytics.updateContext(.plan(.free)) - harness.analytics.track(.upgradePromptShown(source: "mac_sidebar_badge", plan: "pro")) - await harness.analytics.drain() - let event = try #require(harness.sink.events.first { $0.name == "cmux_upgrade_prompt_shown" }) - #expect(event.properties["source"] == .string("mac_sidebar_badge")) - #expect(event.properties["plan"] == .string("pro")) - #expect(event.properties["account_plan"] == .string("free")) - #expect(event.properties["cloud_enabled"] == .bool(true)) - #expect(event.properties["channel"] == .string("dev")) - #expect(event.properties["app_version"] == .string("0.66.0")) - } - - @Test("a new install reports first launch once; a pre-catalog install never does") - func firstLaunch() async { - let fresh = Harness() - await fresh.analytics.drain() - #expect(fresh.milestones() == ["first_launch"]) - - let relaunch = Harness(defaults: fresh.defaults) - await relaunch.analytics.drain() - #expect(relaunch.milestones().isEmpty) - - withExtendedLifetime(fresh) {} - - let existing = Harness(preexisting: true) - existing.analytics.featureUsed(.split) - await existing.analytics.drain() - #expect(existing.milestones() == ["first_split"]) - let milestone = existing.sink.events.first { $0.name == "cmux_activation_milestone_reached" } - #expect(milestone?.properties["is_new_install"] == .bool(false)) - } - - @Test("upgrade prompts dedupe per source per launch; clicks never dedupe") - func impressionsDedupe() async { - let harness = Harness() - for _ in 0..<3 { - harness.analytics.track(.upgradePromptShown(source: "mac_sidebar_badge", plan: "pro")) - harness.analytics.track(.upgradeEntrypointOpened(source: "mac_sidebar_badge", plan: "pro", destination: .pricingPage)) - } - harness.analytics.track(.upgradePromptShown(source: "mac_cloud_welcome", plan: "pro")) - harness.analytics.track(.cloudButtonShown) - harness.analytics.track(.cloudButtonShown) - await harness.analytics.drain() - let names = harness.sink.eventNames - #expect(names.filter { $0 == "cmux_upgrade_prompt_shown" }.count == 2) - #expect(names.filter { $0 == "cmux_cloud_button_shown" }.count == 1) - #expect(names.filter { $0 == "cmux_upgrade_entrypoint_opened" }.count == 3) - #expect(harness.milestones() == ["first_launch", "first_upgrade_click"]) - } - - @Test("features count once per UTC day") - func featureDailyDedupe() async { - let harness = Harness() - harness.analytics.featureUsed(.commandPalette) - harness.analytics.featureUsed(.commandPalette) - await harness.analytics.drain() - harness.clock.advance(86_400) - harness.analytics.featureUsed(.commandPalette) - await harness.analytics.drain() - #expect(harness.sink.eventNames.filter { $0 == "cmux_feature_used" }.count == 2) - } - - @Test("a paid plan after an upgrade click is attributed to that click's source") - func planActivationAttribution() async throws { - let harness = Harness() - harness.analytics.identify(userID: "user-1") - harness.analytics.notePlan(accountID: "user-1", isPaid: false) - harness.analytics.track(.upgradeEntrypointOpened(source: "mac_cloud_welcome", plan: "pro", destination: .pricingPage)) - await harness.analytics.drain() - harness.clock.advance(120) - harness.analytics.notePlan(accountID: "user-1", isPaid: true) - harness.analytics.notePlan(accountID: "user-1", isPaid: true) - await harness.analytics.drain() - let activated = harness.sink.events.filter { $0.name == "cmux_plan_activated" } - #expect(activated.count == 1) - let event = try #require(activated.first) - #expect(event.properties["upgrade_source"] == .string("mac_cloud_welcome")) - #expect(event.properties["since_upgrade_click"] == .string("1m_3m")) - #expect(event.properties["account_plan"] == .string("paid")) - #expect(harness.milestones().contains("first_paid_plan")) - - harness.analytics.notePlan(accountID: "user-1", isPaid: false) - await harness.analytics.drain() - #expect(harness.sink.eventNames.last == "cmux_plan_deactivated") - } - - @Test("a click attributes one activation, and only for the account that clicked") - func attributionIsPerAccountAndSingleUse() async { - let harness = Harness() - harness.analytics.identify(userID: "user-a") - harness.analytics.notePlan(accountID: "user-a", isPaid: false) - harness.analytics.track(.upgradeEntrypointOpened(source: "mac_sidebar_badge", plan: "pro", destination: .pricingPage)) - // Switching to an already-paid account is not an activation from A's click. - harness.analytics.signedOut() - harness.analytics.identify(userID: "user-b") - harness.analytics.notePlan(accountID: "user-b", isPaid: true) - await harness.analytics.drain() - #expect(!harness.sink.eventNames.contains("cmux_plan_activated")) - - harness.analytics.signedOut() - harness.analytics.identify(userID: "user-a") - harness.analytics.notePlan(accountID: "user-a", isPaid: true) - harness.analytics.notePlan(accountID: "user-a", isPaid: false) - harness.analytics.notePlan(accountID: "user-a", isPaid: true) - await harness.analytics.drain() - let activations = harness.sink.events.filter { $0.name == "cmux_plan_activated" } - #expect(activations.map { $0.properties["upgrade_source"] } == [.string("mac_sidebar_badge"), .string("none")]) - } - - @Test("an existing subscriber seen for the first time is not an activation") - func existingSubscriber() async { - let harness = Harness() - harness.analytics.notePlan(accountID: "user-1", isPaid: true) - await harness.analytics.drain() - #expect(!harness.sink.eventNames.contains("cmux_plan_activated")) - } - - @Test("identify once per user, and sign out after the event") - func identityLifecycle() async { - let harness = Harness() - harness.analytics.identify(userID: "user-1") - harness.analytics.identify(userID: "user-1") - harness.analytics.signedOut() - harness.analytics.signedOut() - await harness.analytics.drain() - let identity: [String] = harness.sink.calls.compactMap { - switch $0 { - case let .identify(user): return "identify:\(user ?? "nil")" - case let .capture(name, _): return name == "cmux_signed_out" ? name : nil - case .superProperties: return nil - } - } - #expect(identity == ["identify:user-1", "cmux_signed_out", "identify:nil"]) - let signedOut = harness.sink.events.first { $0.name == "cmux_signed_out" } - #expect(signedOut?.properties["is_signed_in"] == .bool(true)) - } - - @Test("agent sessions start once, end once, and never send the session id") - func agentSessions() async { - let harness = Harness() - harness.analytics.noteAgentHook(source: "claude", sessionID: "secret-session", phase: .sessionStart) - harness.analytics.noteAgentHook(source: "claude", sessionID: "secret-session", phase: .activity) - // A session first seen mid-flight (it outlived a relaunch) is neither - // started nor ended again. - harness.analytics.noteAgentHook(source: "codex", sessionID: "other", phase: .activity) - harness.analytics.noteAgentHook(source: "codex", sessionID: "other", phase: .sessionEnd) - harness.analytics.noteAgentHook(source: "claude", sessionID: "secret-session", phase: .sessionEnd) - harness.analytics.noteAgentHook(source: "claude", sessionID: "secret-session", phase: .sessionEnd) - await harness.analytics.drain() - let names = harness.sink.eventNames - #expect(names.filter { $0 == "cmux_agent_session_started" }.count == 1) - #expect(names.filter { $0 == "cmux_agent_session_ended" }.count == 1) - #expect(harness.milestones().contains("first_agent_session")) - for event in harness.sink.events { - #expect(!event.properties.values.contains(.string("secret-session"))) - } - } - - @Test("Cloud requests become delete, attach and plan-limit events; creates count once") - func cloudRequests() async { - let harness = Harness() - let analytics = harness.analytics - // A create's own request is skipped: the create coordinator reports it. - analytics.noteCloudRequest(.init(method: "POST", route: "/api/vm", success: false, errorCode: "vm_active_limit_exceeded", durationMs: 300)) - analytics.noteCloudRequest(.init(method: "GET", route: "/api/vm/{id}/attach-endpoint", success: true, errorCode: nil, durationMs: 450)) - analytics.noteCloudRequest(.init(method: "GET", route: "/api/vm/{id}/attach-endpoint", success: false, errorCode: "backend_unreachable", durationMs: 4_000)) - analytics.noteCloudRequest(.init(method: "DELETE", route: "/api/vm/{id}", success: true, errorCode: nil, durationMs: 800)) - analytics.noteCloudRequest(.init(method: "POST", route: "/api/vm/{id}/resize", success: false, errorCode: "vm_resize_plan_limit", durationMs: 100)) - analytics.noteCloudRequest(.init(method: "GET", route: "/api/vm", success: false, errorCode: "cancelled", durationMs: 1)) - await analytics.drain() - let events = harness.sink.events.filter { $0.name != "cmux_activation_milestone_reached" } - #expect(events.map(\.name) == [ - "cmux_cloud_machine_attached", - "cmux_cloud_machine_attached", - "cmux_cloud_machine_deleted", - "cmux_cloud_plan_limit_hit", - ]) - #expect(events[0].properties["latency"] == .string("lt_1s")) - #expect(events[1].properties["success"] == .bool(false)) - #expect(events[1].properties["error_class"] == .string("network")) - #expect(events[3].properties["limit"] == .string("resize_size")) - #expect(events[3].properties["operation"] == .string("resize")) - } - - @Test("machine creates report outcome, consecutive failures and the plan limit") - func machineCreates() async { - let harness = Harness() - let analytics = harness.analytics - analytics.noteMachineCreateFinished(succeeded: false, errorCode: nil, seconds: 2) - analytics.noteMachineCreateFinished(succeeded: false, errorCode: "vm_active_limit_exceeded", seconds: 0.3) - analytics.noteMachineCreateFinished(succeeded: true, errorCode: nil, seconds: 14) - await analytics.drain() - let events = harness.sink.events.filter { $0.name != "cmux_activation_milestone_reached" } - #expect(events.map(\.name) == [ - "cmux_cloud_machine_create_failed", - "cmux_cloud_machine_create_failed", - "cmux_cloud_plan_limit_hit", - "cmux_cloud_machine_create_succeeded", - ]) - #expect(events[0].properties["error_class"] == .string("server")) - #expect(events[1].properties["consecutive_failures"] == .int(2)) - #expect(events[1].properties["error_class"] == .string("plan_limit")) - #expect(events[2].properties["limit"] == .string("active_machines")) - #expect(events[2].properties["operation"] == .string("create")) - #expect(events[3].properties["duration"] == .string("10s_30s")) - #expect(harness.milestones().contains("first_cloud_machine")) - } - - @Test("a version change reports its direction") - func appUpdated() async { - let first = Harness(version: "0.65.2") - await first.analytics.drain() - let upgraded = Harness(version: "0.66.0", defaults: first.defaults) - await upgraded.analytics.drain() - let downgraded = Harness(version: "0.65.2", defaults: first.defaults) - await downgraded.analytics.drain() - #expect(upgraded.sink.events.first { $0.name == "cmux_app_updated" }?.properties["direction"] == .string("upgrade")) - #expect(downgraded.sink.events.first { $0.name == "cmux_app_updated" }?.properties["direction"] == .string("downgrade")) - #expect(!first.sink.eventNames.contains("cmux_app_updated")) - withExtendedLifetime(first) {} - } -} diff --git a/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/RecordingAnalyticsSink.swift b/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/RecordingAnalyticsSink.swift deleted file mode 100644 index 5ee5a4d68295..000000000000 --- a/Packages/macOS/CmuxProductAnalytics/Tests/CmuxProductAnalyticsTests/RecordingAnalyticsSink.swift +++ /dev/null @@ -1,40 +0,0 @@ -import CMUXMobileCore -import Foundation -import os - -/// Records every sink call in order. -final class RecordingAnalyticsSink: AnalyticsEmitting { - enum Call: Equatable { - case capture(String, [String: AnalyticsValue]) - case identify(String?) - case superProperties([String: AnalyticsValue]) - } - - // Sink calls arrive from the pipeline actor while tests read on their own task. - private let state = OSAllocatedUnfairLock<[Call]>(initialState: []) - - var calls: [Call] { state.withLock { $0 } } - - var events: [(name: String, properties: [String: AnalyticsValue])] { - calls.compactMap { call in - if case let .capture(name, properties) = call { return (name: name, properties: properties) } - return nil - } - } - - var eventNames: [String] { events.map(\.name) } - - func capture(_ event: String, _ properties: [String: AnalyticsValue]) { - state.withLock { $0.append(.capture(event, properties)) } - } - - func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) { - state.withLock { $0.append(.identify(userId)) } - } - - func setSuperProperties(_ properties: [String: AnalyticsValue]) { - state.withLock { $0.append(.superProperties(properties)) } - } - - func flush() async {} -} diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ProUpgradeCardModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ProUpgradeCardModelTests.swift deleted file mode 100644 index 2cbad0094492..000000000000 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ProUpgradeCardModelTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import CMUXMobileCore -import Testing -@testable import CmuxSettingsUI - -/// The Settings plan row offers the next tier and never asks a Max -/// subscriber to upgrade to Pro. -@Suite struct ProUpgradeCardModelTests { - @Test func freeAndGoAreOfferedPro() { - for tier in [CmuxPlanTier.free, .go] { - let model = ProUpgradeCardModel(tier: tier, isProActive: tier == .go, canManageBilling: false, isSignedIn: true) - #expect(model.actions == [.upgradeToPro], "\(tier)") - } - } - - @Test func managedGoKeepsBillingBesideTheProOffer() { - let model = ProUpgradeCardModel(tier: .go, isProActive: true, canManageBilling: true, isSignedIn: true) - #expect(model.actions == [.upgradeToPro, .manageBilling]) - } - - @Test func proIsOfferedMaxAndBilling() { - let model = ProUpgradeCardModel(tier: .pro, isProActive: true, canManageBilling: true, isSignedIn: true) - #expect(model.actions == [.upgradeToMax, .manageBilling]) - #expect(!model.actions.contains(.upgradeToPro)) - } - - @Test func maxIsOfferedNoUpgrade() { - let managed = ProUpgradeCardModel(tier: .max, isProActive: true, canManageBilling: true, isSignedIn: true) - #expect(managed.actions == [.manageBilling]) - #expect(managed.title == String(localized: "settings.account.max.title", defaultValue: "cmux Max")) - - let external = ProUpgradeCardModel(tier: .max, isProActive: true, canManageBilling: false, isSignedIn: true) - #expect(external.actions.isEmpty) - } - - @Test func unknownPlanForASignedInAccountOffersNothing() { - let model = ProUpgradeCardModel(tier: nil, isProActive: false, canManageBilling: false, isSignedIn: true) - #expect(model.actions.isEmpty) - } - - @Test func signedOutIsOfferedPro() { - let model = ProUpgradeCardModel(tier: nil, isProActive: false, canManageBilling: false, isSignedIn: false) - #expect(model.actions == [.upgradeToPro]) - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/ProgramStatusAgentSettlement.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/ProgramStatusAgentSettlement.swift deleted file mode 100644 index e5ffd31373da..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Status/ProgramStatusAgentSettlement.swift +++ /dev/null @@ -1,52 +0,0 @@ -/// Decides when an agent's own OSC 7501 report proves its turn ended. -/// -/// Agent hooks write the sidebar's "Running" row, and a turn that ends without -/// its Stop hook leaves that row stuck: Claude Code runs no Stop hook when the -/// user interrupts a turn with Esc, and a Stop hook that times out or fails is -/// never delivered. Claude Code also reports its own activity as an OSC 7501 -/// `working` record. When that record leaves the working and blocked states, -/// the agent itself says the turn is over, so a pane still marked running can -/// settle without guessing from timers or output. -public struct ProgramStatusAgentSettlement: Sendable { - /// OSC 7501 `app` names that map to an agent status key. - private static let statusKeysByApp: [String: String] = [ - "claude-code": "claude_code", - ] - - public init() {} - - /// The agent status key an OSC 7501 app reports for, if any. - public func statusKey(forApp app: String?) -> String? { - guard let app else { return nil } - return Self.statusKeysByApp[app.lowercased()] - } - - /// Agent status keys whose app had a working or blocked record before a - /// change and has none after it. - /// - /// - Parameters: - /// - previous: The panel's records before the change. - /// - current: The panel's records after the change. - /// - Returns: The keys whose agent reported the end of its activity. - public func endedActivityStatusKeys( - previous: ProgramStatusRecordStore, - current: ProgramStatusRecordStore - ) -> Set<String> { - activeStatusKeys(in: previous).subtracting(activeStatusKeys(in: current)) - } - - /// Whether the panel still has a working or blocked record for the key. - public func isActive(statusKey: String, in store: ProgramStatusRecordStore) -> Bool { - activeStatusKeys(in: store).contains(statusKey) - } - - private func activeStatusKeys(in store: ProgramStatusRecordStore) -> Set<String> { - var keys = Set<String>() - for record in store.records where record.state == .working || record.state == .blocked { - if let key = statusKey(forApp: store.effectiveApp(for: record)) { - keys.insert(key) - } - } - return keys - } -} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/ProgramStatusAgentSettlementTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/ProgramStatusAgentSettlementTests.swift deleted file mode 100644 index 72ede99274a8..000000000000 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/ProgramStatusAgentSettlementTests.swift +++ /dev/null @@ -1,69 +0,0 @@ -import CmuxSidebar -import Testing - -@Suite("OSC 7501 agent turn settlement") -struct ProgramStatusAgentSettlementTests { - private let settlement = ProgramStatusAgentSettlement() - - private func store(_ reports: [ProgramStatusReport]) -> ProgramStatusRecordStore { - var store = ProgramStatusRecordStore() - for report in reports { store.apply(report) } - return store - } - - @Test func interruptClearEndsClaudeActivity() { - // Esc: Claude Code clears its working record and runs no Stop hook. - let working = store([ProgramStatusReport(state: .working, app: "claude-code")]) - var cleared = working - cleared.apply(ProgramStatusReport(state: .clear)) - #expect(settlement.endedActivityStatusKeys(previous: working, current: cleared) == ["claude_code"]) - #expect(!settlement.isActive(statusKey: "claude_code", in: cleared)) - } - - @Test func doneAndIdleReportsEndClaudeActivity() { - let working = store([ProgramStatusReport(state: .working, app: "claude-code")]) - for state in [ProgramStatusState.done, .idle, .error] { - var ended = working - ended.apply(ProgramStatusReport(state: state, app: "claude-code")) - #expect(settlement.endedActivityStatusKeys(previous: working, current: ended) == ["claude_code"]) - } - } - - @Test func blockedStaysActive() { - // A permission prompt is not the end of the turn. - let working = store([ProgramStatusReport(state: .working, app: "claude-code")]) - var blocked = working - blocked.apply(ProgramStatusReport(state: .blocked, kind: .permission, app: "claude-code")) - #expect(settlement.endedActivityStatusKeys(previous: working, current: blocked).isEmpty) - #expect(settlement.isActive(statusKey: "claude_code", in: blocked)) - } - - @Test func childExitDropEndsActivity() { - var store = store([ProgramStatusReport(state: .working, app: "claude-code")]) - let previous = store - store.dropTransient() - #expect(settlement.endedActivityStatusKeys(previous: previous, current: store) == ["claude_code"]) - } - - @Test func unknownAppsAndNewTurnsDoNotSettle() { - let other = store([ProgramStatusReport(state: .working, app: "cargo")]) - var otherDone = other - otherDone.apply(ProgramStatusReport(state: .done, app: "cargo")) - #expect(settlement.endedActivityStatusKeys(previous: other, current: otherDone).isEmpty) - - // A pane that was idle and starts working has not ended anything. - let idle = store([ProgramStatusReport(state: .done, app: "claude-code")]) - var working = idle - working.apply(ProgramStatusReport(state: .working, app: "claude-code")) - #expect(settlement.endedActivityStatusKeys(previous: idle, current: working).isEmpty) - #expect(settlement.isActive(statusKey: "claude_code", in: working)) - } - - @Test func inheritedAppNameCounts() { - let working = store([ - ProgramStatusReport(state: .done, id: "", app: "Claude-Code"), - ProgramStatusReport(state: .working, id: "turn"), - ]) - #expect(settlement.isActive(statusKey: "claude_code", in: working)) - } -} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessEvidence.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessEvidence.swift deleted file mode 100644 index 4fbeb3d9ddea..000000000000 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessEvidence.swift +++ /dev/null @@ -1,42 +0,0 @@ -public import CmuxFoundation - -/// Process observations supplied to ``RestorableAgentProcessMatcher``. -public struct RestorableAgentProcessEvidence: Sendable { - /// The process ID from the restorable session record. - public let processID: Int - /// The process generation saved with the record, when available. - public let recordedIdentity: AgentPIDProcessIdentity? - /// The process generation observed during restore, when available. - public let currentIdentity: AgentPIDProcessIdentity? - /// Whether the process ID is occupied, updated when a presence probe becomes necessary. - public var presence: RestorableAgentProcessPresence - /// Whether argv/environment identify the recorded cmux surface, when inspectable. - public let scopeMatches: Bool? - /// Whether argv/environment identify the expected agent session, when inspectable. - public let expectedProcessMatches: Bool? - - /// Creates process evidence for one recorded process ID. - /// - /// - Parameters: - /// - processID: The PID saved in the session record. - /// - recordedIdentity: The saved generation, or `nil` for a legacy record. - /// - currentIdentity: The observed generation, or `nil` if unreadable. - /// - presence: Whether the PID is currently occupied. - /// - scopeMatches: Whether the process belongs to the saved surface, or `nil` if unreadable. - /// - expectedProcessMatches: Whether the agent and session match, or `nil` if unreadable. - public init( - processID: Int, - recordedIdentity: AgentPIDProcessIdentity?, - currentIdentity: AgentPIDProcessIdentity?, - presence: RestorableAgentProcessPresence, - scopeMatches: Bool?, - expectedProcessMatches: Bool? - ) { - self.processID = processID - self.recordedIdentity = recordedIdentity - self.currentIdentity = currentIdentity - self.presence = presence - self.scopeMatches = scopeMatches - self.expectedProcessMatches = expectedProcessMatches - } -} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessMatcher.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessMatcher.swift deleted file mode 100644 index 8446a1a457d6..000000000000 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessMatcher.swift +++ /dev/null @@ -1,40 +0,0 @@ -/// Classifies process evidence used by automatic agent-session restore. -public struct RestorableAgentProcessMatcher: Sendable { - /// Creates a stateless process matcher. - public init() {} - - /// Returns whether the evidence authenticates the recorded process generation. - /// - /// A legacy record without a saved generation never authenticates a matching - /// process, but contradictory argv or scope evidence still proves that a reused - /// PID cannot block restoration. Confirmed absence overrides any identity - /// captured before the process exited. - /// - /// - Parameter evidence: Process observations for the saved PID. - /// - Returns: A match only for an authenticated generation, a mismatch for - /// definitive contradictory evidence, or unknown when observations are inconclusive. - public func match(_ evidence: RestorableAgentProcessEvidence) -> RestorableAgentProcessMatch { - guard evidence.presence != .absent else { return .mismatches } - - if let recordedIdentity = evidence.recordedIdentity { - guard Int(recordedIdentity.pid) == evidence.processID else { - return .unknown - } - guard let currentIdentity = evidence.currentIdentity, - Int(currentIdentity.pid) == evidence.processID else { - return .unknown - } - guard currentIdentity == recordedIdentity else { - return .mismatches - } - } - - if evidence.scopeMatches == false || evidence.expectedProcessMatches == false { - return .mismatches - } - guard evidence.scopeMatches != nil, evidence.expectedProcessMatches != nil else { - return .unknown - } - return evidence.recordedIdentity == nil ? .unknown : .matches - } -} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessPresence.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessPresence.swift deleted file mode 100644 index d7c384c3522b..000000000000 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestorableAgentProcessPresence.swift +++ /dev/null @@ -1,10 +0,0 @@ -/// Tri-state evidence about whether a recorded process ID is still occupied. -public enum RestorableAgentProcessPresence: Equatable, Hashable, Sendable { - /// The process ID currently exists. - case present - /// The process ID is confirmed absent. - case absent - /// The process table could not establish whether the ID exists. - case unknown -} - diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestorableAgentProcessMatcherTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestorableAgentProcessMatcherTests.swift deleted file mode 100644 index b8e874a9cf43..000000000000 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestorableAgentProcessMatcherTests.swift +++ /dev/null @@ -1,117 +0,0 @@ -import CmuxFoundation -import Testing -@testable import CmuxWorkspaces - -@Suite("RestorableAgentProcessMatcher") -struct RestorableAgentProcessMatcherTests { - private let matcher = RestorableAgentProcessMatcher() - - @Test("A reused legacy PID with contradictory process evidence mismatches", arguments: [false, true]) - func reusedLegacyPIDMismatches(hasCurrentIdentity: Bool) { - let result = matcher.match(.init( - processID: 42, - recordedIdentity: nil, - currentIdentity: hasCurrentIdentity ? identity() : nil, - presence: .present, - scopeMatches: true, - expectedProcessMatches: false - )) - #expect(result == .mismatches) - } - - @Test("A different surface proves a legacy PID is reused even without agent details") - func reusedLegacyPIDInAnotherScopeMismatches() { - #expect(matcher.match(.init( - processID: 42, - recordedIdentity: nil, - currentIdentity: nil, - presence: .present, - scopeMatches: false, - expectedProcessMatches: nil - )) == .mismatches) - } - - @Test("A matching legacy PID remains unknown without a saved generation", arguments: [false, true]) - func matchingLegacyPIDRemainsUnknown(hasCurrentIdentity: Bool) { - let result = matcher.match(.init( - processID: 42, - recordedIdentity: nil, - currentIdentity: hasCurrentIdentity ? identity() : nil, - presence: .present, - scopeMatches: true, - expectedProcessMatches: true - )) - #expect(result == .unknown) - } - - @Test("A matching saved process generation authenticates") - func matchingSavedGenerationAuthenticates() { - let identity = identity() - let result = matcher.match(.init( - processID: 42, - recordedIdentity: identity, - currentIdentity: identity, - presence: .present, - scopeMatches: true, - expectedProcessMatches: true - )) - #expect(result == .matches) - } - - @Test("A changed process generation proves PID reuse even when argv is unreadable") - func changedGenerationMismatches() { - #expect(matcher.match(.init( - processID: 42, - recordedIdentity: identity(), - currentIdentity: identity(startSeconds: 20), - presence: .present, - scopeMatches: nil, - expectedProcessMatches: nil - )) == .mismatches) - } - - @Test("Confirmed absence overrides a previously captured matching process", - arguments: [false, true]) - func absentProcessOverridesCapturedIdentity(hasRecordedIdentity: Bool) { - #expect(matcher.match(.init( - processID: 42, - recordedIdentity: hasRecordedIdentity ? identity() : nil, - currentIdentity: identity(), - presence: .absent, - scopeMatches: true, - expectedProcessMatches: true - )) == .mismatches) - } - - @Test("Unreadable processes remain uncertain unless their absence is definitive", - arguments: [RestorableAgentProcessPresence.present, .absent, .unknown], [false, true]) - func unreadableProcessRequiresDefinitiveAbsence( - presence: RestorableAgentProcessPresence, - hasRecordedIdentity: Bool - ) { - #expect(matcher.match(.init( - processID: 42, - recordedIdentity: hasRecordedIdentity ? identity() : nil, - currentIdentity: nil, - presence: presence, - scopeMatches: nil, - expectedProcessMatches: nil - )) == (presence == .absent ? .mismatches : .unknown)) - } - - @Test("A saved generation cannot authenticate without a matching current identity") - func missingCurrentGenerationStaysUnknown() { - #expect(matcher.match(.init( - processID: 42, - recordedIdentity: identity(), - currentIdentity: nil, - presence: .present, - scopeMatches: true, - expectedProcessMatches: true - )) == .unknown) - } - - private func identity(startSeconds: Int64 = 10) -> AgentPIDProcessIdentity { - AgentPIDProcessIdentity(pid: 42, startSeconds: startSeconds, startMicroseconds: 2) - } -} diff --git a/Sources/AccountAnalyticsState.swift b/Sources/AccountAnalyticsState.swift deleted file mode 100644 index 7363c4342291..000000000000 --- a/Sources/AccountAnalyticsState.swift +++ /dev/null @@ -1,69 +0,0 @@ -import CmuxProductAnalytics - -/// Turns account observations into analytics actions: identify on sign-in, -/// a success or failure for each sign-in the user started, and sign-out. Pure, so the transitions are unit tested without auth. -struct AccountAnalyticsState: Equatable { - enum Action: Equatable { - case identify(userID: String) - case signInSucceeded - case signInFailed(SignInFailureReason) - case signedOut - } - - private(set) var userID: String? - /// A user-started sign-in has no result yet. Session restore never sets it. - private(set) var isSignInPending = false - private var reportedFailure: SignInFailureReason? - - mutating func signInStarted() { - isSignInPending = true - reportedFailure = nil - } - - /// - Parameters: - /// - userID: The signed-in Stack user id, or nil. - /// - isSettling: A restore or sign-in is still running, so nil is not yet signed out. - /// - failure: The browser sign-in's latest failure. - mutating func observe( - userID newUserID: String?, - isSettling: Bool, - failure: SignInFailureReason? - ) -> [Action] { - var actions: [Action] = [] - if let newUserID { - if newUserID != userID { - actions.append(.identify(userID: newUserID)) - } - if userID != newUserID, isSignInPending { - actions.append(.signInSucceeded) - isSignInPending = false - } - userID = newUserID - } else if !isSettling, userID != nil { - actions.append(.signedOut) - userID = nil - } - if isSignInPending, let failure, failure != reportedFailure { - actions.append(.signInFailed(failure)) - reportedFailure = failure - isSignInPending = false - } - return actions - } - - static func reason(_ failure: AccountSignInModel.Failure?) -> SignInFailureReason? { - guard let failure else { return nil } - return switch failure { - case .cancelled: .cancelled - case .offline: .offline - case .network, .unknown: .network - case .timedOut: .timedOut - case .server: .server - case .invalidLink: .invalidLink - case .browserUnavailable: .browserUnavailable - case .unauthorized: .unauthorized - case .rejected: .rejected - case .rateLimited: .rateLimited - } - } -} diff --git a/Sources/AppDelegate+ProductAnalytics.swift b/Sources/AppDelegate+ProductAnalytics.swift deleted file mode 100644 index 1a9901265910..000000000000 --- a/Sources/AppDelegate+ProductAnalytics.swift +++ /dev/null @@ -1,42 +0,0 @@ -import AppKit -import CMUXAgentLaunch -import CmuxProductAnalytics - -extension AppDelegate { - /// Starts product analytics for the launch: records the launch itself and - /// counts agent sessions from accepted hook events. No-op when telemetry - /// is off, because ``ProductAnalytics/app`` is then disabled. - func startProductAnalytics() { - let analytics = ProductAnalytics.app - guard analytics.isEnabled else { return } - refreshProductAnalyticsActivityContext() - Task { @MainActor in - for await notification in NotificationCenter.default.notifications(named: .workstreamEventReceived) { - guard let event = notification.object as? WorkstreamEvent else { continue } - analytics.noteAgentHook( - source: event.source, - sessionID: event.sessionId, - phase: Self.agentHookPhase(event.hookEventName) - ) - } - } - } - - /// Refreshes the open-workspace count carried by later events. Runs on - /// activation (before the daily active event) and whenever a window's - /// workspace count changes. - func refreshProductAnalyticsActivityContext() { - let analytics = ProductAnalytics.app - guard analytics.isEnabled else { return } - let count = mainWindowContexts.values.reduce(0) { $0 + $1.tabManager.tabs.count } - analytics.updateContext(.workspacesOpen(count)) - } - - nonisolated static func agentHookPhase(_ name: WorkstreamEvent.HookEventName) -> AgentHookPhase { - switch name { - case .sessionStart: .sessionStart - case .sessionEnd: .sessionEnd - default: .activity - } - } -} diff --git a/Sources/Auth/PostHogIdentityObserver.swift b/Sources/Auth/PostHogIdentityObserver.swift deleted file mode 100644 index c8072dd987d9..000000000000 --- a/Sources/Auth/PostHogIdentityObserver.swift +++ /dev/null @@ -1,50 +0,0 @@ -import CmuxAuthRuntime -import Foundation - -/// Keeps the usage analytics identity in step with the Cloud session. -/// -/// A signed-in session (fresh sign-in or a restored session at launch) -/// identifies analytics as the sign-in service user id, so the install's -/// anonymous events join the same person the web server records. Sign-out -/// resets analytics to a fresh anonymous id. The auth coordinator is the -/// source of truth; this observer only forwards account changes. -@MainActor -final class PostHogIdentityObserver { - private let auth: AuthCoordinator - private let analytics: PostHogAnalytics - private var observationTask: Task<Void, Never>? - - init(auth: AuthCoordinator, analytics: PostHogAnalytics = .shared) { - self.auth = auth - self.analytics = analytics - } - - func start() { - observationTask?.cancel() - // Capture the collaborators, not `self`, so `deinit` can cancel the - // otherwise endless stream loop. - let auth = auth - let analytics = analytics - observationTask = Task { @MainActor in - // Before restore finishes the coordinator can report a transient - // signed-out state; acting on it would reset a valid identity. - await auth.awaitBootstrapped() - var previousAccountID: String?? - for await identity in auth.authenticatedSessionIdentities() { - guard !Task.isCancelled else { return } - let accountID = identity?.accountID - guard accountID != previousAccountID else { continue } - previousAccountID = .some(accountID) - if let accountID { - analytics.identify(accountID: accountID) - } else { - analytics.resetIdentity() - } - } - } - } - - deinit { - observationTask?.cancel() - } -} diff --git a/Sources/Cloud/CloudVMSyncSubscriber.swift b/Sources/Cloud/CloudVMSyncSubscriber.swift deleted file mode 100644 index 463fe8e2b458..000000000000 --- a/Sources/Cloud/CloudVMSyncSubscriber.swift +++ /dev/null @@ -1,238 +0,0 @@ -import CmuxAuthRuntime -import CmuxCloud -import Foundation -import OSLog - -nonisolated private let cloudVMSyncLog = Logger(subsystem: "dev.cmux", category: "cloud-vm-sync") - -/// Keeps one machines panel subscribed to its team's `vms` sync collection on -/// the presence worker (`workers/presence/src/syncVms.ts`), so a machine -/// created, renamed, resumed or destroyed anywhere lands in the list within a -/// round trip instead of on the next REST poll. -/// -/// Owns its own presence socket: ``DeviceDirectory`` exists only while device -/// discovery is on, and the Cloud list must stay live without it. The hello -/// asks for `vms` from the last cursor this instance applied (0/0 on the first -/// connect and after every scope change), so a reconnect after the server's -/// 15-minute deadline catches up with deltas. Every socket presents the same -/// team header ``VMClient`` sends (`X-Cmux-Team-Id` = `auth.resolvedTeamID`, -/// none for a personal account without teams), and its token source fails -/// closed once the account or team changes, at which point the owner calls -/// ``start()`` again and the subscriber resubscribes under the new scope. -/// -/// Reconnect and backoff mirror ``DeviceDirectory``: `[1, 2, 5, 10, 30]` s -/// after a failure, at once after a clean close. Cloud availability and the -/// panel's lifecycle are the owner's decision (``MachinesPanelViewModel`` -/// starts this with polling and stops it with polling). -@MainActor -final class CloudVMSyncSubscriber { - /// What the owner receives, on the main actor, in stream order. - enum Event: Equatable { - /// A socket delivered its first frame; the owner re-reads the REST - /// list because changes during the gap are not replayed in full. - case connected - /// A complete `vms` snapshot. `backfilled` false means the page set is - /// partial and must never delete a row. - case snapshot(records: [VMSyncRecord], backfilled: Bool) - case delta(records: [VMSyncRecord]) - /// The socket ended or failed after `.connected`. - case disconnected - } - - enum State: Equatable, Sendable { - case stopped - case connecting - case live - case retrying(attempt: Int) - } - - /// The account and team one socket is bound to. - struct Scope: Equatable, Sendable { - let identity: AuthenticatedSessionIdentity - let teamID: String? - } - - typealias SubscriberFactory = @Sendable ( - URL, - [DevicePresenceFrame.SyncCollection], - @escaping @Sendable () async throws -> DevicePresenceSubscriber.Credentials? - ) -> DevicePresenceSubscriber - - /// The production subscriber. The machine list lives on the team's - /// presence object, which the worker publishes into. - nonisolated static let defaultSubscriber: SubscriberFactory = { url, collections, credentials in - DevicePresenceSubscriber(serviceBaseURL: url, stream: .team, collections: collections, credentials: credentials) - } - - nonisolated static let reconnectDelays: [Duration] = DeviceDirectory.reconnectDelays - - private(set) var state: State = .stopped - private(set) var scope: Scope? - /// The reconnect cursor: the `vms` head this instance last applied. - private(set) var cursor = 0 - private(set) var epoch = 0 - - private let auth: AuthCoordinator - private let serviceURL: @MainActor @Sendable () -> URL? - private let makeSubscriber: SubscriberFactory - private let clock: any Clock<Duration> - private var onEvent: @MainActor (Event) -> Void = { _ in } - private var loopTask: Task<Void, Never>? - private var pendingSnapshot: [VMSyncRecord] = [] - - init( - auth: AuthCoordinator, - serviceURL: @escaping @MainActor @Sendable () -> URL? = { PresenceHeartbeatClient.resolvedServiceURL() }, - makeSubscriber: @escaping SubscriberFactory = CloudVMSyncSubscriber.defaultSubscriber, - clock: any Clock<Duration> = ContinuousClock() - ) { - self.auth = auth - self.serviceURL = serviceURL - self.makeSubscriber = makeSubscriber - self.clock = clock - } - - deinit { - // The loop holds this instance weakly, so an owner that is released - // without stop() still ends the socket here. - loopTask?.cancel() - } - - /// Installs the owner's event handler; events are delivered on the main actor. - func setEventHandler(_ handler: @escaping @MainActor (Event) -> Void) { - onEvent = handler - } - - var isRunning: Bool { loopTask != nil } - - /// The scope a socket opened now would bind to; nil while signed out. - func currentScope() -> Scope? { - guard let identity = auth.authenticatedSessionIdentity else { return nil } - return Scope(identity: identity, teamID: auth.resolvedTeamID) - } - - /// Subscribes under the current account and team. A running subscription - /// under the same scope is kept (its cursor stays valid); a different scope - /// or a signed-out account tears the socket down first, and a fresh scope - /// starts from cursor 0 because cursors are per team. - func start() { - let next = currentScope() - if loopTask != nil, next == scope { return } - stop() - guard let next else { return } - scope = next - cursor = 0 - epoch = 0 - let clock = clock - loopTask = Task { [weak self] in - var failures = 0 - while !Task.isCancelled { - var connected = false - do { - let frames: AsyncThrowingStream<DevicePresenceFrame, any Error> - do { - // Retain the owner only while opening the socket, never - // across the receive loop, so deinit can end the task. - guard let owner = self else { return } - frames = try await owner.openSocket(scope: next) - } - for try await frame in frames { - guard !Task.isCancelled, let owner = self else { return } - if !connected { - connected = true - failures = 0 - owner.streamDidConnect() - } - try owner.apply(frame) - } - // A clean close is the server's token deadline: resubscribe at once. - failures = 0 - } catch is CancellationError { - return - } catch { - failures += 1 - cloudVMSyncLog.error("vms subscribe failed: \(String(describing: error), privacy: .private)") - } - guard !Task.isCancelled, let owner = self else { return } - owner.streamDidEnd(wasConnected: connected, failures: failures) - let delay = Self.reconnectDelays[min(max(failures - 1, 0), Self.reconnectDelays.count - 1)] - // Bounded, cancellable backoff between subscribe attempts; the - // cancellation is wired to stop() through the owning task. - guard (try? await clock.sleep(for: failures == 0 ? .zero : delay)) != nil else { return } - } - } - } - - func stop() { - let wasLive = state == .live - loopTask?.cancel() - loopTask = nil - scope = nil - pendingSnapshot = [] - cursor = 0 - epoch = 0 - state = .stopped - if wasLive { onEvent(.disconnected) } - } - - private func openSocket(scope: Scope) async throws -> AsyncThrowingStream<DevicePresenceFrame, any Error> { - // Configuration can arrive after startup. A missing endpoint follows - // the same cancellable recovery as a failed connection. - guard let url = serviceURL() else { - throw DevicePresenceSubscriber.SubscribeError.invalidServiceURL - } - state = .connecting - pendingSnapshot = [] - let tokens = HiveAccountTokenSource(auth: auth, identity: scope.identity, teamID: scope.teamID) - let teamID = scope.teamID - let collections = [DevicePresenceFrame.SyncCollection( - name: DevicePresenceFrame.vmsCollection, cursor: cursor, epoch: epoch - )] - let subscriber = makeSubscriber(url, collections) { - // The same header rule as VMClient: the resolved team id, omitted - // for a personal account; the token source fails closed on a switch. - let session = try await tokens.session() - return DevicePresenceSubscriber.Credentials(accessToken: session.accessToken, teamID: teamID) - } - return try await subscriber.subscribe() - } - - private func streamDidConnect() { - state = .live - onEvent(.connected) - } - - private func streamDidEnd(wasConnected: Bool, failures: Int) { - state = .retrying(attempt: failures) - if wasConnected { onEvent(.disconnected) } - } - - /// A `vms` snapshot failed validation; the receive loop resubscribes. - struct MalformedSnapshot: Error {} - - /// Reduce one frame: buffer snapshot pages until `complete`, advance the - /// cursor only for frames the owner receives, end the session on a - /// malformed snapshot, ignore everything else. - func apply(_ frame: DevicePresenceFrame) throws { - switch frame { - case .vmsSnapshot(let records, let complete, let backfilled, let snapshotRev, let epoch): - pendingSnapshot.append(contentsOf: records) - guard complete else { return } - let all = pendingSnapshot - pendingSnapshot = [] - cursor = snapshotRev - self.epoch = epoch - onEvent(.snapshot(records: all, backfilled: backfilled)) - case .vmsDelta(let records, let rev): - cursor = max(cursor, rev) - onEvent(.delta(records: records)) - case .vmsMalformed: - // Drop the partial snapshot and keep the cursor: the stream - // resubscribes and the REST list stays the authority meanwhile. - pendingSnapshot = [] - throw MalformedSnapshot() - case .snapshot, .online, .offline, .seen, .routes, .syncSnapshot, .syncDelta, .ignored: - return - } - } -} diff --git a/Sources/Cloud/CloudWelcomeAnalyticsSession.swift b/Sources/Cloud/CloudWelcomeAnalyticsSession.swift deleted file mode 100644 index 12848ec98258..000000000000 --- a/Sources/Cloud/CloudWelcomeAnalyticsSession.swift +++ /dev/null @@ -1,77 +0,0 @@ -import CmuxCloud -import CmuxProductAnalytics -import Foundation - -/// The analytics for one shown welcome card: shown, each button, and how it -/// closed. Exactly one `cmux_cloud_welcome_closed` per shown card. -@MainActor -final class CloudWelcomeAnalyticsSession { - let trigger: CloudWelcomeTrigger - private let analytics: ProductAnalytics - private let now: @MainActor () -> Date - /// The controller's step resolver, which the card also renders from. - private let currentStep: @MainActor () -> CloudWelcomeNextStep - private let shownAt: Date - private var recordedUpgradeImpression = false - private(set) var isClosed = false - - init( - trigger: CloudWelcomeTrigger, - currentStep: @escaping @MainActor () -> CloudWelcomeNextStep, - analytics: ProductAnalytics, - now: @escaping @MainActor () -> Date - ) { - self.trigger = trigger - self.analytics = analytics - self.now = now - self.currentStep = currentStep - self.shownAt = now() - let step = currentStep() - analytics.track(.cloudWelcomeShown(trigger: trigger, step: Self.step(step))) - stepDisplayed(step) - } - - /// The card now offers `step`. A plan that loads after the card opened can - /// turn Open Cloud into Upgrade; the Upgrade impression is recorded the - /// first time it is on screen. - func stepDisplayed(_ step: CloudWelcomeNextStep) { - guard step == .upgrade, !recordedUpgradeImpression, !isClosed else { return } - recordedUpgradeImpression = true - analytics.track(.upgradeShown(.cloudWelcome)) - } - - /// A button closed the card. `step` is the step its primary button - /// offered; Not Now passes nil and records the step on screen now. - func clicked(_ button: CloudWelcomeButton, step: CloudWelcomeNextStep?) { - guard !isClosed else { return } - let offered = Self.step(step ?? currentStep()) - analytics.track(.cloudWelcomeButtonClicked(trigger: trigger, step: offered, button: button)) - close(.init(button: button), step: offered) - } - - /// The window closed with no button. - func windowClosed() { - guard !isClosed else { return } - close(.windowClosed, step: Self.step(currentStep())) - } - - private func close(_ outcome: CloudWelcomeOutcome, step: CloudWelcomeStep) { - guard !isClosed else { return } - isClosed = true - analytics.track(.cloudWelcomeClosed( - trigger: trigger, - step: step, - outcome: outcome, - visibleFor: DurationBucket(seconds: now().timeIntervalSince(shownAt)) - )) - } - - static func step(_ step: CloudWelcomeNextStep) -> CloudWelcomeStep { - switch step { - case .upgrade: .upgrade - case .enable: .enable - case .signIn: .signIn - case .openCloud: .openCloud - } - } -} diff --git a/Sources/Cloud/SidebarCloudButtonAction.swift b/Sources/Cloud/SidebarCloudButtonAction.swift deleted file mode 100644 index 96908d97298a..000000000000 --- a/Sources/Cloud/SidebarCloudButtonAction.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -import CmuxProductAnalytics - -/// Reveals Cloud on every press, introducing it explicitly while activation is off. -@MainActor -struct SidebarCloudButtonAction { - let isAvailable: () -> Bool - let isEnabled: () -> Bool - let revealMachines: () -> Void - let presentIntroduction: () -> Void - var analytics: ProductAnalytics = .disabled - - func perform() { - guard isAvailable() else { return } - revealMachines() - if !isEnabled() { - // Before the card opens, so the click precedes its welcome events. - analytics.track(.cloudButtonClicked(action: .introduceCloud)) - presentIntroduction() - } else { - analytics.track(.cloudButtonClicked(action: .openCloud)) - } - } -} diff --git a/Sources/HostAccountFlow+Analytics.swift b/Sources/HostAccountFlow+Analytics.swift deleted file mode 100644 index 61fa96eaa6c7..000000000000 --- a/Sources/HostAccountFlow+Analytics.swift +++ /dev/null @@ -1,38 +0,0 @@ -import CmuxProductAnalytics - -extension HostAccountFlow { - /// Reports sign-in, identity, team and sign-out changes. Called on every - /// coordinator change and once at init; cheap when nothing changed. - func syncAnalytics() { - let actions = analyticsState.observe( - userID: coordinator.currentUser?.id, - isSettling: isWorkingOnAuth, - failure: AccountAnalyticsState.reason(lastSignInFailure) - ) - for action in actions { - switch action { - case let .identify(userID): - analytics.identify(userID: userID) - case .signInSucceeded: - analytics.track(.signInSucceeded) - case let .signInFailed(reason): - analytics.track(.signInFailed(reason: reason)) - case .signedOut: - analytics.signedOut() - } - } - } - - /// `cmux_signed_out` is captured before a sign-out runs so it joins the - /// account. If the account is still signed in afterwards (a failed or - /// no-op sign-out), later events get the signed-in context back. - func restoreAnalyticsIfStillSignedIn() { - guard let userID = coordinator.currentUser?.id else { return } - analytics.identify(userID: userID) - } - - func noteSignInStarted() { - analyticsState.signInStarted() - analytics.track(.signInStarted) - } -} diff --git a/Sources/PostHogContextCalls.swift b/Sources/PostHogContextCalls.swift deleted file mode 100644 index fd7f871db593..000000000000 --- a/Sources/PostHogContextCalls.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -import PostHog - -/// The PostHog SDK call that sets context on later events, injectable so -/// tests observe it without the SDK. Person identity and groups (`identify`, -/// `group`, `reset`) are owned by the sign-in identity observer. -struct PostHogContextCalls: Sendable { - var register: @Sendable ([String: Any]) -> Void - - static let live = PostHogContextCalls( - register: { PostHogSDK.shared.register($0) } - ) -} diff --git a/Sources/PostHogProductAnalyticsSink.swift b/Sources/PostHogProductAnalyticsSink.swift deleted file mode 100644 index 639317d99cc8..000000000000 --- a/Sources/PostHogProductAnalyticsSink.swift +++ /dev/null @@ -1,29 +0,0 @@ -import CMUXMobileCore -import CmuxProductAnalytics -import Foundation - -/// Sends ``ProductAnalytics`` output through the app's PostHog SDK, which -/// owns batching, retries and the on-disk queue for offline launches. -struct PostHogProductAnalyticsSink: AnalyticsEmitting { - let analytics: PostHogAnalytics - - func capture(_ event: String, _ properties: [String: AnalyticsValue]) { - analytics.capture(event, properties: Self.plain(properties)) - } - - /// The PostHog person and its team group (identify, group, reset) have - /// one owner, the sign-in identity observer, which uses the same Stack - /// user id: a group lasts until `reset()`, so only that owner can clear it - /// on sign-out. Product analytics only adds events and context. - func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) {} - - func setSuperProperties(_ properties: [String: AnalyticsValue]) { - analytics.register(Self.plain(properties)) - } - - func flush() async {} - - static func plain(_ properties: [String: AnalyticsValue]) -> [String: Any] { - properties.mapValues { $0.jsonObject } - } -} diff --git a/Sources/ProBadgeActions.swift b/Sources/ProBadgeActions.swift deleted file mode 100644 index 482c71dd37db..000000000000 --- a/Sources/ProBadgeActions.swift +++ /dev/null @@ -1,27 +0,0 @@ -import CmuxCloud -import CmuxProductAnalytics - -/// What the sidebar footer's Upgrade badge does, with its analytics. The view -/// calls these; tests call them with a recording emitter and a no-op opener. -@MainActor -struct ProBadgeActions { - static let source = ProUpgradeSource.sidebarBadge - - var analytics: ProductAnalytics = .app - var openPricing: @MainActor (ProUpgradeSource) -> Void = { ProUpgradePresenter.presentAppPricingWeb(source: $0) } - - /// The badge is on screen. - func appeared() { - analytics.track(.upgradeShown(Self.source)) - } - - /// The badge was clicked: records the click with its source and opens pricing. - func upgrade() { - ProUpgradePresenter.present(source: Self.source, analytics: analytics, open: openPricing) - } - - /// The badge's X hid it. - func dismissed() { - analytics.track(.upgradeBadgeDismissed(source: Self.source.rawValue)) - } -} diff --git a/Sources/ProductAnalytics+App.swift b/Sources/ProductAnalytics+App.swift deleted file mode 100644 index 5a11ccf39a6a..000000000000 --- a/Sources/ProductAnalytics+App.swift +++ /dev/null @@ -1,37 +0,0 @@ -import CmuxCloud -import CmuxProductAnalytics -import CmuxSettings -import Foundation - -extension ProductAnalytics { - /// The app's product analytics, sending through ``PostHogAnalytics``. - /// Disabled whenever PostHog capture is (telemetry opt-out in Settings, - /// managed `DisableTelemetry`, XCTest, debug builds without - /// `CMUX_POSTHOG_ENABLE=1`), so it enqueues nothing in those launches. - static let app: ProductAnalytics = { - let posthog = PostHogAnalytics.shared - let defaults = UserDefaults.standard - let info = Bundle.main.infoDictionary ?? [:] - let os = ProcessInfo.processInfo.operatingSystemVersion - return ProductAnalytics( - isEnabled: posthog.isCaptureEnabled, - sink: PostHogProductAnalyticsSink(analytics: posthog), - ledger: ProductAnalyticsLedger(defaults: defaults), - context: ProductAnalyticsContext( - appVersion: info["CFBundleShortVersionString"] as? String ?? "unknown", - appBuild: info["CFBundleVersion"] as? String ?? "unknown", - channel: BuildFlavor.current.rawValue, - osVersion: "\(os.majorVersion).\(os.minorVersion)", - cloudEnabled: CloudMachinesFeature.offMainIsEnabled() - ), - isPreexistingInstall: isPreexistingInstall(defaults: defaults) - ) - }() - - /// An install that ran before the event catalog shipped leaves earlier - /// analytics or campaign markers; its first launch is not reported now. - static func isPreexistingInstall(defaults: UserDefaults) -> Bool { - defaults.string(forKey: "posthog.lastActiveDayUTC") != nil - || defaults.string(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) != nil - } -} diff --git a/Sources/ProductAnalyticsEvent+Upgrade.swift b/Sources/ProductAnalyticsEvent+Upgrade.swift deleted file mode 100644 index 56c691ae1971..000000000000 --- a/Sources/ProductAnalyticsEvent+Upgrade.swift +++ /dev/null @@ -1,19 +0,0 @@ -import CmuxCloud -import CmuxProductAnalytics - -extension ProductAnalyticsEvent { - /// An upgrade click from `source`. The same token reaches checkout as - /// `cmux_source`, so this joins `cmux_billing_checkout_*` on `checkout_source`. - static func upgradeOpened( - _ source: ProUpgradeSource, - plan: CheckoutPlan = .pro, - destination: UpgradeDestination - ) -> Self { - .upgradeEntrypointOpened(source: source.rawValue, plan: plan.rawValue, destination: destination) - } - - /// An upgrade entrypoint from `source` became visible. - static func upgradeShown(_ source: ProUpgradeSource, plan: CheckoutPlan = .pro) -> Self { - .upgradePromptShown(source: source.rawValue, plan: plan.rawValue) - } -} diff --git a/Sources/RemoteTui/SSHTuiAttachRetry.swift b/Sources/RemoteTui/SSHTuiAttachRetry.swift deleted file mode 100644 index 19c4af2bcfe5..000000000000 --- a/Sources/RemoteTui/SSHTuiAttachRetry.swift +++ /dev/null @@ -1,136 +0,0 @@ -import CmuxCloud -import CmuxSurfaceCatalogModel -import Foundation - -/// How a restored or reconnecting `cmux ssh` workspace retries its attach. -/// -/// After a relaunch the first attach often runs before the network is ready -/// (Tailscale, DNS or Wi-Fi still coming up). One failure there is not a -/// workspace failure, so a retryable error keeps the workspace `.connecting` -/// and retries after a short backoff. Only an error that retrying cannot fix, -/// or a run of failures that outlasts the backoff schedule, reaches the sidebar. -struct SSHTuiAttachRetryPolicy: Sendable { - /// Delay before each retry; the attach reports its error once these run out. - let delays: [Duration] - /// Cadence of the quiet retries that follow a reported, retryable error. - var reportedFailureRetryInterval: Duration = .seconds(30) - - static let standard = SSHTuiAttachRetryPolicy( - delays: [.seconds(1), .seconds(2), .seconds(4), .seconds(8), .seconds(15), .seconds(15)] - ) - - /// The delay before the next attempt after `failureCount` consecutive - /// failures ending in `error`, or nil when the attach should report it. - func retryDelay(afterFailure failureCount: Int, error: Error) -> Duration? { - guard Self.isRetryable(error), failureCount >= 1, failureCount <= delays.count else { return nil } - return delays[failureCount - 1] - } - - /// Errors worth another attempt while the workspace is still connecting. - static func isRetryable(_ error: Error) -> Bool { - if isPermanent(error) { return false } - // The carrier already waited out its own connect deadline (minutes, - // with internal retries); another fast lap only delays the report. - if case CloudMachineLink.LinkError.timedOut? = error as? CloudMachineLink.LinkError { return false } - return true - } - - /// Errors that no later attempt can fix, so a reported failure stops - /// retrying in the background and waits for Reconnect or a link-up. - static func isPermanent(_ error: Error) -> Bool { - if error is CancellationError { return true } - if let failure = error as? CloudDiagnosticFailure, failure == .unsupported { return true } - if case CloudMachineLink.LinkError.clientMissing? = error as? CloudMachineLink.LinkError { return true } - return CloudMachineAccessLoss(error: error) != nil - } - - /// Whether a waiting or failed attach should retry now instead of - /// waiting out its backoff. The provider's terminal mirrors keep - /// reconnecting the carrier on their own; once the machine link reports - /// connected, the workspace attach should follow immediately. A link that - /// was already connected when the attach failed does not trigger this, so - /// an attach that fails for another reason cannot spin on every catalog change. - static func shouldRetryNow(linkWasConnectedAtFailure: Bool, linkState: SurfaceLinkState?) -> Bool { - !linkWasConnectedAtFailure && linkState == .connected - } -} - -/// A backoff wait that a link-up event can end early. -@MainActor -final class SSHTuiAttachBackoff { - private var sleeper: Task<Void, Never>? - /// A wake that arrived before the wait began ends that wait at once. - private var wakeRequested = false - - /// Waits `delay` on `clock`, or until `wake()`, or until the caller's task is cancelled. - func wait(_ delay: Duration, clock: any Clock<Duration>) async { - if wakeRequested { - wakeRequested = false - return - } - let sleeper = Task { _ = try? await clock.sleep(for: delay) } - self.sleeper = sleeper - await withTaskCancellationHandler { - await sleeper.value - } onCancel: { - sleeper.cancel() - } - if self.sleeper == sleeper { self.sleeper = nil } - } - - func wake() { - if let sleeper { - sleeper.cancel() - } else { - wakeRequested = true - } - } -} - -/// Runs one attach with bounded retries. Kept separate from the coordinator -/// so the state transitions can be tested without an SSH carrier. -enum SSHTuiAttachRetry { - enum Outcome { - case connected - case failed(Error) - case cancelled - } - - /// Calls `attempt` until it succeeds, is cancelled, or `policy` gives up. - /// - /// `attempt` receives a predicate telling it whether a given error will be - /// retried, so it can keep a reserved pane pending instead of failing it - /// between attempts. `failed` runs before each backoff wait so the caller - /// can record whether the machine link was up when the attempt failed, - /// and `retrying` runs when that wait ends, before the next attempt. - @MainActor - static func run( - policy: SSHTuiAttachRetryPolicy, - clock: any Clock<Duration>, - backoff: SSHTuiAttachBackoff, - failed: @MainActor (Error) -> Void = { _ in }, - retrying: @MainActor () -> Void = {}, - attempt: @MainActor (_ willRetry: (Error) -> Bool) async throws -> Void - ) async -> Outcome { - var failures = 0 - while true { - if Task.isCancelled { return .cancelled } - let nextFailureCount = failures + 1 - do { - try await attempt { policy.retryDelay(afterFailure: nextFailureCount, error: $0) != nil } - return .connected - } catch is CancellationError { - return .cancelled - } catch { - if Task.isCancelled { return .cancelled } - failures = nextFailureCount - guard let delay = policy.retryDelay(afterFailure: failures, error: error) else { - return .failed(error) - } - failed(error) - await backoff.wait(delay, clock: clock) - retrying() - } - } - } -} diff --git a/Sources/RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift b/Sources/RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift deleted file mode 100644 index 525467bc980c..000000000000 --- a/Sources/RestorableAgentSessionIndex+ClaudeTranscriptlessResume.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -extension RestorableAgentSessionIndex { - /// Whether this load saw a Claude hook record for `sessionId`, found no - /// transcript for it in any config root, and Claude still has not written - /// one at the paths it reported. The re-check covers a first prompt sent - /// after this index loaded; it stats at most a few files per session. - func claudeSessionLacksTranscript(_ sessionId: String?) -> Bool { - guard let sessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines), - !sessionId.isEmpty, - let expectedPaths = claudeTranscriptPathsBySessionWithoutTranscript[sessionId] else { - return false - } - return !expectedPaths.contains(where: Self.regularNonEmptyFileExists(atPath:)) - } - - /// Drops the Claude resume state a saved terminal would replay when Claude - /// never wrote that session's transcript (it was quit before its first - /// prompt). Resuming it fails with "No conversation found", so the tab - /// reopens as a plain shell. Applied after the snapshot is built so an - /// older session on the same surface is never promoted in its place. - func removingTranscriptlessClaudeResume( - from terminal: SessionTerminalPanelSnapshot - ) -> SessionTerminalPanelSnapshot { - guard !claudeTranscriptPathsBySessionWithoutTranscript.isEmpty else { return terminal } - var terminal = terminal - if let agent = terminal.agent, - agent.kind == .claude, - claudeSessionLacksTranscript(agent.sessionId) { - terminal.agent = nil - terminal.hibernation = nil - } - if bindingResumesTranscriptlessClaude(terminal.resumeBinding) { - terminal.resumeBinding = nil - } - if bindingResumesTranscriptlessClaude(terminal.managedAgentResumeBinding) { - terminal.managedAgentResumeBinding = nil - } - return terminal - } - - /// Applies ``removingTranscriptlessClaudeResume(from:)`` to every terminal panel. - func removingTranscriptlessClaudeResume( - from panels: [SessionPanelSnapshot] - ) -> [SessionPanelSnapshot] { - guard !claudeTranscriptPathsBySessionWithoutTranscript.isEmpty else { return panels } - return panels.map { panel in - guard let terminal = panel.terminal else { return panel } - var panel = panel - panel.terminal = removingTranscriptlessClaudeResume(from: terminal) - return panel - } - } - - private static func regularNonEmptyFileExists(atPath path: String) -> Bool { - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), - !isDirectory.boolValue, - let size = (try? FileManager.default.attributesOfItem(atPath: path))?[.size] as? NSNumber else { - return false - } - return size.intValue > 0 - } - - private func bindingResumesTranscriptlessClaude( - _ binding: SurfaceResumeBindingSnapshot? - ) -> Bool { - guard let binding, binding.isAgentHookBinding, - binding.kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - == RestorableAgentKind.claude.rawValue else { - return false - } - return claudeSessionLacksTranscript(binding.checkpointId) - } -} diff --git a/Sources/RightSidebarMode+ProductFeature.swift b/Sources/RightSidebarMode+ProductFeature.swift deleted file mode 100644 index 7c8cfac9d11f..000000000000 --- a/Sources/RightSidebarMode+ProductFeature.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CmuxProductAnalytics - -extension RightSidebarMode { - /// The feature this right-sidebar tab counts as in product analytics. - var productFeature: ProductFeature { - switch self { - case .files: .filesExplorer - case .find: .find - case .sessions: .vault - case .feed: .feed - case .dock: .dock - case .machines: .cloudSidebar - case .customSidebar: .customSidebar - } - } -} diff --git a/Sources/UpgradePromptImpression.swift b/Sources/UpgradePromptImpression.swift deleted file mode 100644 index bbb29662b175..000000000000 --- a/Sources/UpgradePromptImpression.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CmuxCloud -import CmuxProductAnalytics -import SwiftUI - -extension View { - /// Records `cmux_upgrade_prompt_shown` for `source` when this view - /// appears. The pipeline keeps one per source per launch, so re-renders - /// and remounts are free. - func upgradePromptImpression( - _ source: ProUpgradeSource, - plan: CheckoutPlan = .pro, - analytics: ProductAnalytics = .app - ) -> some View { - onAppear { analytics.track(.upgradeShown(source, plan: plan)) } - } -} diff --git a/cmuxTests/AccountUpgradeOfferTests.swift b/cmuxTests/AccountUpgradeOfferTests.swift deleted file mode 100644 index dd8c82f6862e..000000000000 --- a/cmuxTests/AccountUpgradeOfferTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import CMUXMobileCore -import CmuxCommandPalette -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -/// Every upgrade entrypoint (account menu, sidebar help menu, Help menu, -/// command palette) offers the tier above the account's plan: Free and Go -/// see Pro, Pro sees Max, and Max sees nothing. -@MainActor -struct AccountUpgradeOfferTests { - @Test func menuTitlesNameTheOfferedTier() { - #expect(ProUpgradePresenter.upgradeMenuTitle(for: .pro) == String( - localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…" - )) - #expect(ProUpgradePresenter.upgradeMenuTitle(for: .max) == String( - localized: "menu.help.upgradeToMax", defaultValue: "Upgrade to cmux Max…" - )) - } - - @Test(arguments: [ - (CmuxPlanTier.free, Optional(CmuxPlanTier.pro)), - (CmuxPlanTier.go, Optional(CmuxPlanTier.pro)), - (CmuxPlanTier.pro, Optional(CmuxPlanTier.max)), - (CmuxPlanTier.max, nil), - ]) - func paletteCommandFollowsTheTier(tier: CmuxPlanTier, expected: CmuxPlanTier?) throws { - let command = try #require(ContentView.commandPaletteProCommandContributions().first { - $0.commandId == ContentView.commandPaletteProUpgradeCommandId - }) - var context = CommandPaletteContextSnapshot() - let offer = CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: tier) - context.setString( - CommandPaletteContextKeys.accountUpgradeOffer, - ContentView.commandPaletteUpgradeOfferValue(offer) - ) - #expect(command.when(context) == (expected != nil)) - if let expected { - #expect(command.title(context) == ProUpgradePresenter.upgradeCommandTitle(for: expected)) - } - } - - @Test func paletteHidesTheUpgradeWhileThePlanIsUnknown() throws { - let command = try #require(ContentView.commandPaletteProCommandContributions().first { - $0.commandId == ContentView.commandPaletteProUpgradeCommandId - }) - var context = CommandPaletteContextSnapshot() - context.setString( - CommandPaletteContextKeys.accountUpgradeOffer, - ContentView.commandPaletteUpgradeOfferValue(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: nil)) - ) - #expect(!command.when(context)) - } - - @Test func footerBadgeIsHiddenForProAndMax() { - for tier in CmuxPlanTier.allCases { - #expect( - SidebarFooterPresentationPolicy.isUpgradeVisible(isProActive: tier.includesPro) == !tier.includesPro, - "\(tier)" - ) - } - } -} diff --git a/cmuxTests/AgentAcknowledgedTurnNotificationTests.swift b/cmuxTests/AgentAcknowledgedTurnNotificationTests.swift deleted file mode 100644 index 92e78a4926ab..000000000000 --- a/cmuxTests/AgentAcknowledgedTurnNotificationTests.swift +++ /dev/null @@ -1,205 +0,0 @@ -import CmuxAgentJournal -import CmuxSettings -import Foundation -import Testing -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -/// A turn's notification, once read or dismissed, stays dismissed: Claude's -/// ~60 s `idle_prompt` reminder and replayed completions for the same turn -/// must not create a new unread entry, while real new attention still does. -extension AgentNotificationRegressionTests { - private struct JournalHarness { - let center: AgentJournalLifecycleCenter - let directory: URL - - func close() { - try? FileManager.default.removeItem(at: directory) - } - } - - private func makeJournalHarness(_ fixture: Fixture, source: String) throws -> JournalHarness { - fixture.source.surfaceResumeBindingsByPanelId[fixture.panelId] = SurfaceResumeBindingSnapshot( - name: source, kind: source, command: "agent resume", checkpointId: "session", - source: "agent-hook", updatedAt: 1) - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-acknowledged-turn-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - // Preferences are read, never written: other suites share - // UserDefaults.standard. The shipped defaults deliver both categories; - // suppression tests assert that first so a pass cannot come from a - // preference filter. - return JournalHarness( - center: AgentJournalLifecycleCenter(databaseURL: directory.appendingPathComponent("journal.sqlite3")), - directory: directory) - } - - private func expectRemindersAndCompletionsEnabled() { - let delivery = AgentNotificationDelivery() - #expect(delivery.allows(category: .idleReminder, pending: false)) - #expect(delivery.allows(category: .turnComplete, pending: false)) - } - - private func idleReminder(_ fixture: Fixture, at time: Int64) -> AgentJournalEventDraft { - draft(fixture, .idleObserved, source: "claude", at: time, category: "idle-reminder", - body: "Claude is waiting for your input", nativeEvent: "Notification") - } - - private func draft(_ fixture: Fixture, _ kind: AgentJournalEventKind, source: String, at time: Int64, - turn: String? = nil, request: String? = nil, category: String? = nil, - body: String = "Done", nativeEvent: String? = nil) -> AgentJournalEventDraft { - AgentJournalEventDraft(kind: kind, occurredAtMs: time, source: source, - agentKey: source == "claude" ? "claude_code" : source, sessionId: "session", - workspaceId: fixture.source.id.uuidString, surfaceId: fixture.panelId.uuidString, - nativeEvent: nativeEvent, - attention: AgentAttentionContext(turnIdentity: turn, requestIdentity: request, - notification: category.map { - AgentJournalNotification(title: source, subtitle: "", body: body, category: $0) - })) - } - - /// Runs observations through the live consumer, then waits on a no-op - /// admission: the operation stream is FIFO, so its reply orders after them. - private func settle(_ harness: JournalHarness, _ fixture: Fixture, _ drafts: [AgentJournalEventDraft]) async { - for draft in drafts { harness.center.observe(draft) } - _ = await harness.center.admitNotification(AgentJournalEventDraft(kind: .stateChanged, occurredAtMs: 0, - source: "barrier", agentKey: "barrier", unattributedReason: "test-barrier")) - TerminalMutationBus.shared.drainForTesting() - } - - private func unreadCount(_ fixture: Fixture) -> Int { - fixture.store.notifications.filter { !$0.isRead && $0.surfaceId == fixture.panelId }.count - } - - /// Claude Code fires `Notification(idle_prompt)` about 60 s after a turn - /// settles. Once the user read that turn's completion, the reminder is not - /// new attention and must not come back as an unread entry. - @Test func claudeIdleReminderAfterReadCompletionStaysQuiet() async throws { - let fixture = try makeFixture() - defer { fixture.restore() } - let harness = try makeJournalHarness(fixture, source: "claude") - defer { harness.close() } - expectRemindersAndCompletionsEnabled() - - await settle(harness, fixture, [ - draft(fixture, .sessionStarted, source: "claude", at: 1), - draft(fixture, .turnStarted, source: "claude", at: 2), - draft(fixture, .turnCompleted, source: "claude", at: 3, category: "turn-complete", nativeEvent: "Stop"), - ]) - #expect(unreadCount(fixture) == 1) - fixture.store.markRead(forTabId: fixture.source.id, surfaceId: fixture.panelId) - #expect(unreadCount(fixture) == 0) - - #expect(await !harness.center.admitNotification(idleReminder(fixture, at: 60_003))) - await settle(harness, fixture, []) - #expect(unreadCount(fixture) == 0) - } - - /// Dismissing (clearing) the completion acknowledges the turn the same way. - @Test func claudeIdleReminderAfterDismissedCompletionStaysQuiet() async throws { - let fixture = try makeFixture() - defer { fixture.restore() } - let harness = try makeJournalHarness(fixture, source: "claude") - defer { harness.close() } - - await settle(harness, fixture, [ - draft(fixture, .sessionStarted, source: "claude", at: 1), - draft(fixture, .turnStarted, source: "claude", at: 2), - draft(fixture, .turnCompleted, source: "claude", at: 3, category: "turn-complete", nativeEvent: "Stop"), - ]) - #expect(unreadCount(fixture) == 1) - fixture.store.clearNotifications(forTabId: fixture.source.id, surfaceId: fixture.panelId) - #expect(fixture.store.notifications.isEmpty) - - await settle(harness, fixture, [idleReminder(fixture, at: 60_003)]) - #expect(fixture.store.notifications.isEmpty) - } - - /// A completion that was claimed but never recorded (here, the workspace - /// was muted) was not seen by the user, so it is not acknowledged and the - /// reminder stays eligible once notifications reach the pane again. - @Test func claudeIdleReminderAfterUnrecordedCompletionStillNotifies() async throws { - let fixture = try makeFixture() - defer { fixture.restore() } - let harness = try makeJournalHarness(fixture, source: "claude") - defer { harness.close() } - expectRemindersAndCompletionsEnabled() - - fixture.source.isMuted = true - await settle(harness, fixture, [ - draft(fixture, .sessionStarted, source: "claude", at: 1), - draft(fixture, .turnStarted, source: "claude", at: 2), - draft(fixture, .turnCompleted, source: "claude", at: 3, category: "turn-complete", nativeEvent: "Stop"), - ]) - #expect(fixture.store.notifications.isEmpty) - fixture.source.isMuted = false - - #expect(await harness.center.admitNotification(idleReminder(fixture, at: 60_003))) - } - - /// The reminder is still the nag the user enabled while the completion is - /// unread, and a later turn or a permission request is real new attention. - @Test func claudeUnreadCompletionNewTurnAndPermissionStillNotify() async throws { - let fixture = try makeFixture() - defer { fixture.restore() } - let harness = try makeJournalHarness(fixture, source: "claude") - defer { harness.close() } - - await settle(harness, fixture, [ - draft(fixture, .sessionStarted, source: "claude", at: 1), - draft(fixture, .turnStarted, source: "claude", at: 2), - draft(fixture, .turnCompleted, source: "claude", at: 3, category: "turn-complete", nativeEvent: "Stop"), - ]) - #expect(unreadCount(fixture) == 1) - #expect(await harness.center.admitNotification(idleReminder(fixture, at: 60_003))) - - fixture.store.markRead(forTabId: fixture.source.id, surfaceId: fixture.panelId) - await settle(harness, fixture, [ - draft(fixture, .turnStarted, source: "claude", at: 70_000), - draft(fixture, .turnCompleted, source: "claude", at: 70_001, category: "turn-complete", nativeEvent: "Stop"), - ]) - #expect(unreadCount(fixture) == 1) - - fixture.store.markRead(forTabId: fixture.source.id, surfaceId: fixture.panelId) - await settle(harness, fixture, [ - draft(fixture, .turnStarted, source: "claude", at: 80_000), - draft(fixture, .approvalRequested, source: "claude", at: 80_001, request: "tool-1", - category: "needs-permission", body: "Claude needs your permission to use Bash"), - ]) - #expect(unreadCount(fixture) == 1) - } - - /// Codex can deliver the same Stop again (a resumed session re-announcing - /// its last turn, or a second hook producer). The replay keeps its turn - /// identity and must not resurrect the completion the user read. - @Test func codexReplayedCompletionAfterReadDoesNotResurrect() async throws { - let fixture = try makeFixture() - defer { fixture.restore() } - let harness = try makeJournalHarness(fixture, source: "codex") - defer { harness.close() } - - await settle(harness, fixture, [ - draft(fixture, .sessionStarted, source: "codex", at: 1), - draft(fixture, .turnStarted, source: "codex", at: 2, turn: "turn-1"), - draft(fixture, .turnCompleted, source: "codex", at: 3, turn: "turn-1", category: "turn-complete", nativeEvent: "Stop"), - ]) - #expect(unreadCount(fixture) == 1) - fixture.store.markRead(forTabId: fixture.source.id, surfaceId: fixture.panelId) - - await settle(harness, fixture, [ - draft(fixture, .turnCompleted, source: "codex", at: 4, turn: "turn-1", category: "turn-complete", nativeEvent: "Stop"), - draft(fixture, .sessionStarted, source: "codex", at: 5), - draft(fixture, .turnCompleted, source: "codex", at: 6, turn: "turn-1", category: "turn-complete", nativeEvent: "Stop"), - ]) - #expect(unreadCount(fixture) == 0) - - await settle(harness, fixture, [ - draft(fixture, .turnStarted, source: "codex", at: 7, turn: "turn-2"), - draft(fixture, .turnCompleted, source: "codex", at: 8, turn: "turn-2", category: "turn-complete", nativeEvent: "Stop"), - ]) - #expect(unreadCount(fixture) == 1) - } -} diff --git a/cmuxTests/ClaudeTranscriptlessRestoreTests.swift b/cmuxTests/ClaudeTranscriptlessRestoreTests.swift deleted file mode 100644 index aeada3a0479c..000000000000 --- a/cmuxTests/ClaudeTranscriptlessRestoreTests.swift +++ /dev/null @@ -1,255 +0,0 @@ -import CMUXAgentLaunch -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -/// Claude Code writes `<config>/projects/<slug>/<id>.jsonl` only after the -/// first message. A session that was started and quit before any prompt has -/// nothing to resume, so cmux must not save or replay `cmux restore claude`. -@MainActor -@Suite(.serialized) -struct ClaudeTranscriptlessRestoreTests { - private struct Fixture { - let root: URL - let configDirectory: URL - let workingDirectory: URL - let hookStateDirectory: URL - let defaults: UserDefaults - let defaultsName: String - - var environment: [String: String] { - ["CMUX_AGENT_HOOK_STATE_DIR": hookStateDirectory.path] - } - - func transcriptURL(sessionID: String) -> URL { - configDirectory - .appendingPathComponent("projects", isDirectory: true) - .appendingPathComponent( - RestorableAgentSessionIndex.encodeClaudeProjectDir(workingDirectory.path), - isDirectory: true - ) - .appendingPathComponent("\(sessionID).jsonl", isDirectory: false) - } - - func cleanup() { - try? FileManager.default.removeItem(at: root) - defaults.removePersistentDomain(forName: defaultsName) - } - } - - private func makeFixture() throws -> Fixture { - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-claude-transcriptless-\(UUID().uuidString)", isDirectory: true) - let configDirectory = root.appendingPathComponent("claude-config", isDirectory: true) - let workingDirectory = root.appendingPathComponent("repo", isDirectory: true) - let hookStateDirectory = root.appendingPathComponent("hook-state", isDirectory: true) - try fileManager.createDirectory(at: workingDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: hookStateDirectory, withIntermediateDirectories: true) - let defaultsName = "cmux-claude-transcriptless-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: defaultsName)) - defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) - let fixture = Fixture( - root: root, - configDirectory: configDirectory, - workingDirectory: workingDirectory, - hookStateDirectory: hookStateDirectory, - defaults: defaults, - defaultsName: defaultsName - ) - try fileManager.createDirectory( - at: fixture.transcriptURL(sessionID: "placeholder").deletingLastPathComponent(), - withIntermediateDirectories: true - ) - return fixture - } - - private func writeHookRecord( - _ fixture: Fixture, - sessionID: String, - workspaceID: UUID, - panelID: UUID - ) throws { - let storeURL = RestorableAgentKind.claude.hookStoreFileURL( - homeDirectory: fixture.root.path, - environment: fixture.environment - ) - // SessionStart reports where Claude will write the transcript before - // the file exists. - let record: [String: Any] = [ - "sessionId": sessionID, - "workspaceId": workspaceID.uuidString, - "surfaceId": panelID.uuidString, - "cwd": fixture.workingDirectory.path, - "transcriptPath": fixture.transcriptURL(sessionID: sessionID).path, - "updatedAt": 1_791_483_500, - "launchCommand": [ - "launcher": "claude", - "executablePath": "/usr/local/bin/claude", - "arguments": ["/usr/local/bin/claude"], - "workingDirectory": fixture.workingDirectory.path, - "environment": ["CLAUDE_CONFIG_DIR": fixture.configDirectory.path], - "capturedAt": 1_791_483_500, - "source": "test", - ], - ] - let data = try JSONSerialization.data( - withJSONObject: ["version": 1, "sessions": [sessionID: record]], - options: [.prettyPrinted, .sortedKeys] - ) - try data.write(to: storeURL, options: .atomic) - } - - private func writeTranscript(_ fixture: Fixture, sessionID: String) throws { - try """ - {"type":"user","sessionId":"\(sessionID)","cwd":"\(fixture.workingDirectory.path)","message":{"role":"user","content":"hello"}} - - """.write(to: fixture.transcriptURL(sessionID: sessionID), atomically: true, encoding: .utf8) - } - - private func hookBinding(_ fixture: Fixture, sessionID: String) -> SurfaceResumeBindingSnapshot { - SurfaceResumeBindingSnapshot( - name: "Claude Code", - kind: "claude", - command: "claude --resume \(sessionID)", - cwd: fixture.workingDirectory.path, - checkpointId: sessionID, - source: "agent-hook", - environment: ["CLAUDE_CONFIG_DIR": fixture.configDirectory.path], - autoResume: true, - approvalPolicy: .auto, - updatedAt: 1_791_483_501 - ) - } - - private func loadIndex(_ fixture: Fixture) -> RestorableAgentSessionIndex { - RestorableAgentSessionIndex.load( - homeDirectory: fixture.root.path, - fileManager: .default, - registry: CmuxVaultAgentRegistry(registrations: []), - detectedSnapshots: [:], - environment: fixture.environment, - processArgumentsProvider: { _ in nil }, - processPresenceProvider: { _ in .absent } - ) - } - - /// Saves the source workspace with `index`, forces unknown liveness (the - /// live-tab-at-quit case from #20246), and restores the result. - private enum TranscriptTiming { - case never - case beforeIndexLoad - case afterIndexLoad - } - - private func saveAndRestore( - _ fixture: Fixture, - transcript: TranscriptTiming, - index: (Fixture) -> RestorableAgentSessionIndex - ) throws -> (terminal: SessionTerminalPanelSnapshot?, input: String?) { - let source = Workspace( - agentSessionAutoResumeDefaults: fixture.defaults, - restorableAgentIndexProvider: { .empty } - ) - defer { source.teardownAllPanels() } - let panelID = try #require(source.focusedPanelId) - let sessionID = UUID().uuidString.lowercased() - try writeHookRecord(fixture, sessionID: sessionID, workspaceID: source.id, panelID: panelID) - if transcript == .beforeIndexLoad { - try writeTranscript(fixture, sessionID: sessionID) - } - source.surfaceResumeBindingsByPanelId[panelID] = hookBinding(fixture, sessionID: sessionID) - - let agentIndex = index(fixture) - if transcript == .afterIndexLoad { - try writeTranscript(fixture, sessionID: sessionID) - } - var snapshot = source.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: agentIndex - ) - let savedTerminal = snapshot.panels.first?.terminal - for panelIndex in snapshot.panels.indices { - snapshot.panels[panelIndex].terminal?.wasAgentRunning = nil - } - - let restored = Workspace( - agentSessionAutoResumeDefaults: fixture.defaults, - restorableAgentIndexProvider: { .empty } - ) - defer { restored.teardownAllPanels() } - restored.restoreSessionSnapshot(snapshot) - let restoredPanelID = try #require(restored.focusedPanelId) - let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) - return (savedTerminal, restoredPanel.surface.debugInitialInputForTesting()) - } - - @Test("A Claude session quit before its first prompt is not saved or restored") - func transcriptlessClaudeSessionIsNotSavedOrRestored() throws { - let fixture = try makeFixture() - defer { fixture.cleanup() } - - let result = try saveAndRestore(fixture, transcript: .never) { loadIndex($0) } - - #expect(result.terminal?.resumeBinding == nil) - #expect(result.terminal?.agent == nil) - #expect(result.input == nil, Comment(rawValue: result.input ?? "")) - } - - @Test("A Claude session with a transcript still saves and restores") - func claudeSessionWithTranscriptStillRestores() throws { - let fixture = try makeFixture() - defer { fixture.cleanup() } - - let result = try saveAndRestore(fixture, transcript: .beforeIndexLoad) { loadIndex($0) } - - let binding = try #require(result.terminal?.resumeBinding) - #expect(binding.kind == "claude") - let input = try #require(result.input) - #expect(input == " cmux restore claude \(binding.checkpointId ?? "")\n", Comment(rawValue: input)) - } - - @Test("A first prompt sent after the index loaded keeps the Claude binding") - func transcriptWrittenAfterIndexLoadKeepsBinding() throws { - let fixture = try makeFixture() - defer { fixture.cleanup() } - - let result = try saveAndRestore(fixture, transcript: .afterIndexLoad) { loadIndex($0) } - - let binding = try #require(result.terminal?.resumeBinding) - #expect(binding.kind == "claude") - #expect(result.input == " cmux restore claude \(binding.checkpointId ?? "")\n") - } - - @Test("A cached index marked incomplete still drops a transcript-less Claude session") - func cachedIndexDropsTranscriptlessClaudeSession() throws { - let fixture = try makeFixture() - defer { fixture.cleanup() } - - // Update relaunch and the quit deadline fallback save from a cached - // index. Its evidence is re-checked on disk before anything is dropped. - let result = try saveAndRestore(fixture, transcript: .never) { - loadIndex($0).markingIncomplete() - } - - #expect(result.terminal?.resumeBinding == nil) - #expect(result.input == nil, Comment(rawValue: result.input ?? "")) - } - - @Test("A cached index keeps a Claude session whose first prompt landed after it loaded") - func cachedIndexKeepsClaudeSessionWithLaterTranscript() throws { - let fixture = try makeFixture() - defer { fixture.cleanup() } - - let result = try saveAndRestore(fixture, transcript: .afterIndexLoad) { - loadIndex($0).markingIncomplete() - } - - #expect(result.terminal?.resumeBinding?.kind == "claude") - } -} diff --git a/cmuxTests/CloudStaleInventoryMaterializationTests.swift b/cmuxTests/CloudStaleInventoryMaterializationTests.swift deleted file mode 100644 index f4b54000aabb..000000000000 --- a/cmuxTests/CloudStaleInventoryMaterializationTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import CmuxCloud -import CmuxSurfaceCatalogModel -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@MainActor -@Suite("Cloud stale inventory admission", .serialized, .exclusiveAppContext) -struct CloudStaleInventoryMaterializationTests { - @Test("a stale graph never materializes an interactive restored terminal") - func staleGraphIsNotInteractive() { - #expect(CmuxTuiSurfaceProvider.shouldShowRecoverableCloudFailure(observation: .stale(reason: "link timeout"))) - #expect(!CmuxTuiSurfaceProvider.shouldShowRecoverableCloudFailure(observation: .current)) - } - - @Test("missing and stale observations fail the actual attachment and fresh state retries it", arguments: [false, true]) - func restoredAttachmentRecovers(hasStaleObservation: Bool) throws { - _ = try #require(AppDelegate.shared) - let manager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) - let workspace = Workspace() - manager.tabs = [workspace] - manager.selectedTabId = workspace.id - defer { workspace.teardownAllPanels(); manager.tabs = [] } - try LiveWorkspaceFixture.withAppRegistration(of: manager) { - let panelID = try #require(workspace.focusedPanelId) - let catalog = SurfaceCatalog() - let summary = VMSummary(id: "stale-\(UUID())", provider: "freestyle", status: "running", image: "fixture", createdAt: 0, base: nil) - let provider = CmuxTuiSurfaceProvider(summary: summary, - links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }), catalog: catalog) - catalog.register(provider) - defer { provider.suspendForFeatureFlag(); catalog.unregister(machine: provider.machine) } - let resource = SurfaceResource(id: .init(machine: provider.machine, kind: .terminal, key: "term_fixture"), title: "Terminal", lifecycle: .running) - let state = CloudVMState(machine: provider.machine, cursor: nil, rawSnapshot: Data("{}".utf8), - workspaces: [], screens: [], panes: [], tabs: [], terminals: [], browsers: [], agents: []) - if hasStaleObservation { - catalog.replaceCloudState(state, resources: [resource], info: provider.info, observation: .stale(reason: "link timeout")) - } - let reservation = CloudTerminalPaneReservation(workspaceID: workspace.id, panelID: panelID, machine: provider.machine) - workspace.cloudPendingCreations[panelID] = reservation - defer { reservation.cancel?(); workspace.cloudPendingCreations[panelID] = nil } - provider.attachReservedTerminalPane(reservation, resource: resource, remoteTabID: nil) - #expect(workspace.cloudMaterializationFailures[panelID] != nil) - #expect(!provider.materializedPanels.contains(panelID)) - #expect(provider.restoredAttachTasks[panelID] == nil) - #expect(workspace.retryReservedCloudTerminalPane(surfaceId: panelID)) - #expect(!provider.materializedPanels.contains(panelID)) - - catalog.record(SurfaceProjection(resource: resource.id, workspaceID: workspace.id, panelID: panelID)) - catalog.replaceCloudState(state, resources: [resource], info: provider.info, observation: .current) - provider.projectionsRestored() - #expect(provider.materializedPanels.contains(panelID)) - #expect(provider.restoredAttachTasks[panelID] != nil) - } - } - -} diff --git a/cmuxTests/CloudWelcomePresentationTests.swift b/cmuxTests/CloudWelcomePresentationTests.swift deleted file mode 100644 index c967f31e9d97..000000000000 --- a/cmuxTests/CloudWelcomePresentationTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -import AppKit -import CmuxProductAnalytics -import CmuxSettings -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@MainActor -@Suite("Cloud welcome presentation gate", .serialized, .exclusiveAppContext) -struct CloudWelcomePresentationTests { - private var isVisible: Bool { - NSApp.windows.contains { $0.identifier?.rawValue == "cmux.cloud.welcome" && $0.isVisible } - } - - @Test("a direct action shows and marks once, then continues without the card") - func directActionMarksSeenAndContinues() throws { - let suite = "CloudWelcomePresentationTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - var steps: [CloudWelcomeNextStep] = [] - var resumed = 0 - let controller = CloudWelcomeWindowController( - defaults: defaults, - nextStep: { .enable }, - performNextStep: { step, resume in steps.append(step); resume?() } - ) - defer { controller.dismiss() } - - controller.present(over: nil, trigger: .newCloudMachine, onEnabled: { resumed += 1 }) - #expect(isVisible) - #expect(defaults.string(forKey: CloudWelcomeWindowController.seenVersionDefaultsKey) == "0.65.1") - #expect(steps.isEmpty) - #expect(resumed == 0) - controller.dismiss() - controller.present(over: nil, trigger: .newCloudMachine, onEnabled: { resumed += 1 }) - #expect(!isVisible) - #expect(steps == [.enable]) - #expect(resumed == 1) - - let relaunchedDefaults = try #require(UserDefaults(suiteName: suite)) - let relaunched = CloudWelcomeWindowController(defaults: relaunchedDefaults) - let policy = ManagedDevicePolicy(defaults: relaunchedDefaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) - #expect(!relaunched.claimLaunchPresentation(appVersion: "0.65.2", policy: policy)) - } - - @Test("a launch viewer takes the current next step on every later Cloud action", - arguments: [CloudWelcomeNextStep.signIn, .enable, .upgrade, .openCloud]) - func launchViewerSkipsCard(step: CloudWelcomeNextStep) throws { - let suite = "CloudWelcomePresentationTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) - let launch = CloudWelcomeWindowController(defaults: defaults) - #expect(launch.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) - var steps: [CloudWelcomeNextStep] = [] - var resumed = 0 - let controller = CloudWelcomeWindowController( - defaults: defaults, - nextStep: { step }, - performNextStep: { step, resume in steps.append(step); resume?() } - ) - defer { controller.dismiss() } - for _ in 0..<2 { - controller.present(over: nil, trigger: .newCloudMachine, onEnabled: { resumed += 1 }) - #expect(!isVisible) - } - #expect(steps == [step, step]) - #expect(resumed == 2) - #expect(!controller.claimLaunchPresentation(appVersion: "0.65.2", policy: policy)) - } - - #if DEBUG - @Test("debug force-show marks seen and reset permits another first presentation") - func debugForceAndReset() throws { - let suite = "CloudWelcomePresentationTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let controller = CloudWelcomeWindowController(defaults: defaults, performNextStep: { _, _ in }) - defer { controller.dismiss() } - let policy = ManagedDevicePolicy(defaults: defaults, releaseDomainDefaults: nil, forcedObject: { _, _ in nil }) - controller.presentForDebug(over: nil) - #expect(isVisible) - #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) - controller.dismiss() - #expect(!controller.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) - controller.presentForDebug(over: nil) - #expect(isVisible) - controller.dismiss() - controller.present(over: nil, trigger: .newCloudMachine) - #expect(!isVisible) - controller.resetForDebug() - #expect(!CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) - controller.present(over: nil, trigger: .newCloudMachine) - #expect(isVisible) - #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) - controller.resetForDebug() - #expect(controller.claimLaunchPresentation(appVersion: "0.65.1", policy: policy)) - } - #endif -} diff --git a/cmuxTests/SSHTuiAttachRetryTests.swift b/cmuxTests/SSHTuiAttachRetryTests.swift deleted file mode 100644 index 94cf264aeff8..000000000000 --- a/cmuxTests/SSHTuiAttachRetryTests.swift +++ /dev/null @@ -1,203 +0,0 @@ -import CmuxCloud -import CmuxSurfaceCatalogModel -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -/// A restored `cmux ssh` workspace must stay connecting while its attach -/// retries, and report an error only once retrying cannot help. -@MainActor -@Suite("SSH cmux-tui restore attach retry", .serialized) -struct SSHTuiAttachRetryTests { - private struct TransientFailure: Error {} - - private let policy = SSHTuiAttachRetryPolicy(delays: [.seconds(1), .seconds(2)]) - - @Test("Retryable failures back off on the schedule, then give up") - func retryScheduleIsBounded() { - let error = CloudMachineLink.LinkError.failureMessage("ssh: Could not resolve hostname host") - #expect(policy.retryDelay(afterFailure: 1, error: error) == .seconds(1)) - #expect(policy.retryDelay(afterFailure: 2, error: error) == .seconds(2)) - #expect(policy.retryDelay(afterFailure: 3, error: error) == nil) - } - - @Test("Failures that no retry can fix report at once") - func permanentFailuresAreNotRetried() { - #expect(policy.retryDelay(afterFailure: 1, error: CloudDiagnosticFailure.unsupported) == nil) - #expect(policy.retryDelay(afterFailure: 1, error: CloudMachineLink.LinkError.clientMissing) == nil) - #expect(policy.retryDelay(afterFailure: 1, error: CancellationError()) == nil) - // The carrier already spent its connect deadline; report instead of another lap. - #expect(policy.retryDelay(afterFailure: 1, error: CloudMachineLink.LinkError.timedOut) == nil) - #expect(policy.retryDelay(afterFailure: 1, error: CloudMachineLink.LinkError.requestTimedOut) == .seconds(1)) - // A spent carrier deadline is reported, but still retried quietly afterwards. - #expect(!SSHTuiAttachRetryPolicy.isPermanent(CloudMachineLink.LinkError.timedOut)) - #expect(SSHTuiAttachRetryPolicy.isPermanent(CloudDiagnosticFailure.unsupported)) - #expect(SSHTuiAttachRetryPolicy.isPermanent(CloudMachineLink.LinkError.clientMissing)) - #expect(!SSHTuiAttachRetryPolicy.isPermanent(CloudMachineLink.LinkError.failureMessage("Connection refused"))) - } - - @Test("Only a link that came up after the failure retries early") - func linkUpRetriesOnlyAfterADownLink() { - #expect(SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: false, linkState: .connected)) - #expect(!SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: true, linkState: .connected)) - #expect(!SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: false, linkState: .connecting)) - #expect(!SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: false, linkState: .error)) - #expect(!SSHTuiAttachRetryPolicy.shouldRetryNow(linkWasConnectedAtFailure: false, linkState: nil)) - } - - @Test("A transient failure retries after its backoff and connects without reporting") - func transientFailureConnectsOnRetry() async { - let clock = CloudReadManualClock() - let backoff = SSHTuiAttachBackoff() - var attempts = 0 - var keptPending: [Bool] = [] - var failures = 0 - var retries = 0 - let run = Task { @MainActor in - await SSHTuiAttachRetry.run( - policy: policy, clock: clock, backoff: backoff, - failed: { _ in failures += 1 }, - retrying: { retries += 1 }, - attempt: { willRetry in - attempts += 1 - if attempts == 1 { - keptPending.append(willRetry(TransientFailure())) - throw TransientFailure() - } - } - ) - } - await waitUntil { clock.pendingSleeperCount == 1 } - #expect(attempts == 1) - #expect(failures == 1) - clock.advance(by: .seconds(1)) - let outcome = await run.value - guard case .connected = outcome else { - Issue.record("expected connected, got \(outcome)") - return - } - #expect(attempts == 2) - #expect(retries == 1) - #expect(keptPending == [true]) - } - - @Test("Failures that outlast the schedule report the last error") - func exhaustedRetriesFail() async { - let clock = CloudReadManualClock() - let backoff = SSHTuiAttachBackoff() - var attempts = 0 - var lastKeptPending: Bool? - let run = Task { @MainActor in - await SSHTuiAttachRetry.run(policy: policy, clock: clock, backoff: backoff) { willRetry in - attempts += 1 - lastKeptPending = willRetry(TransientFailure()) - throw TransientFailure() - } - } - await waitUntil { clock.pendingSleeperCount == 1 } - clock.advance(by: .seconds(1)) - await waitUntil { attempts == 2 && clock.pendingSleeperCount == 1 } - clock.advance(by: .seconds(2)) - let outcome = await run.value - guard case .failed(let error) = outcome else { - Issue.record("expected failed, got \(outcome)") - return - } - #expect(error is TransientFailure) - #expect(attempts == 3) - // The final attempt fails its reserved pane instead of keeping it pending. - #expect(lastKeptPending == false) - } - - @Test("A permanent failure reports on the first attempt without waiting") - func permanentFailureFailsImmediately() async { - let clock = CloudReadManualClock() - var attempts = 0 - let outcome = await SSHTuiAttachRetry.run(policy: policy, clock: clock, backoff: SSHTuiAttachBackoff()) { _ in - attempts += 1 - throw CloudDiagnosticFailure.unsupported - } - guard case .failed = outcome else { - Issue.record("expected failed, got \(outcome)") - return - } - #expect(attempts == 1) - #expect(clock.pendingSleeperCount == 0) - } - - @Test("A link-up wake retries before the backoff elapses") - func wakeEndsBackoffEarly() async { - let clock = CloudReadManualClock() - let backoff = SSHTuiAttachBackoff() - var attempts = 0 - let run = Task { @MainActor in - await SSHTuiAttachRetry.run( - policy: SSHTuiAttachRetryPolicy(delays: [.seconds(600)]), clock: clock, backoff: backoff - ) { _ in - attempts += 1 - if attempts == 1 { throw TransientFailure() } - } - } - await waitUntil { clock.pendingSleeperCount == 1 } - backoff.wake() - let outcome = await run.value - guard case .connected = outcome else { - Issue.record("expected connected, got \(outcome)") - return - } - #expect(attempts == 2) - #expect(clock.now.offset == .zero) - } - - @Test("A wake that lands before the wait begins is not lost") - func wakeBeforeWaitIsKept() async { - let clock = CloudReadManualClock() - let backoff = SSHTuiAttachBackoff() - var attempts = 0 - let outcome = await SSHTuiAttachRetry.run( - policy: SSHTuiAttachRetryPolicy(delays: [.seconds(600)]), clock: clock, backoff: backoff, - failed: { _ in backoff.wake() } - ) { _ in - attempts += 1 - if attempts == 1 { throw TransientFailure() } - } - guard case .connected = outcome else { - Issue.record("expected connected, got \(outcome)") - return - } - #expect(attempts == 2) - } - - @Test("Cancelling the attach ends a backoff wait without reporting") - func cancellationEndsQuietly() async { - let clock = CloudReadManualClock() - let backoff = SSHTuiAttachBackoff() - var attempts = 0 - let run = Task { @MainActor in - await SSHTuiAttachRetry.run(policy: policy, clock: clock, backoff: backoff) { _ in - attempts += 1 - throw TransientFailure() - } - } - await waitUntil { clock.pendingSleeperCount == 1 } - run.cancel() - let outcome = await run.value - guard case .cancelled = outcome else { - Issue.record("expected cancelled, got \(outcome)") - return - } - #expect(attempts == 1) - } - - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0..<10_000 where !condition() { - await Task.yield() - } - #expect(condition()) - } -} diff --git a/cmuxTests/SidebarCloudButtonActionTests.swift b/cmuxTests/SidebarCloudButtonActionTests.swift deleted file mode 100644 index 0e49e7141405..000000000000 --- a/cmuxTests/SidebarCloudButtonActionTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -import AppKit -import CmuxSettings -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@MainActor -@Suite("Sidebar Cloud button routing", .serialized, .exclusiveAppContext) -struct SidebarCloudButtonActionTests { - @Test("enabled Cloud reveals Machines on every press without opening welcome") - func enabledCloudKeepsMachinesOpen() { - var visible = false - var mode: RightSidebarMode = .files - var introductions = 0 - let action = SidebarCloudButtonAction( - isAvailable: { true }, - isEnabled: { true }, - revealMachines: { visible = true; mode = .machines }, - presentIntroduction: { introductions += 1 } - ) - action.perform() - #expect(visible) - #expect(mode == .machines) - action.perform() - #expect(visible) - #expect(mode == .machines) - #expect(introductions == 0) - } - - @Test("disabled Cloud reopens welcome despite the seen marker for every account route", - arguments: [CloudWelcomeNextStep.signIn, .upgrade, .enable, .openCloud]) - func disabledCloudAlwaysIntroduces(step: CloudWelcomeNextStep) throws { - let suite = "SidebarCloudButtonActionTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - CloudWelcomeWindowController.markCampaignSeen(in: defaults) - var nextSteps: [CloudWelcomeNextStep] = [] - let welcome = CloudWelcomeWindowController( - defaults: defaults, - nextStep: { step }, - performNextStep: { step, _ in nextSteps.append(step) } - ) - defer { welcome.dismiss() } - var revealed = 0 - let action = SidebarCloudButtonAction( - isAvailable: { true }, - isEnabled: { false }, - revealMachines: { revealed += 1 }, - presentIntroduction: { - #expect(revealed > 0) - welcome.presentIntroduction(over: nil) - } - ) - for _ in 0..<2 { - action.perform() - #expect(NSApp.windows.contains { - $0.identifier?.rawValue == "cmux.cloud.welcome" && $0.isVisible - }) - welcome.dismiss() - } - #expect(revealed == 2) - #expect(nextSteps.isEmpty) - #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) - } - - @Test("an explicit introduction records the seen marker so launch does not repeat it") - func introductionMarksWelcomeSeen() throws { - let suite = "SidebarCloudButtonActionTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let welcome = CloudWelcomeWindowController(defaults: defaults) - defer { welcome.dismiss() } - #expect(!CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) - welcome.presentIntroduction(over: nil) - #expect(CloudWelcomeWindowController.hasSeenWelcome(in: defaults)) - #expect(!welcome.claimLaunchPresentation(appVersion: "0.65.1", policy: ManagedDevicePolicy(defaults: defaults))) - } - - @Test("managed Cloud restrictions do not reveal or introduce Cloud") - func unavailableCloudDoesNothing() { - var events = 0 - SidebarCloudButtonAction( - isAvailable: { false }, - isEnabled: { false }, - revealMachines: { events += 1 }, - presentIntroduction: { events += 1 } - ).perform() - #expect(events == 0) - } -} diff --git a/cmuxTests/UpgradeAnalyticsTests.swift b/cmuxTests/UpgradeAnalyticsTests.swift deleted file mode 100644 index 33bcaaa1418e..000000000000 --- a/cmuxTests/UpgradeAnalyticsTests.swift +++ /dev/null @@ -1,320 +0,0 @@ -import AppKit -import CMUXMobileCore -import CmuxCloud -import CmuxProductAnalytics -import Foundation -import os -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -/// Records the events a ``ProductAnalytics`` sends, for app-level analytics tests. -private final class RecordingSink: AnalyticsEmitting { - // Written by the pipeline actor, read by the test after `drain()`. - private let state = OSAllocatedUnfairLock<[(String, [String: AnalyticsValue])]>(initialState: []) - var events: [(name: String, properties: [String: AnalyticsValue])] { state.withLock { $0.map { (name: $0.0, properties: $0.1) } } } - func named(_ name: String) -> [[String: AnalyticsValue]] { events.filter { $0.name == name }.map(\.properties) } - - func capture(_ event: String, _ properties: [String: AnalyticsValue]) { state.withLock { $0.append((event, properties)) } } - func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) {} - func setSuperProperties(_ properties: [String: AnalyticsValue]) {} - func flush() async {} -} - -private enum TestAnalytics { - static let fixedNow = Date(timeIntervalSince1970: 1_800_000_000) - - /// An emitter with its own ledger suite. Remove the suite with - /// ``remove(_:)`` once the test has drained the emitter. - static func make(enabled: Bool = true) -> (ProductAnalytics, RecordingSink, String) { - let sink = RecordingSink() - let suiteName = "UpgradeAnalyticsTests.\(UUID().uuidString)" - let analytics = ProductAnalytics( - isEnabled: enabled, - sink: sink, - ledger: ProductAnalyticsLedger(defaults: UserDefaults(suiteName: suiteName)!), - context: ProductAnalyticsContext(appVersion: "0.66.0", appBuild: "1", channel: "dev", osVersion: "15.5"), - isPreexistingInstall: true - ) - return (analytics, sink, suiteName) - } - - static func remove(_ suiteName: String) { - UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) - } -} - -@MainActor -@Suite("Upgrade analytics", .serialized, .exclusiveAppContext) -struct UpgradeAnalyticsTests { - @Test("the sidebar Upgrade badge records its impression, click and dismiss with its source") - func sidebarBadge() async { - let (analytics, sink, ledgerSuite) = TestAnalytics.make() - defer { TestAnalytics.remove(ledgerSuite) } - var opened: [ProUpgradeSource] = [] - let actions = ProBadgeActions(analytics: analytics, openPricing: { opened.append($0) }) - actions.appeared() - actions.appeared() - actions.upgrade() - actions.dismissed() - await analytics.drain() - - #expect(opened == [.sidebarBadge]) - #expect(sink.named("cmux_upgrade_prompt_shown").compactMap { $0["source"] } == [AnalyticsValue.string("mac_sidebar_badge")]) - let click = sink.named("cmux_upgrade_entrypoint_opened") - #expect(click.count == 1) - #expect(click.first?["source"] == .string("mac_sidebar_badge")) - #expect(click.first?["destination"] == .string("pricing_page")) - #expect(click.first?["plan"] == .string("pro")) - #expect(click.first?["client"] == .string("mac")) - #expect(click.first?["account_plan"] != nil) - #expect(sink.named("cmux_upgrade_badge_dismissed").compactMap { $0["source"] } == [AnalyticsValue.string("mac_sidebar_badge")]) - } - - @Test("the welcome card's Upgrade records shown, click, close and the checkout source", - arguments: [CloudWelcomeTrigger.launchAnnouncement, .newCloudWorkspace, .newCloudMachine]) - func welcomeUpgrade(trigger: CloudWelcomeTrigger) async throws { - let (analytics, sink, ledgerSuite) = TestAnalytics.make() - defer { TestAnalytics.remove(ledgerSuite) } - let suite = "UpgradeAnalyticsTests.welcome.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - var performed: [CloudWelcomeNextStep] = [] - let controller = CloudWelcomeWindowController( - defaults: defaults, - nextStep: { .upgrade }, - performNextStep: { step, _ in - performed.append(step) - // What the real `.upgrade` step does, minus opening pricing. - ProUpgradePresenter.present(source: .cloudWelcome, analytics: analytics, open: { _ in }) - }, - analytics: analytics, - now: { TestAnalytics.fixedNow } - ) - defer { controller.dismiss() } - controller.present(over: nil, trigger: trigger) - controller.handleNext(.upgrade) - await analytics.drain() - - #expect(performed == [.upgrade]) - let shown = try #require(sink.named("cmux_cloud_welcome_shown").first) - #expect(shown["trigger"] == .string(trigger.rawValue)) - #expect(shown["next_step"] == .string("upgrade")) - #expect(sink.named("cmux_upgrade_prompt_shown").compactMap { $0["source"] } == [AnalyticsValue.string("mac_cloud_welcome")]) - let click = try #require(sink.named("cmux_cloud_welcome_button_clicked").first) - #expect(click["button"] == .string("upgrade")) - #expect(click["trigger"] == .string(trigger.rawValue)) - let closed = sink.named("cmux_cloud_welcome_closed") - #expect(closed.count == 1) - #expect(closed.first?["outcome"] == .string("upgrade")) - #expect(sink.named("cmux_upgrade_entrypoint_opened").compactMap { $0["source"] } == [AnalyticsValue.string("mac_cloud_welcome")]) - let order = sink.events.map(\.name).filter { $0.hasPrefix("cmux_cloud_welcome") || $0 == "cmux_upgrade_entrypoint_opened" } - #expect(order == [ - "cmux_cloud_welcome_shown", - "cmux_cloud_welcome_button_clicked", - "cmux_cloud_welcome_closed", - "cmux_upgrade_entrypoint_opened", - ]) - } - - @Test("Enable, Not Now and a plain window close each end the card once") - func welcomeOtherOutcomes() async throws { - for (step, act, button, outcome) in [ - (CloudWelcomeNextStep.enable, "next", "enable", "enable"), - (.signIn, "next", "sign_in", "sign_in"), - (.upgrade, "notNow", "not_now", "not_now"), - (.upgrade, "close", nil, "window_closed"), - ] as [(CloudWelcomeNextStep, String, String?, String)] { - let (analytics, sink, ledgerSuite) = TestAnalytics.make() - defer { TestAnalytics.remove(ledgerSuite) } - let suite = "UpgradeAnalyticsTests.outcome.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let controller = CloudWelcomeWindowController( - defaults: defaults, - nextStep: { step }, - performNextStep: { _, _ in }, - analytics: analytics, - now: { TestAnalytics.fixedNow } - ) - controller.present(over: nil, trigger: .newCloudMachine) - switch act { - case "next": controller.handleNext(step) - case "notNow": controller.handleNotNow() - default: controller.dismiss() - } - controller.dismiss() - await analytics.drain() - - let buttons = sink.named("cmux_cloud_welcome_button_clicked").compactMap { $0["button"] } - let expectedButtons: [AnalyticsValue] = button.map { [AnalyticsValue.string($0)] } ?? [] - #expect(buttons == expectedButtons) - let closed = sink.named("cmux_cloud_welcome_closed") - #expect(closed.count == 1, "\(outcome)") - #expect(closed.first?["outcome"] == .string(outcome)) - #expect(closed.first?["visible_for"] == .string("lt_1s")) - } - } - - @Test("the sidebar Cloud button records its route; while Cloud is off the welcome follows with its trigger") - func sidebarCloudButton() async throws { - let (analytics, sink, ledgerSuite) = TestAnalytics.make() - defer { TestAnalytics.remove(ledgerSuite) } - let suite = "UpgradeAnalyticsTests.cloudButton.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let welcome = CloudWelcomeWindowController( - defaults: defaults, - nextStep: { .upgrade }, - performNextStep: { _, _ in }, - analytics: analytics, - now: { TestAnalytics.fixedNow } - ) - defer { welcome.dismiss() } - - SidebarCloudButtonAction( - isAvailable: { true }, isEnabled: { true }, revealMachines: {}, presentIntroduction: {}, - analytics: analytics - ).perform() - SidebarCloudButtonAction( - isAvailable: { true }, isEnabled: { false }, revealMachines: {}, - presentIntroduction: { welcome.presentIntroduction(over: nil) }, - analytics: analytics - ).perform() - SidebarCloudButtonAction( - isAvailable: { false }, isEnabled: { false }, revealMachines: {}, presentIntroduction: {}, - analytics: analytics - ).perform() - await analytics.drain() - - #expect(sink.named("cmux_cloud_button_clicked").compactMap { $0["action"] } - == [AnalyticsValue.string("open_cloud"), .string("introduce_cloud")]) - let shown = try #require(sink.named("cmux_cloud_welcome_shown").first) - #expect(shown["trigger"] == .string("sidebar_cloud_button")) - #expect(shown["next_step"] == .string("upgrade")) - let order = sink.events.map(\.name).filter { $0 == "cmux_cloud_button_clicked" || $0 == "cmux_cloud_welcome_shown" } - #expect(order == ["cmux_cloud_button_clicked", "cmux_cloud_button_clicked", "cmux_cloud_welcome_shown"]) - } - - @Test("a card that turns into Upgrade after the plan loads records the Upgrade impression once") - func welcomeUpgradeImpressionAfterPlanLoads() async throws { - let (analytics, sink, ledgerSuite) = TestAnalytics.make() - defer { TestAnalytics.remove(ledgerSuite) } - let suite = "UpgradeAnalyticsTests.planLoads.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - var step = CloudWelcomeNextStep.openCloud - let controller = CloudWelcomeWindowController( - defaults: defaults, - nextStep: { step }, - performNextStep: { _, _ in }, - analytics: analytics, - now: { TestAnalytics.fixedNow } - ) - defer { controller.dismiss() } - controller.present(over: nil, trigger: .newCloudWorkspace) - await analytics.drain() - #expect(sink.named("cmux_upgrade_prompt_shown").isEmpty) - - step = .upgrade - controller.analyticsSession?.stepDisplayed(.upgrade) - controller.analyticsSession?.stepDisplayed(.upgrade) - controller.handleNotNow() - await analytics.drain() - #expect(sink.named("cmux_upgrade_prompt_shown").compactMap { $0["source"] } == [AnalyticsValue.string("mac_cloud_welcome")]) - #expect(sink.named("cmux_cloud_welcome_shown").first?["next_step"] == .string("open_cloud")) - #expect(sink.named("cmux_cloud_welcome_closed").first?["next_step"] == .string("upgrade")) - } - - @Test("with telemetry off, no upgrade surface records anything") - func optOut() async throws { - let (analytics, sink, ledgerSuite) = TestAnalytics.make(enabled: false) - defer { TestAnalytics.remove(ledgerSuite) } - let actions = ProBadgeActions(analytics: analytics, openPricing: { _ in }) - actions.appeared() - actions.upgrade() - actions.dismissed() - let suite = "UpgradeAnalyticsTests.optout.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let controller = CloudWelcomeWindowController( - defaults: defaults, - nextStep: { .upgrade }, - performNextStep: { _, _ in }, - analytics: analytics, - now: { TestAnalytics.fixedNow } - ) - controller.present(over: nil, trigger: .launchAnnouncement) - controller.handleNotNow() - await analytics.drain() - #expect(sink.events.isEmpty) - } -} - -@Suite("Account analytics transitions") -struct AccountAnalyticsStateTests { - @Test("restore identifies without a sign-in success; a started sign-in succeeds once") - func signInLifecycle() { - var state = AccountAnalyticsState() - let restoring = state.observe(userID: nil, isSettling: true, failure: nil) - let restored = state.observe(userID: "u1", isSettling: false, failure: nil) - let signedOut = state.observe(userID: nil, isSettling: false, failure: nil) - state.signInStarted() - let waiting = state.observe(userID: nil, isSettling: true, failure: nil) - let signedIn = state.observe(userID: "u2", isSettling: false, failure: nil) - let unchanged = state.observe(userID: "u2", isSettling: false, failure: nil) - #expect(restoring.isEmpty) - #expect(restored == [.identify(userID: "u1")]) - #expect(signedOut == [.signedOut]) - #expect(waiting.isEmpty) - #expect(signedIn == [.identify(userID: "u2"), .signInSucceeded]) - #expect(unchanged.isEmpty) - } - - @Test("a failure is reported once per started sign-in") - func signInFailure() { - var state = AccountAnalyticsState() - let unstarted = state.observe(userID: nil, isSettling: false, failure: .timedOut) - state.signInStarted() - let failed = state.observe(userID: nil, isSettling: false, failure: .timedOut) - let repeated = state.observe(userID: nil, isSettling: false, failure: .timedOut) - #expect(unstarted.isEmpty) - #expect(failed == [.signInFailed(.timedOut)]) - #expect(repeated.isEmpty) - #expect(AccountAnalyticsState.reason(.unknown) == .network) - #expect(AccountAnalyticsState.reason(nil) == nil) - } -} - -@MainActor -@Suite("Machine create analytics", .serialized) -struct MachineCreateAnalyticsTests { - @Test("a refused create reports its plan limit, and the retry that succeeds reports one create") - func refusalThenRetry() async throws { - let (analytics, sink, ledgerSuite) = TestAnalytics.make() - defer { TestAnalytics.remove(ledgerSuite) } - let launches = MachineCreateCoordinatorTests.LaunchRecorder() - let coordinator = MachineCreateCoordinator( - notifier: { _ in }, - notificationCenter: NotificationCenter(), - analytics: analytics - ) - coordinator.start(MachineCreateCoordinatorTests.newMachineRequest(), launch: launches.launch) - let id = try #require(coordinator.operations.first?.id) - launches.complete(status: 1, output: "Error: limit", refusalCode: "vm_active_limit_exceeded") - #expect(coordinator.retry(id)) - launches.complete(status: 0, output: "OK machine=m1", machineID: "m1") - await analytics.drain() - - let failed = sink.named("cmux_cloud_machine_create_failed") - #expect(failed.count == 1) - #expect(failed.first?["error_code"] == .string("vm_active_limit_exceeded")) - #expect(failed.first?["error_class"] == .string("plan_limit")) - #expect(sink.named("cmux_cloud_plan_limit_hit").compactMap { $0["limit"] } == [AnalyticsValue.string("active_machines")]) - #expect(sink.named("cmux_cloud_machine_create_succeeded").count == 1) - } -} diff --git a/cmuxUITests/BrowserFixtures/return-submit.html b/cmuxUITests/BrowserFixtures/return-submit.html deleted file mode 100644 index 023033eabe95..000000000000 --- a/cmuxUITests/BrowserFixtures/return-submit.html +++ /dev/null @@ -1,28 +0,0 @@ -<!doctype html> -<html lang="en"> -<meta charset="utf-8"> -<title>Return submission fixture -
- - - -
-

Submissions: 0

-

Trusted Return: 0

-

Trusted Command Return: 0

- - diff --git a/cmuxUITests/BrowserReturnSubmissionUITests.swift b/cmuxUITests/BrowserReturnSubmissionUITests.swift deleted file mode 100644 index 1ad30a7638b2..000000000000 --- a/cmuxUITests/BrowserReturnSubmissionUITests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import XCTest - -/// Native keys must traverse the app/window routing before reaching WebKit. -/// Socket browser.press bypasses that routing and cannot cover this regression. -final class BrowserReturnSubmissionUITests: BrowserFixtureSocketTestCase { - func testReturnSubmitsFormAndCommandReturnReachesPage() throws { - let app = try launchApp() - let surfaceID = try openFixture("return-submit") - let field = app.webViews.textFields["Account name"].firstMatch - XCTAssertTrue(field.waitForExistence(timeout: 15)) - field.click() - app.typeText("browser-regression") - XCTAssertEqual(try evalString("document.activeElement.id", surfaceID: surfaceID), "account") - XCTAssertEqual(try evalString("document.getElementById('account').value", surfaceID: surfaceID), "browser-regression") - - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - XCTAssertTrue(app.webViews.staticTexts["Submissions: 1"].firstMatch.waitForExistence(timeout: 5)) - XCTAssertTrue(app.webViews.staticTexts["Trusted Return: 1"].firstMatch.waitForExistence(timeout: 5)) - - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command]) - XCTAssertTrue(app.webViews.staticTexts["Trusted Command Return: 1"].firstMatch.waitForExistence(timeout: 5)) - XCTAssertEqual(try evalString("document.activeElement.id", surfaceID: surfaceID), "account") - } -} diff --git a/tests/test_opencode_server_entry.py b/tests/test_opencode_server_entry.py deleted file mode 100644 index 3862e51d9d10..000000000000 --- a/tests/test_opencode_server_entry.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -"""Released OpenCode loads cmux through each plugin's default `server` entry. - -OpenCode 1.x (including the current dev channel) calls `default.server(ctx)` -inside the TUI process with `{ client, project, worktree, directory, -serverUrl, $ }` and delivers events through the returned `event` hook. It -loads `cmux-feed.js`, `cmux-session.js` and `plugins/cmux/index.js`, so the -server entries must deliver one Stop per turn instead of returning `{}`. -""" -from __future__ import annotations - -import json -import os -import re -import shutil -import subprocess -import tempfile -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - - -def session_plugin_source() -> str: - swift = (ROOT / "CLI/cmux.swift").read_text(encoding="utf-8") - match = re.search(r'openCodeSessionPluginSource = #"""\n(.*?)\n"""#', swift, re.S) - if match is None: - raise RuntimeError("openCodeSessionPluginSource not found") - return match.group(1) - - -HARNESS = r''' -const { pluginDir, logPath, socketPath, fakeCmux } = JSON.parse(process.env.CMUX_OPENCODE_SERVER_HARNESS); -const fs = await import("node:fs"); -const net = await import("node:net"); -const path = await import("node:path"); - -const frames = []; -const server = net.createServer((connection) => { - connection.setEncoding("utf8"); - connection.on("error", () => {}); - let buffered = ""; - connection.on("data", (chunk) => { - buffered += chunk; - let index; - while ((index = buffered.indexOf("\n")) >= 0) { - const line = buffered.slice(0, index); - buffered = buffered.slice(index + 1); - if (!line) continue; - const frame = JSON.parse(line); - frames.push(frame); - connection.write(JSON.stringify({ id: frame.id, ok: true, result: {} }) + "\n"); - } - }); -}); -await new Promise((resolve) => server.listen(socketPath, resolve)); - -// Shape observed from OpenCode 1.19.0 and the dev channel. -const ctx = { - client: {}, - project: { id: "project" }, - worktree: "/tmp", - directory: "/tmp", - experimental_workspace: {}, - serverUrl: new URL("http://127.0.0.1:4096"), - $: () => {}, -}; - -// Old V2 snapshots called setup()/server() without the server context; they -// must stay inert because their shared service is not owned by one TUI. -const v2 = await import(path.join(pluginDir, "cmux", "index.js")); -const v2Hooks = await v2.default.server({}); -if (v2Hooks && typeof v2Hooks.event === "function") throw new Error("V2 shared server entry subscribed"); -const v2Cleanup = await v2.default.setup({}); -if (typeof v2Cleanup !== "function") throw new Error("V2 setup did not return cleanup"); -v2Cleanup(); - -const { execFileSync } = await import("node:child_process"); -const hookLog = () => (fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""); -const fakeCmuxRunning = () => { - try { - execFileSync("pgrep", ["-f", fakeCmux], { stdio: "ignore" }); - return true; - } catch (_) { - return false; - } -}; -const waitFor = async (predicate, message) => { - const deadline = Date.now() + 30000; - while (!predicate()) { - if (Date.now() > deadline) throw new Error(message); - await new Promise((resolve) => setTimeout(resolve, 20)); - } -}; - -// Load the three cmux entries for one plugin scope, as OpenCode does. -const loadScope = async (scopeCtx) => { - const hooks = []; - for (const file of ["cmux-feed.js", "cmux-session.js", path.join("cmux", "index.js")]) { - const mod = await import(path.join(pluginDir, file)); - if (typeof mod.default?.server !== "function") throw new Error(`${file} lacks a server entry`); - hooks.push(await mod.default.server(scopeCtx, {})); - } - const active = hooks.filter((hook) => hook && typeof hook.event === "function"); - if (active.length === 0) throw new Error("no OpenCode server entry subscribed to events"); - return active; -}; - -let sessionEndsExpected = 0; -const sessionEnds = () => hookLog().split("\n").filter((line) => line.includes("opencode session-end")).length; -const stopHookLines = () => hookLog().split("\n").filter((line) => line.includes("hooks enqueue opencode stop")); - -// Drive one turn. The session.deleted sentinel follows the duplicate-prone -// idle events: socket frames are ordered, and every hook process is spawned -// before the sentinel's. -const runTurn = async (scope, sid, { midTurn = [] } = {}) => { - const emit = async (event) => { - for (const hook of scope) await hook.event({ event }); - }; - await emit({ type: "session.created", properties: { info: { id: sid, directory: "/tmp" } } }); - await emit({ type: "session.status", properties: { sessionID: sid, status: { type: "busy" } } }); - for (const event of midTurn) await emit(event); - await emit({ type: "session.status", properties: { sessionID: sid, status: { type: "idle" } } }); - await emit({ type: "session.idle", properties: { sessionID: sid } }); - await emit({ type: "session.deleted", properties: { info: { id: sid } } }); - sessionEndsExpected++; - - const forSession = (frame) => frame.params?.event?.session_id === `opencode-${sid}`; - await waitFor( - () => frames.some((frame) => forSession(frame) && frame.params.event.hook_event_name === "SessionEnd"), - `feed sentinel never arrived for ${sid}: ${JSON.stringify(frames)}`, - ); - await waitFor(() => sessionEnds() >= sessionEndsExpected, `session-end hook never ran for ${sid}`); - await waitFor(() => !fakeCmuxRunning(), "hook processes did not exit"); - const stops = frames.filter((frame) => forSession(frame) && frame.params.event.hook_event_name === "Stop"); - if (stops.length !== 1) throw new Error(`expected one feed Stop for ${sid}, got ${stops.length}: ${JSON.stringify(frames)}`); - if (stops[0].params.event.surface_id !== "surface-server-entry") throw new Error("feed Stop lost the surface id"); - return stops[0]; -}; - -const first = await loadScope(ctx); -// Every loader in one scope receives the same ctx; a repeat load is a no-op. -if ((await loadScope(ctx).catch(() => [])).length !== 0) throw new Error("duplicate loader in one scope subscribed"); -await runTurn(first, "ses_server_entry"); -// OpenCode keeps one scope per directory; a second live scope must not evict -// the first. -const second = await loadScope({ ...ctx, directory: "/tmp/other" }); -await runTurn(second, "ses_server_entry_second"); -await runTurn(first, "ses_server_entry_concurrent"); - -// Esc publishes session.error(MessageAbortedError) and then goes idle. -const aborted = await runTurn(first, "ses_server_entry_abort", { - midTurn: [{ type: "session.error", properties: { sessionID: "ses_server_entry_abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } } }], -}); -if (aborted.params.event.is_error) throw new Error("an interrupt was reported as a failed turn"); -if (stopHookLines().some((line) => line.includes("StopFailure"))) throw new Error("an interrupt sent a StopFailure hook"); - -// OpenCode disposes a torn-down scope's hooks; they must not emit afterwards. -for (const hook of first) await hook.dispose?.(); -const stale = "ses_server_entry_stale"; -for (const event of [ - { type: "session.created", properties: { info: { id: stale, directory: "/tmp" } } }, - { type: "session.status", properties: { sessionID: stale, status: { type: "busy" } } }, - { type: "session.idle", properties: { sessionID: stale } }, -]) { - for (const hook of first) await hook.event({ event }); -} -// Hook processes are spawned synchronously inside event(); wait for them. -await waitFor(() => !fakeCmuxRunning(), "hook processes did not exit"); -if (frames.some((frame) => frame.params?.event?.session_id === `opencode-${stale}`)) { - throw new Error("a disposed feed scope still emitted events"); -} -if (stopHookLines().length !== 4) throw new Error(`expected one stop hook per turn, got ${stopHookLines().length}: ${hookLog()}`); -for (const hook of second) await hook.dispose?.(); -server.close(); -console.log("PASS: OpenCode server entry delivers one completion"); -process.exit(0); -''' - - -def main() -> int: - node = shutil.which("node") - if node is None: - print("SKIP: node not found") - return 0 - with tempfile.TemporaryDirectory(prefix="cmux-opencode-server-") as td: - root = Path(td) - plugin_dir = root / "plugins" - package_dir = plugin_dir / "cmux" - package_dir.mkdir(parents=True) - feed_source = (ROOT / "Resources/opencode-plugin.js").read_text(encoding="utf-8") - (plugin_dir / "cmux-feed.js").write_text(feed_source, encoding="utf-8") - (plugin_dir / "cmux-session.js").write_text(session_plugin_source(), encoding="utf-8") - (package_dir / "index.js").write_text( - "// cmux-opencode-tui-plugin-server-marker v2\n" + feed_source, encoding="utf-8" - ) - (plugin_dir / "package.json").write_text('{"type":"module"}', encoding="utf-8") - (package_dir / "package.json").write_text('{"type":"module"}', encoding="utf-8") - log_path = root / "cmux.log" - fake_cmux = root / "cmux" - fake_cmux.write_text(f'#!/bin/sh\necho "$* $(cat)" >> "{log_path}"\n', encoding="utf-8") - fake_cmux.chmod(0o755) - socket_path = str(root / "cmux.sock") - env = os.environ.copy() - env.update( - CMUX_SURFACE_ID="surface-server-entry", - CMUX_WORKSPACE_ID="workspace-server-entry", - CMUX_SOCKET_PATH=socket_path, - CMUX_OPENCODE_CMUX_BIN=str(fake_cmux), - CMUX_OPENCODE_HOOKS_DISABLED="", - CMUX_OPENCODE_SERVER_HARNESS=json.dumps( - {"pluginDir": str(plugin_dir), "logPath": str(log_path), "socketPath": socket_path, "fakeCmux": str(fake_cmux)} - ), - ) - harness = root / "harness.mjs" - harness.write_text(HARNESS, encoding="utf-8") - try: - result = subprocess.run([node, str(harness)], env=env, capture_output=True, text=True, timeout=30) - finally: - try: - os.unlink(socket_path) - except FileNotFoundError: - pass - sys_out = (result.stdout + result.stderr).strip() - if result.returncode != 0: - print(f"FAIL: {sys_out[-3000:]}") - return 1 - print(sys_out) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 55965c4a5b11cceb84c7ca3ca61b01af01598298 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:30:36 -0700 Subject: [PATCH 085/183] Regenerate settings schema, MDM manifests, action exports and settings page strings on nx-grow-land after merging tip 8369646f Exports from nx-remote job 1014-172503-89b0d1 (Xcode 27, regenerate-swift-exports.sh on a8d901e2), page strings from gen-strings.mjs on a Freestyle VM. The tip's action export was stale too. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/mdm/com.manaflow.cmux.json | 3 +- docs/mdm/com.manaflow.cmux.plist | 1 + docs/mdm/managed-preferences.md | 2 +- plans/cmux-next/action-surfaces.json | 2677 ++++++++++++----- plans/cmux-next/actions.md | 18 +- schemas/settings/settings-schema.json | 16 +- .../src/pages/settings/generated/strings.json | 21 + 7 files changed, 1957 insertions(+), 781 deletions(-) diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index f13f51d80603..0e4ea2a0e641 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -594,7 +594,8 @@ "flat-sidebar-line", "bare", "panes", - "two-tone" + "two-tone", + "floating" ], "title" : "Window Style", "type" : "string" diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index ee9dc0374827..d84662a5829d 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -1835,6 +1835,7 @@ bare panes two-tone + floating pfm_title Window Style diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index d9be4e464ec9..ff241f0f7b1d 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -103,7 +103,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `appearance.focusIndicator` | string | `"both"` | `border`, `tabs`, `both`, `none` | Focused Pane. How the focused pane stands out: its border, subtler tabs in the other panes, both or neither. | | `focus.inactiveTabStyle` | string | `"fade"` | `fade`, `tonal`, `quiet` | Unfocused Pane Tabs. How the other panes' tabs draw subtler when Focused Pane marks tabs: Fade dims them, Tonal steps their text down, Quiet drops the selected pill. | | `ui.animationSpeed` | string | `"fast"` | `fast`, `normal`, `off` | Animations | -| `appearance.chromeStyle` | string | `"default"` | `default`, `card`, `card-outlined`, `flat`, `flat-sidebar-line`, `bare`, `panes`, `two-tone` | Window Style. The look of the window: whether panes sit in one rounded card, and where lines are drawn. Separation, Sidebar Background and the sidebar border still win when you set them. | +| `appearance.chromeStyle` | string | `"default"` | `default`, `card`, `card-outlined`, `flat`, `flat-sidebar-line`, `bare`, `panes`, `two-tone`, `floating` | Window Style. The look of the window: whether panes sit in one rounded card, and where lines are drawn. Separation, Sidebar Background and the sidebar border still win when you set them. | | `ui.paneAnimations` | boolean | `false` | | Animate Panes. Splits, closes, moves, zooms, column scrolls and screen switches slide into place. Off: they change at once. | | `ui.animations.newTab` | boolean | `true` | | Animate New Tabs. A new tab grows into its place in the tab bar. Off shows it there at once. | | `ui.animations.newWorkspace` | boolean | `false` | | Animate New Workspaces. A new workspace row opens in the sidebar. Off shows it there at once. | diff --git a/plans/cmux-next/action-surfaces.json b/plans/cmux-next/action-surfaces.json index 23e0e1cb44d7..cf27e7311728 100644 --- a/plans/cmux-next/action-surfaces.json +++ b/plans/cmux-next/action-surfaces.json @@ -18,6 +18,7 @@ ] }, "id" : "openSettings", + "main_menu" : "app", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -51,6 +52,7 @@ ] }, "id" : "newWindow", + "main_menu" : "window", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -85,6 +87,7 @@ ] }, "id" : "newIncognitoWindow", + "main_menu" : "window", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -118,6 +121,7 @@ ] }, "id" : "closeWindow", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -145,6 +149,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "window.closeOthers", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -172,6 +177,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "closeAllWindows", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -199,6 +205,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "zoomWindow", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -226,6 +233,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "selectNextWindow", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -253,6 +261,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "selectPreviousWindow", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -280,6 +289,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "minimizeWindow", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -313,6 +323,7 @@ ] }, "id" : "toggleFullScreen", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -345,6 +356,7 @@ ] }, "id" : "quit", + "main_menu" : "app", "mcp" : "endsApp", "palette" : "offered", "palette_section" : { @@ -372,6 +384,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "quitKeepSessions", + "main_menu" : "app", "mcp" : "endsApp", "palette" : "offered", "palette_section" : { @@ -399,6 +412,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "quitEndSessions", + "main_menu" : "app", "mcp" : "endsApp", "palette" : "offered", "palette_section" : { @@ -426,6 +440,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "quitEndEverything", + "main_menu" : "app", "mcp" : "endsApp", "palette" : "offered", "palette_section" : { @@ -460,6 +475,7 @@ ] }, "id" : "showHideAllWindows", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -493,6 +509,7 @@ ] }, "id" : "globalSearch", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -520,6 +537,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.open", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -553,6 +571,7 @@ ] }, "id" : "commandPalette", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -595,6 +614,7 @@ ] }, "id" : "commandPaletteNext", + "main_menu" : null, "mcp" : "paletteInternal", "palette" : "paletteInternal", "palette_section" : { @@ -637,6 +657,7 @@ ] }, "id" : "commandPalettePrevious", + "main_menu" : null, "mcp" : "paletteInternal", "palette" : "paletteInternal", "palette_section" : { @@ -669,6 +690,7 @@ ] }, "id" : "goToWorkspace", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -696,6 +718,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openTaskManager", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -723,6 +746,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.sleepyMode", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -750,6 +774,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "keepMacAwake", + "main_menu" : "app", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -777,6 +802,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "showMainWindow", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -804,6 +830,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "about", + "main_menu" : "app", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -831,6 +858,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "taskManager.killProcess", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -863,6 +891,7 @@ ] }, "id" : "newTab", + "main_menu" : "file", "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -896,6 +925,7 @@ ] }, "id" : "newBrowserWorkspace", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -928,6 +958,7 @@ ] }, "id" : "openFolder", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -955,6 +986,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openFolderInVSCodeInline", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -988,6 +1020,7 @@ ] }, "id" : "reopenPreviousSession", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1015,6 +1048,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "reopenClosedWorkspace", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1094,6 +1128,7 @@ ] }, "id" : "nextSidebarTab", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -1173,6 +1208,7 @@ ] }, "id" : "prevSidebarTab", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -1200,6 +1236,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "nextSidebarTabInGroup", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -1227,6 +1264,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "prevSidebarTabInGroup", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -1261,6 +1299,7 @@ ] }, "id" : "moveWorkspaceUp", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1295,6 +1334,7 @@ ] }, "id" : "moveWorkspaceDown", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1322,6 +1362,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.moveWorkspaceToTop", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1453,6 +1494,7 @@ ] }, "id" : "selectWorkspaceByNumber", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -1480,6 +1522,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "moveWorkspaceToWindow", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1507,6 +1550,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "moveWorkspaceToNewWindow", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1540,6 +1584,7 @@ ] }, "id" : "renameWorkspace", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1567,6 +1612,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.clearWorkspaceName", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1600,6 +1646,7 @@ ] }, "id" : "editWorkspaceDescription", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1627,6 +1674,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.clearWorkspaceDescription", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1659,6 +1707,7 @@ ] }, "id" : "markWorkspaceDone", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1692,6 +1741,7 @@ ] }, "id" : "cycleWorkspaceStatus", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -1719,6 +1769,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.workspaceStatus", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1746,6 +1797,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.addWorkspaceChecklistItem", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1778,6 +1830,7 @@ ] }, "id" : "toggleChecklistItemComplete", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -1805,6 +1858,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openWorkspaceTodoPane", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1838,6 +1892,7 @@ ] }, "id" : "closeWorkspace", + "main_menu" : "file", "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -1865,6 +1920,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.closeOtherWorkspaces", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1892,6 +1948,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.closeWorkspacesBelow", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1919,6 +1976,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.closeWorkspacesAbove", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1946,6 +2004,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.closeAll", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -1973,6 +2032,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.closeAllInAllWindows", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2000,6 +2060,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.toggleWorkspacePin", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2027,6 +2088,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.markWorkspaceRead", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2054,6 +2116,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.markWorkspaceUnread", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2081,6 +2144,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.workspaceColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2108,6 +2172,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.resetWorkspaceColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2135,6 +2200,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "reconnectWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2162,6 +2228,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "disconnectWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2189,6 +2256,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "copyWorkspaceSSHError", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -2216,6 +2284,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "clearWorkspaceNotifications", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2243,6 +2312,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "revealWorkspaceInFinder", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -2270,6 +2340,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.copyWorkspaceID", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -2297,6 +2368,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.copyWorkspaceIDAndRef", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -2324,6 +2396,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.copyWorkspaceLink", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -2351,6 +2424,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.newWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2378,6 +2452,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.rename", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2405,6 +2480,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.togglePin", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2432,6 +2508,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.markRead", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2459,6 +2536,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.markUnread", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2486,6 +2564,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.clearNotifications", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2513,6 +2592,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.ungroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2540,6 +2620,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.delete", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2567,6 +2648,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.editConfig", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -2600,6 +2682,7 @@ ] }, "id" : "saveLayoutTemplate", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2627,6 +2710,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.layout.open", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2654,6 +2738,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "manageLayouts", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -2681,6 +2766,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openWorkspacePullRequests", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -2708,6 +2794,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.findWork", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -2735,6 +2822,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.importClassic", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -2762,6 +2850,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.newAbove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2789,6 +2878,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.newBelow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2816,6 +2906,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.newAtTop", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2843,6 +2934,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.newAtBottom", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2870,6 +2962,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.newLikeThis", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2897,6 +2990,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.newInNewGroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2924,6 +3018,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.newOnMachine", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2951,6 +3046,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.duplicate", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -2978,6 +3074,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.duplicateTerminalsOnly", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3005,6 +3102,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3032,6 +3130,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3059,6 +3158,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.toggleTop", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3086,6 +3186,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.moveToBottom", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3113,6 +3214,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.moveToNewGroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3140,6 +3242,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.closeOthersInGroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3167,6 +3270,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.selectFirst", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -3194,6 +3298,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.selectLast", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -3227,6 +3332,7 @@ ] }, "id" : "workspace.selectLastUsed", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -3254,6 +3360,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.sortByName", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3281,6 +3388,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.sortByLastUsed", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3308,6 +3416,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.sortByDirectory", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3336,6 +3445,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.collapseAll", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3364,6 +3474,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.expandAll", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3391,6 +3502,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.copyPath", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -3418,6 +3530,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.mergeInto", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3445,6 +3558,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "pane.moveToNewWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3478,6 +3592,7 @@ ] }, "id" : "newWorkspaceGroup", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3511,6 +3626,7 @@ ] }, "id" : "groupSelectedWorkspaces", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -3544,6 +3660,7 @@ ] }, "id" : "toggleFocusedWorkspaceGroupCollapsed", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -3579,6 +3696,7 @@ ] }, "id" : "nextWorkspaceGroup", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -3614,6 +3732,7 @@ ] }, "id" : "prevWorkspaceGroup", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -3641,6 +3760,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "moveWorkspaceToGroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3668,6 +3788,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "removeWorkspaceFromGroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3695,6 +3816,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.setColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3722,6 +3844,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3749,6 +3872,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3776,6 +3900,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.copyID", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -3803,6 +3928,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.addToSection", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3830,6 +3956,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.collapse", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3857,6 +3984,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.expand", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3884,6 +4012,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.moveUp", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3911,6 +4040,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.moveDown", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3938,6 +4068,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.moveToWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3965,6 +4096,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.closeWorkspaces", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -3992,6 +4124,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.closeOthers", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4019,6 +4152,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.duplicate", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4046,6 +4180,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.moveToNewWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4073,6 +4208,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.grey", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4100,6 +4236,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.blue", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4127,6 +4264,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.red", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4154,6 +4292,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.yellow", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4181,6 +4320,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.green", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4208,6 +4348,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.pink", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4235,6 +4376,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.purple", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4262,6 +4404,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.cyan", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4289,6 +4432,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.color.orange", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4316,6 +4460,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.new", + "main_menu" : "window", "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -4335,7 +4480,7 @@ "cli_name" : "space new-window", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4343,6 +4488,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.newWindow", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4362,7 +4508,7 @@ "cli_name" : "space new-workspace", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4370,6 +4516,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.newWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4389,7 +4536,7 @@ "cli_name" : "space rename", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4397,6 +4544,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.rename", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4416,7 +4564,7 @@ "cli_name" : "space set-color", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4424,6 +4572,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.setColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4443,7 +4592,7 @@ "cli_name" : "space color-grey", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4451,6 +4600,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.grey", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4470,7 +4620,7 @@ "cli_name" : "space color-blue", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4478,6 +4628,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.blue", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4497,7 +4648,7 @@ "cli_name" : "space color-red", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4505,6 +4656,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.red", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4524,7 +4676,7 @@ "cli_name" : "space color-yellow", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4532,6 +4684,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.yellow", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4551,7 +4704,7 @@ "cli_name" : "space color-green", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4559,6 +4712,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.green", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4578,7 +4732,7 @@ "cli_name" : "space color-pink", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4586,6 +4740,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.pink", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4605,7 +4760,7 @@ "cli_name" : "space color-purple", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4613,6 +4768,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.purple", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4632,7 +4788,7 @@ "cli_name" : "space color-cyan", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4640,6 +4796,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.cyan", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4659,7 +4816,7 @@ "cli_name" : "space color-orange", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4667,6 +4824,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.color.orange", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -4686,7 +4844,7 @@ "cli_name" : "space clear-color", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4694,6 +4852,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.clearColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4713,7 +4872,7 @@ "cli_name" : "space set-icon", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4721,6 +4880,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4740,7 +4900,7 @@ "cli_name" : "space clear-icon", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4748,6 +4908,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4775,6 +4936,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.setDefaults", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4794,7 +4956,7 @@ "cli_name" : "space new-group", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4802,6 +4964,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.newGroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4821,7 +4984,7 @@ "cli_name" : "space delete", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -4829,6 +4992,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.delete", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4856,6 +5020,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.moveLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4883,6 +5048,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.moveRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4910,6 +5076,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.move", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -4943,6 +5110,7 @@ ] }, "id" : "space.next", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -4976,6 +5144,7 @@ ] }, "id" : "space.previous", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5010,6 +5179,7 @@ ] }, "id" : "space.selectByNumber", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5037,6 +5207,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.switch", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5064,6 +5235,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.moveToSpace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5091,6 +5263,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.duplicateToSpace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5118,6 +5291,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspaceGroup.moveToSpace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5137,7 +5311,7 @@ "cli_name" : "space set-theme", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -5145,6 +5319,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.setTheme", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5164,7 +5339,7 @@ "cli_name" : "space clear-theme", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -5172,6 +5347,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "space.clearTheme", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5200,6 +5376,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.setTheme", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5227,6 +5404,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.clearTheme", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5254,6 +5432,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.setTheme", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5281,6 +5460,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.clearTheme", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5314,6 +5494,7 @@ ] }, "id" : "splitRight", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5347,6 +5528,7 @@ ] }, "id" : "newColumn", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5381,6 +5563,7 @@ ] }, "id" : "newRow", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5415,6 +5598,7 @@ ] }, "id" : "splitDown", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5448,6 +5632,7 @@ ] }, "id" : "newPaneAutoLayout", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5482,6 +5667,7 @@ ] }, "id" : "toggleSplitZoom", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -5509,6 +5695,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "pane.toggleTabBar", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5543,6 +5730,7 @@ ] }, "id" : "equalizeSplits", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -5587,6 +5775,7 @@ ] }, "id" : "resizePaneLeft", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -5631,6 +5820,7 @@ ] }, "id" : "resizePaneRight", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -5675,6 +5865,7 @@ ] }, "id" : "resizePaneUp", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -5719,6 +5910,7 @@ ] }, "id" : "resizePaneDown", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -5752,6 +5944,7 @@ ] }, "id" : "focusLeft", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5785,6 +5978,7 @@ ] }, "id" : "focusRight", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5829,6 +6023,7 @@ ] }, "id" : "focusUp", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5873,6 +6068,7 @@ ] }, "id" : "focusDown", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5900,6 +6096,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "focusPreviousPane", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5927,6 +6124,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "focusNextPane", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5961,6 +6159,7 @@ ] }, "id" : "focusPaneByNumber", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -5994,6 +6193,7 @@ ] }, "id" : "triggerFlash", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -6021,6 +6221,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.swapWithSession", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -6053,6 +6254,7 @@ ] }, "id" : "increaseWorkspaceTerminalFontSize", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -6085,6 +6287,7 @@ ] }, "id" : "decreaseWorkspaceTerminalFontSize", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -6117,6 +6320,7 @@ ] }, "id" : "resetWorkspaceTerminalFontSize", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -6150,6 +6354,7 @@ ] }, "id" : "toggleCanvasLayout", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -6183,6 +6388,7 @@ ] }, "id" : "canvasOverview", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -6216,6 +6422,7 @@ ] }, "id" : "canvasTidy", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -6249,6 +6456,7 @@ ] }, "id" : "canvasRevealFocusedPane", + "main_menu" : "view", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -6282,6 +6490,7 @@ ] }, "id" : "canvasZoomIn", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -6315,6 +6524,7 @@ ] }, "id" : "canvasZoomOut", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -6347,6 +6557,7 @@ ] }, "id" : "canvasZoomReset", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -6374,6 +6585,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "canvasAlignLeft", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6401,6 +6613,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "canvasAlignRight", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6428,6 +6641,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "canvasAlignTop", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6455,6 +6669,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "canvasAlignBottom", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6482,6 +6697,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "canvasEqualizeWidths", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6509,6 +6725,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "canvasEqualizeHeights", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6536,6 +6753,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "canvasDistributeHorizontally", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6563,6 +6781,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "canvasDistributeVertically", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6590,6 +6809,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.newSimulatorPane", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -6623,6 +6843,7 @@ ] }, "id" : "simulatorHome", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6655,6 +6876,7 @@ ] }, "id" : "simulatorRotateLeft", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6687,6 +6909,7 @@ ] }, "id" : "simulatorRotateRight", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6720,6 +6943,7 @@ ] }, "id" : "simulatorToggleAppearance", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6753,6 +6977,7 @@ ] }, "id" : "simulatorToggleSoftwareKeyboard", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -6780,6 +7005,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openFilesPane", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -6807,6 +7033,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openFindPane", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -6834,6 +7061,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openVaultPane", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -6861,6 +7089,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openCloudPane", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -6888,6 +7117,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "file.open", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -6920,6 +7150,7 @@ ] }, "id" : "newTab.default", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -6947,6 +7178,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "newTab.ofKind", + "main_menu" : null, "mcp" : "duplicateOfDefault", "palette" : "offered", "palette_section" : { @@ -6974,6 +7206,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "newTab.toRight", + "main_menu" : null, "mcp" : "duplicateOfDefault", "palette" : "offered", "palette_section" : { @@ -7001,6 +7234,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "newTab.page", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7028,6 +7262,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "newTab.horizontal", + "main_menu" : null, "mcp" : "duplicateOfDefault", "palette" : "offered", "palette_section" : { @@ -7055,6 +7290,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "newTab.submit", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7087,6 +7323,7 @@ ] }, "id" : "focusLocation", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -7120,6 +7357,7 @@ ] }, "id" : "newSurface", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7153,6 +7391,7 @@ ] }, "id" : "openBrowser", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7180,6 +7419,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "openBrowser.webkit", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7208,6 +7448,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "openBrowser.chromium", + "main_menu" : null, "mcp" : "offered", "palette" : "duplicateOfDefault", "palette_section" : { @@ -7240,6 +7481,7 @@ ] }, "id" : "closeTab", + "main_menu" : "file", "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -7273,6 +7515,7 @@ ] }, "id" : "closeOtherTabsInPane", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7300,6 +7543,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "closeTabsToLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7327,6 +7571,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "closeTabsToRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7359,6 +7604,7 @@ ] }, "id" : "renameTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7386,6 +7632,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.restart", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7413,6 +7660,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.focus", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7440,6 +7688,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "pane.focus", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7467,6 +7716,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.focus", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7494,6 +7744,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.clearTabName", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7521,6 +7772,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "navigate.next", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -7548,6 +7800,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "navigate.previous", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -7588,6 +7841,7 @@ ], "default_shortcut" : null, "id" : "hints.show", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -7630,6 +7884,7 @@ ] }, "id" : "nextSurface", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -7674,6 +7929,7 @@ ] }, "id" : "prevSurface", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -7708,6 +7964,7 @@ ] }, "id" : "moveSurfaceLeft", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7742,6 +7999,7 @@ ] }, "id" : "moveSurfaceRight", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7776,6 +8034,7 @@ ] }, "id" : "moveSurfaceToPreviousPane", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7810,6 +8069,7 @@ ] }, "id" : "moveSurfaceToNextPane", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7844,6 +8104,7 @@ ] }, "id" : "moveSurfaceToPaneLeft", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7878,6 +8139,7 @@ ] }, "id" : "moveSurfaceToPaneRight", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7912,6 +8174,7 @@ ] }, "id" : "moveSurfaceToPaneUp", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7946,6 +8209,7 @@ ] }, "id" : "moveSurfaceToPaneDown", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -7979,6 +8243,7 @@ ] }, "id" : "selectSurfaceByNumber", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -8012,6 +8277,7 @@ ] }, "id" : "tab.search", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8039,6 +8305,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.moveTabToNewWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8072,6 +8339,7 @@ ] }, "id" : "palette.toggleTabPin", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8099,6 +8367,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.toggleTabUnread", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8126,6 +8395,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.toggleFullWidthTab", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -8153,6 +8423,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "duplicateTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8180,6 +8451,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "reloadTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8207,6 +8479,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "toggleTabAudioMute", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -8234,6 +8507,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "disconnectRemoteTab", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -8261,6 +8535,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.copyIdentifiers", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -8289,6 +8564,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.copyPaneID", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -8317,6 +8593,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.copyPaneLink", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -8344,6 +8621,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.copySurfaceID", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -8371,6 +8649,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.copySurfaceLink", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -8404,6 +8683,7 @@ ] }, "id" : "reopenClosedBrowserPanel", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8431,6 +8711,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8458,6 +8739,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8485,6 +8767,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.showResources", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -8512,6 +8795,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.showResources", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -8539,6 +8823,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.show", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -8566,6 +8851,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.tab", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8593,6 +8879,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.channels", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8620,6 +8907,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.attachFiles", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8647,6 +8935,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.newMessage", + "main_menu" : "file", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -8674,6 +8963,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.invite", + "main_menu" : "file", "mcp" : "credentials", "palette" : "offered", "palette_section" : { @@ -8701,6 +8991,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.newChief", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8728,6 +9019,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.archiveChief", + "main_menu" : null, "mcp" : "offered", "palette" : "noTargetSurface", "palette_section" : { @@ -8755,6 +9047,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.previousConversation", + "main_menu" : "view", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -8782,6 +9075,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.nextConversation", + "main_menu" : "view", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -8809,6 +9103,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.openConversation", + "main_menu" : null, "mcp" : "focusMove", "palette" : "focusMove", "palette_section" : { @@ -8836,6 +9131,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.saveFlightRecording", + "main_menu" : "debug", "mcp" : "devOnly", "palette" : "offered", "palette_section" : { @@ -8863,6 +9159,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.create", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8890,6 +9187,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.addTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8917,6 +9215,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.removeTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8944,6 +9243,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.rename", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8971,6 +9271,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.setColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -8998,6 +9299,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9025,6 +9327,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9052,6 +9355,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.toggleCollapsed", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9079,6 +9383,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.collapse", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9106,6 +9411,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.expand", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9133,6 +9439,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.ungroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9160,6 +9467,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.close", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9187,6 +9495,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.moveToNewSplit", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9214,6 +9523,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.moveToNewColumn", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9241,6 +9551,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.moveToNewWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9268,6 +9579,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.moveToWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9295,6 +9607,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.moveToNewWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9322,6 +9635,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.newTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9349,6 +9663,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.save", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9376,6 +9691,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.unsave", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9403,6 +9719,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.deleteSaved", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9430,6 +9747,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.reopenSaved", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9457,6 +9775,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.grey", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9484,6 +9803,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.blue", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9511,6 +9831,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.red", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9538,6 +9859,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.yellow", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9565,6 +9887,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.green", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9592,6 +9915,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.pink", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9619,6 +9943,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.purple", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9646,6 +9971,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.cyan", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9673,6 +9999,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.color.orange", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -9701,6 +10028,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.new", + "main_menu" : null, "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -9729,6 +10057,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.newWith", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9756,6 +10085,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.duplicate", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9783,6 +10113,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.close", + "main_menu" : null, "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -9810,6 +10141,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.closeOthers", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9837,6 +10169,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.closeToRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9864,6 +10197,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.closeToLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9891,6 +10225,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.reopenClosed", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -9918,6 +10253,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.next", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -9945,6 +10281,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.previous", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -9972,6 +10309,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.select", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -9999,6 +10337,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.selectLast", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -10026,6 +10365,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.rename", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10053,6 +10393,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.clearName", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10080,6 +10421,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.setColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10107,6 +10449,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.clearColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10134,6 +10477,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10161,6 +10505,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10188,6 +10533,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.togglePin", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10215,6 +10561,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.grey", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10242,6 +10589,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.blue", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10269,6 +10617,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.red", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10296,6 +10645,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.yellow", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10323,6 +10673,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.green", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10350,6 +10701,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.pink", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10377,6 +10729,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.purple", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10404,6 +10757,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.cyan", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10431,6 +10785,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.color.orange", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10458,6 +10813,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.moveLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10485,6 +10841,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.moveRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10512,6 +10869,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.moveToWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10539,6 +10897,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.moveToNewWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10566,6 +10925,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screen.moveToNewWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10593,6 +10953,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.create", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10620,6 +10981,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.addScreen", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10647,6 +11009,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.removeScreen", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10674,6 +11037,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.newScreen", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10701,6 +11065,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.rename", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10728,6 +11093,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.setColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10755,6 +11121,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10782,6 +11149,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10809,6 +11177,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.toggleCollapsed", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -10836,6 +11205,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.collapse", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10863,6 +11233,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.expand", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10890,6 +11261,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.ungroup", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10917,6 +11289,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.close", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10944,6 +11317,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.moveLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10971,6 +11345,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.moveRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -10998,6 +11373,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.moveToWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11025,6 +11401,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.moveToNewWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11052,6 +11429,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.moveToNewWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11079,6 +11457,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.save", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11106,6 +11485,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.unsave", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11133,6 +11513,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.reopenSaved", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11160,6 +11541,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.deleteSaved", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11187,6 +11569,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.grey", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11214,6 +11597,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.blue", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11241,6 +11625,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.red", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11268,6 +11653,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.yellow", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11295,6 +11681,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.green", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11322,6 +11709,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.pink", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11349,6 +11737,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.purple", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11376,6 +11765,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.cyan", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11403,6 +11793,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "screenGroup.color.orange", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -11436,6 +11827,7 @@ ] }, "id" : "toggleTerminalCopyMode", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11469,6 +11861,7 @@ ] }, "id" : "focusTextBoxInput", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -11496,6 +11889,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.terminalToggleTextBoxInput", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11528,6 +11922,7 @@ ] }, "id" : "cycleTextBoxSubmitAction", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11562,6 +11957,7 @@ ] }, "id" : "attachTextBoxFile", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11589,6 +11985,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sendCtrlFToTerminal", + "main_menu" : "edit", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11616,6 +12013,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "pasteLastScreenshot", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11643,6 +12041,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.keep", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11676,6 +12075,7 @@ ] }, "id" : "clearScreenKeepScrollback", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11708,6 +12108,7 @@ ] }, "id" : "find", + "main_menu" : "edit", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11741,6 +12142,7 @@ ] }, "id" : "findInDirectory", + "main_menu" : "edit", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11773,6 +12175,7 @@ ] }, "id" : "findNext", + "main_menu" : "edit", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11806,6 +12209,7 @@ ] }, "id" : "findPrevious", + "main_menu" : "edit", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11840,6 +12244,7 @@ ] }, "id" : "hideFind", + "main_menu" : "edit", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11872,6 +12277,7 @@ ] }, "id" : "useSelectionForFind", + "main_menu" : "edit", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11904,6 +12310,7 @@ ] }, "id" : "terminalCopy", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11936,6 +12343,7 @@ ] }, "id" : "terminalPaste", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -11963,6 +12371,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "resetTerminal", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -11990,6 +12399,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "reconnectPane", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12017,6 +12427,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "resumeCommandSet", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12044,6 +12455,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "resumeCommandEdit", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -12071,6 +12483,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "resumeCommandClear", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12098,6 +12511,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.terminalOpenDirectory", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12130,6 +12544,7 @@ ] }, "id" : "browserBack", + "main_menu" : "view", "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -12162,6 +12577,7 @@ ] }, "id" : "browserForward", + "main_menu" : "view", "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -12194,6 +12610,7 @@ ] }, "id" : "browserReload", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12227,6 +12644,7 @@ ] }, "id" : "browserHardReload", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12254,6 +12672,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.openInChromium", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12281,6 +12700,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.openInWebKit", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12308,6 +12728,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "focusBrowserAddressBar", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -12340,6 +12761,7 @@ ] }, "id" : "browserZoomIn", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -12372,6 +12794,7 @@ ] }, "id" : "browserZoomOut", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -12404,6 +12827,7 @@ ] }, "id" : "browserZoomReset", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -12436,6 +12860,7 @@ ] }, "id" : "markdownZoomIn", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -12468,6 +12893,7 @@ ] }, "id" : "markdownZoomOut", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -12500,6 +12926,7 @@ ] }, "id" : "markdownZoomReset", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -12533,6 +12960,7 @@ ] }, "id" : "toggleBrowserDeveloperTools", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12566,6 +12994,7 @@ ] }, "id" : "showBrowserJavaScriptConsole", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12599,6 +13028,7 @@ ] }, "id" : "inspectBrowserElement", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12632,6 +13062,7 @@ ] }, "id" : "toggleBrowserFocusMode", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12666,6 +13097,7 @@ ] }, "id" : "toggleBrowserDesignMode", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12693,6 +13125,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "toggleReactGrab", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12725,6 +13158,7 @@ ] }, "id" : "browserLinkHints", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12757,6 +13191,7 @@ ] }, "id" : "browserLinkHintsNewSplit", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12790,6 +13225,7 @@ ] }, "id" : "splitBrowserRight", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12824,6 +13260,7 @@ ] }, "id" : "splitBrowserDown", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12851,6 +13288,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.browserOpenDefault", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12878,6 +13316,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.browserToggleOmnibar", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12905,6 +13344,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.browserClearHistory", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -12932,6 +13372,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "importFromBrowser", + "main_menu" : "file", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12959,6 +13400,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "password.importCSV", + "main_menu" : "file", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -12986,6 +13428,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "passwords.open", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13013,6 +13456,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.enableBrowser", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -13040,6 +13484,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.disableBrowser", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -13067,6 +13512,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "openLinkInDefaultBrowser", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13094,6 +13540,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserPrint", + "main_menu" : "file", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13121,6 +13568,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserScreenshotPage", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -13148,6 +13596,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserScreenshotSection", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13175,6 +13624,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserTheme", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -13207,6 +13657,7 @@ ] }, "id" : "saveFilePreview", + "main_menu" : "view", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13239,6 +13690,7 @@ ] }, "id" : "toggleFileEditorWordWrap", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13266,6 +13718,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "filePreviewOpenWith", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13293,6 +13746,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "filePreviewOpenExternally", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13320,6 +13774,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "filePreviewRevealInFinder", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13352,6 +13807,7 @@ ] }, "id" : "diffViewerNextLine", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13384,6 +13840,7 @@ ] }, "id" : "diffViewerPreviousLine", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13416,6 +13873,7 @@ ] }, "id" : "diffViewerHalfPageDown", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13448,6 +13906,7 @@ ] }, "id" : "diffViewerHalfPageUp", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13480,6 +13939,7 @@ ] }, "id" : "diffViewerNextHunk", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13512,6 +13972,7 @@ ] }, "id" : "diffViewerPreviousHunk", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13544,6 +14005,7 @@ ] }, "id" : "diffViewerGoToBottom", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13571,6 +14033,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "diffViewerGoToTop", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13603,6 +14066,7 @@ ] }, "id" : "diffViewerSearch", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13630,6 +14094,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "diffViewerNextFile", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13657,6 +14122,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "diffViewerPreviousFile", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -13684,6 +14150,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserDevice", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -13711,6 +14178,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.vscodeServeWebStop", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13738,6 +14206,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.vscodeServeWebRestart", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13765,6 +14234,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.allowAgentWithExtensions", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13792,6 +14262,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.importFromFiles", + "main_menu" : "file", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13819,6 +14290,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.resetPinnedTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -13846,6 +14318,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.media.show", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13873,6 +14346,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.downloads.show", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13900,6 +14374,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.profile.choose", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13927,6 +14402,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.overflow.menu", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -13959,6 +14435,7 @@ ] }, "id" : "omnibar.openInBackgroundTab", + "main_menu" : null, "mcp" : "liveInput", "palette" : "liveInput", "palette_section" : { @@ -13992,6 +14469,7 @@ ] }, "id" : "omnibar.openInForegroundTab", + "main_menu" : null, "mcp" : "liveInput", "palette" : "liveInput", "palette_section" : { @@ -14024,6 +14502,7 @@ ] }, "id" : "browserStop", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14057,6 +14536,7 @@ ] }, "id" : "browser.copyURL", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -14084,6 +14564,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.openLocally", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -14111,6 +14592,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.openOnMachine", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -14144,6 +14626,7 @@ ] }, "id" : "browser.findPrevious", + "main_menu" : null, "mcp" : "familyMember", "palette" : "familyMember", "palette_section" : { @@ -14171,6 +14654,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.download.openBlockedSiteSettings", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -14198,6 +14682,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.prompt.allow", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -14225,6 +14710,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.prompt.block", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -14252,6 +14738,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.openInNewTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14279,6 +14766,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.openInNewWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14306,6 +14794,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.openInNewSpace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14333,6 +14822,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.openInNewWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14360,6 +14850,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.openInSplit", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14387,6 +14878,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.openInIncognitoWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14414,6 +14906,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.saveAs", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "liveInput", "palette_section" : { @@ -14441,6 +14934,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.copy", + "main_menu" : null, "mcp" : "clipboard", "palette" : "liveInput", "palette_section" : { @@ -14468,6 +14962,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.link.copyText", + "main_menu" : null, "mcp" : "clipboard", "palette" : "liveInput", "palette_section" : { @@ -14495,6 +14990,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.image.openInNewTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14522,6 +15018,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.image.saveAs", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "liveInput", "palette_section" : { @@ -14549,6 +15046,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.image.copy", + "main_menu" : null, "mcp" : "clipboard", "palette" : "liveInput", "palette_section" : { @@ -14576,6 +15074,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.image.copyAddress", + "main_menu" : null, "mcp" : "clipboard", "palette" : "liveInput", "palette_section" : { @@ -14603,6 +15102,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.selection.copy", + "main_menu" : null, "mcp" : "clipboard", "palette" : "liveInput", "palette_section" : { @@ -14630,6 +15130,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.selection.search", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14657,6 +15158,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.selection.lookUp", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "liveInput", "palette_section" : { @@ -14691,6 +15193,7 @@ ] }, "id" : "openDiffViewer", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14718,6 +15221,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openDirectoryDiffViewer", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14745,6 +15249,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "openMarkdownFile", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -14777,6 +15282,7 @@ ] }, "id" : "fileEditorGotoLine", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -14804,6 +15310,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "fileEditorReplace", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -14836,6 +15343,7 @@ ] }, "id" : "fileEditorZoomIn", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -14868,6 +15376,7 @@ ] }, "id" : "fileEditorZoomOut", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -14900,6 +15409,7 @@ ] }, "id" : "fileEditorZoomReset", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -14927,6 +15437,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "fileEditorAction", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -14960,6 +15471,7 @@ ] }, "id" : "browser.pageInfo", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -14987,6 +15499,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.connection", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15014,6 +15527,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.certificate", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15041,6 +15555,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.reenableCertificateWarnings", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15068,6 +15583,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.cookies", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15095,6 +15611,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.manageSiteData", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15122,6 +15639,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.deleteSiteData", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15149,6 +15667,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.setPermission", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15176,6 +15695,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.resetPermissions", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15203,6 +15723,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.siteSettings", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15230,6 +15751,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.pageInfo.aboutThisPage", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15257,6 +15779,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.certificateWarning.proceed", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15284,6 +15807,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.certificateWarning.goBack", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15311,6 +15835,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extensions.menu", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15338,6 +15863,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extensions.manage", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15365,6 +15891,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extensions.webStore", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15392,6 +15919,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extensions.loadUnpacked", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15419,6 +15947,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.run", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15446,6 +15975,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.options", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15473,6 +16003,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.pin", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15500,6 +16031,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.unpin", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15527,6 +16059,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.enable", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15554,6 +16087,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.disable", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15581,6 +16115,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.move", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15608,6 +16143,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.reload", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15635,6 +16171,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.remove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15662,6 +16199,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.extension.command", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15689,6 +16227,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.new", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15716,6 +16255,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.rename", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15743,6 +16283,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.setColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15770,6 +16311,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.clearColor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15797,6 +16339,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15824,6 +16367,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15851,6 +16395,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.delete", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -15879,6 +16424,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.newTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15906,6 +16452,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.newWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15933,6 +16480,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.newWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15960,6 +16508,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.openLink", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -15987,6 +16536,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.setWorkspaceDefault", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -16014,6 +16564,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.clearWorkspaceDefault", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -16033,7 +16584,7 @@ "cli_name" : "browser-profile set-space-default", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -16041,6 +16592,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.setSpaceDefault", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -16068,6 +16620,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.clearSpaceDefault", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -16095,6 +16648,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.moveTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -16122,6 +16676,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.duplicateTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -16149,6 +16704,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.clearBrowsingData", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -16176,6 +16732,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browserProfile.manageExtensions", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16203,6 +16760,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.profileMenu", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16230,6 +16788,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.downloads.showFolder", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16262,6 +16821,7 @@ ] }, "id" : "toggleSidebar", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16295,6 +16855,7 @@ ] }, "id" : "toggleRightSidebar", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16328,6 +16889,7 @@ ] }, "id" : "focusSidebar", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -16361,6 +16923,7 @@ ] }, "id" : "focusRightSidebar", + "main_menu" : "view", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -16388,6 +16951,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "switchRightSidebarToFiles", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16415,6 +16979,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "switchRightSidebarToFind", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16442,6 +17007,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "switchRightSidebarToSessions", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16469,6 +17035,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "switchRightSidebarToFeed", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16496,6 +17063,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "switchRightSidebarToDock", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16523,6 +17091,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "switchRightSidebarToMachines", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -16555,6 +17124,7 @@ ] }, "id" : "fileExplorerOpenSelection", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16587,6 +17157,7 @@ ] }, "id" : "fileExplorerOpenSelectionFinderAlias", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16614,6 +17185,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "fileExplorerOpenInCmux", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16641,6 +17213,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "fileExplorerReveal", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16668,6 +17241,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "fileExplorerCopyPath", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16695,6 +17269,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "fileExplorerCopyRelativePath", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16722,6 +17297,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "fileExplorerOpenWith", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16749,6 +17325,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "vaultFocusSession", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -16776,6 +17353,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "vaultOpenSession", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16803,6 +17381,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "vaultResumeInNewWorkspace", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16830,6 +17409,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "vaultCopyResumeCommand", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16857,6 +17437,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "vaultOpenPullRequest", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16884,6 +17465,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "checklistEditItem", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16911,6 +17493,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "checklistMarkInProgress", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16938,6 +17521,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "checklistCompleteItem", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16965,6 +17549,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "checklistRemoveItem", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -16992,6 +17577,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "checklistOpenAsPane", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -17019,6 +17605,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "checklistAttachImages", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -17046,6 +17633,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.toggleMatchTerminalBackground", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17073,6 +17661,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.enableMinimalMode", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17100,6 +17689,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.disableMinimalMode", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17133,6 +17723,7 @@ ] }, "id" : "feed.show", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -17160,6 +17751,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "showNotifications", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -17193,6 +17785,7 @@ ] }, "id" : "jumpToUnread", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -17226,6 +17819,7 @@ ] }, "id" : "jumpToLastPrompt", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -17259,6 +17853,7 @@ ] }, "id" : "toggleUnread", + "main_menu" : "window", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -17292,6 +17887,7 @@ ] }, "id" : "markOldestUnreadAndJumpNext", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -17319,6 +17915,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "markAllNotificationsRead", + "main_menu" : "window", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -17346,6 +17943,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "clearAllNotifications", + "main_menu" : "window", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -17373,6 +17971,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notificationOpen", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -17400,6 +17999,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notificationCopy", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -17427,6 +18027,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notificationToggleRead", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -17454,6 +18055,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notificationDismiss", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -17481,6 +18083,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.toggleWorkspaceMute", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -17508,6 +18111,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.dismissHighlight", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -17535,6 +18139,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.toggleBanners", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17562,6 +18167,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.dismissal.keystroke", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17589,6 +18195,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.dismissal.focus", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17616,6 +18223,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.dismissal.click", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17643,6 +18251,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.dismissal.explicit", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17670,6 +18279,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.dismissal.timeout", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17697,6 +18307,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "notifications.dismissal.never", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17730,6 +18341,7 @@ ] }, "id" : "agentPaneZoomIn", + "main_menu" : "view", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17763,6 +18375,7 @@ ] }, "id" : "agentPaneZoomOut", + "main_menu" : "view", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17796,6 +18409,7 @@ ] }, "id" : "agentPaneZoomReset", + "main_menu" : "view", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -17823,6 +18437,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentChat.toggleNotificationMute", + "main_menu" : null, "mcp" : "unimplemented", "palette" : "offered", "palette_section" : { @@ -17868,6 +18483,7 @@ ] }, "id" : "palette.newAgentChat", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -17895,6 +18511,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.toggleInspector", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -17928,6 +18545,7 @@ ] }, "id" : "palette.quickAgentChat", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -17962,6 +18580,7 @@ ] }, "id" : "palette.startAgentFromAnyApp", + "main_menu" : null, "mcp" : "familyMember", "palette" : "familyMember", "palette_section" : { @@ -17989,6 +18608,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.addHarness", + "main_menu" : "file", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18016,6 +18636,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "stats.show", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18049,6 +18670,7 @@ ] }, "id" : "palette.toggleDictation", + "main_menu" : "edit", "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -18076,6 +18698,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.continueIn", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18109,6 +18732,7 @@ ] }, "id" : "agentPane.switchModel", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18136,6 +18760,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.createCheckpoint", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18168,6 +18793,7 @@ ] }, "id" : "agentPane.searchFiles", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18201,6 +18827,7 @@ ] }, "id" : "agentPane.copyLastCodeBlock", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18234,6 +18861,7 @@ ] }, "id" : "agentPane.archiveChat", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18261,6 +18889,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.git.commit", + "main_menu" : null, "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -18288,6 +18917,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.git.push", + "main_menu" : null, "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -18325,6 +18955,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.searchChats", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18358,6 +18989,7 @@ ] }, "id" : "agentPane.previousTurn", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18391,6 +19023,7 @@ ] }, "id" : "agentPane.nextTurn", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18424,6 +19057,7 @@ ] }, "id" : "agentPane.permission.allowOnce", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18457,6 +19091,7 @@ ] }, "id" : "agentPane.permission.allowChat", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18490,6 +19125,7 @@ ] }, "id" : "agentPane.permission.deny", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18523,6 +19159,7 @@ ] }, "id" : "agentPane.permission.expand", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18550,6 +19187,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.permission.retry", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18577,6 +19215,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.permission.revoke", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18604,6 +19243,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentPane.permission.refresh", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18631,6 +19271,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openTerminalChatView", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18658,6 +19299,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.launchClaudeTeams", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18685,6 +19327,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.launchCodexTeams", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18712,6 +19355,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.forkAgentConversationRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18739,6 +19383,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.forkAgentConversationLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18766,6 +19411,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.forkAgentConversationTop", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18793,6 +19439,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.forkAgentConversationBottom", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18820,6 +19467,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.forkAgentConversationNewTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18847,6 +19495,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.forkAgentConversationNewWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -18874,6 +19523,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.computerUse.setup", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18901,6 +19551,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.computerUse.accessibility", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18928,6 +19579,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.computerUse.screenRecording", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -18955,6 +19607,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agent.openSessionWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "noObject", "palette_section" : { @@ -18982,6 +19635,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.toggleChiefSettings", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -19009,6 +19663,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "home.stopChief", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -19036,6 +19691,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "chief.openMemoryInspector", + "main_menu" : null, "mcp" : "devOnly", "palette" : "offered", "palette_section" : { @@ -19063,6 +19719,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentActivity.open", + "main_menu" : "window", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19090,6 +19747,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "computerUseFocus", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -19117,6 +19775,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "computerUseFocusCallingTerminal", + "main_menu" : "file", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -19144,6 +19803,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "computerUseStop", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19173,6 +19833,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentChat.copyIdentifier", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -19200,6 +19861,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agent.harness.add", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19227,6 +19889,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agent.harness.addFromRegistry", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -19254,6 +19917,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agent.harness.remove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19281,6 +19945,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agent.harness.restore", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19308,6 +19973,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agent.harness.doctor", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19335,6 +20001,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agent.harness.updateAll", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -19368,6 +20035,7 @@ ] }, "id" : "newCloudWorkspace", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19395,6 +20063,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "newCloudMachine", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19422,6 +20091,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.fork", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19449,6 +20119,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.snapshot", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19476,6 +20147,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.restore", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19503,6 +20175,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.promoteTemplate", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19530,6 +20203,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.status", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19557,6 +20231,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.ports", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19584,6 +20259,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.tools", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -19611,6 +20287,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.handoff", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19639,6 +20316,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudMove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19667,6 +20345,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudBringBack", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19694,6 +20373,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudSSH", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19721,6 +20401,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudExec", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19748,6 +20429,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFilesList", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19775,6 +20457,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFileRead", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19802,6 +20485,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFileWrite", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19829,6 +20513,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFileMkdir", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19856,6 +20541,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFileRemove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19883,6 +20569,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFileStat", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19910,6 +20597,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudPrepareSCP", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19937,6 +20625,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudTunnelAttach", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19964,6 +20653,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudTunnelDetach", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -19991,6 +20681,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudTunnelRotateKey", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20018,6 +20709,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudNetworkList", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20045,6 +20737,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFirewallList", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20072,6 +20765,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFirewallGet", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20099,6 +20793,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFirewallCreate", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20126,6 +20821,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudFirewallDelete", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20153,6 +20849,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudDomainList", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20180,6 +20877,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudPublicationList", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20207,6 +20905,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudNewTerminal", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20234,6 +20933,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudOpenMachine", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -20261,6 +20961,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudRenameMachine", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20288,6 +20989,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudKillMachine", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20315,6 +21017,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudPauseMachine", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20342,6 +21045,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudResumeMachine", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20369,6 +21073,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudCopyLink", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -20396,6 +21101,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudCopyPort", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -20423,6 +21129,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudCopyMachineID", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -20450,6 +21157,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudResizeMachine", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20477,6 +21185,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.cloud.deleteSnapshot", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20504,6 +21213,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "cloudDiagnostics", + "main_menu" : "help", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20538,6 +21248,7 @@ ] }, "id" : "openTeamPicker", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -20565,6 +21276,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.auth.signIn", + "main_menu" : null, "mcp" : "credentials", "palette" : "offered", "palette_section" : { @@ -20592,6 +21304,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.auth.signOut", + "main_menu" : null, "mcp" : "credentials", "palette" : "offered", "palette_section" : { @@ -20619,6 +21332,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.mobileConnect", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -20646,6 +21360,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "accounts.show", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20673,6 +21388,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "accounts.refresh", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20700,6 +21416,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "accounts.reauthenticate", + "main_menu" : null, "mcp" : "credentials", "palette" : "offered", "palette_section" : { @@ -20727,6 +21444,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "accounts.connect", + "main_menu" : null, "mcp" : "credentials", "palette" : "offered", "palette_section" : { @@ -20754,6 +21472,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "accounts.remove", + "main_menu" : null, "mcp" : "credentials", "palette" : "offered", "palette_section" : { @@ -20781,6 +21500,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.connect", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20808,6 +21528,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.newWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20835,6 +21556,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.openTerminalHere", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20862,6 +21584,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.reconnect", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20889,6 +21612,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.disconnect", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20916,6 +21640,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.install", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -20943,6 +21668,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "workspace.setRemoteLocalhost", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -20970,6 +21696,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.copyError", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -20997,6 +21724,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.forget", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21024,6 +21752,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.openBrowserTab", + "main_menu" : null, "mcp" : "devOnly", "palette" : "offered", "palette_section" : { @@ -21051,6 +21780,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "remote.openLocalBrowserTab", + "main_menu" : null, "mcp" : "devOnly", "palette" : "offered", "palette_section" : { @@ -21078,6 +21808,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "machine.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21105,6 +21836,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "machine.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21138,6 +21870,7 @@ ] }, "id" : "reloadConfiguration", + "main_menu" : "app", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21165,6 +21898,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openCmuxSettingsFile", + "main_menu" : "app", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -21192,6 +21926,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.openGhosttySettings", + "main_menu" : "app", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -21219,6 +21954,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "ghostty.showDiagnostics", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -21246,6 +21982,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.makeDefaultBrowser", + "main_menu" : "app", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21273,6 +22010,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.makeDefaultTerminal", + "main_menu" : "app", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21300,6 +22038,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.toggleSetting", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21327,6 +22066,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.shortcutKeymap", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21367,6 +22107,7 @@ ], "default_shortcut" : null, "id" : "palette.searchShortcuts", + "main_menu" : "help", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -21394,6 +22135,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "keybindings.open", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21421,6 +22163,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "openDebugSettings", + "main_menu" : null, "mcp" : "devOnly", "palette" : "offered", "palette_section" : { @@ -21448,6 +22191,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.installCLI", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21475,6 +22219,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.uninstallCLI", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21502,6 +22247,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.restartSocketListener", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21529,6 +22275,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.checkForUpdates", + "main_menu" : "app", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21556,6 +22303,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "updates.whatsNew", + "main_menu" : "help", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21583,6 +22331,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "announcements.show", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21610,6 +22359,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "announcements.hide", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -21637,6 +22387,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.applyUpdateIfAvailable", + "main_menu" : "app", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21664,6 +22415,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.attemptUpdate", + "main_menu" : null, "mcp" : "familyMember", "palette" : "offered", "palette_section" : { @@ -21691,6 +22443,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.switchAppChannel", + "main_menu" : "app", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21718,6 +22471,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "palette.pro.upgrade", + "main_menu" : "app", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -21745,6 +22499,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sendFeedback", + "main_menu" : "help", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -21772,6 +22527,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "help.showCrashLogs", + "main_menu" : "help", "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21799,6 +22555,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "help.featureFlags", + "main_menu" : "help", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -21826,6 +22583,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "help.documentation", + "main_menu" : "help", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -21853,6 +22611,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.density.compact", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21880,6 +22639,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.density.comfortable", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21907,6 +22667,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.animationSpeed.fast", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21934,6 +22695,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.animationSpeed.normal", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21961,6 +22723,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.animationSpeed.off", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -21988,6 +22751,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.animations.newTab.toggle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22015,6 +22779,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.animations.newWorkspace.toggle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22042,6 +22807,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.animations.bounce.subtle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22069,6 +22835,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.animations.bounce.none", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22096,6 +22863,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.defaultEngine.chromium", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22123,6 +22891,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.defaultEngine.webkit", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22150,6 +22919,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.paneBorder.toggle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22177,6 +22947,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.panePadding.toggle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22204,6 +22975,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.paneCorners.toggle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22231,6 +23003,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "layout.centerFocusedColumn.never", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22258,6 +23031,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "layout.centerFocusedColumn.always", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22285,6 +23059,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "layout.centerFocusedColumn.onOverflow", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22312,6 +23087,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "focusRing.toggle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22339,6 +23115,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "focusRing.style.ring", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22366,6 +23143,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "focusRing.style.glow", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22393,6 +23171,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "focusRing.singlePane.toggle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22420,6 +23199,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.paneBorderWidth.toggle", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22447,6 +23227,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.paneBorderColor.reset", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22474,6 +23255,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.titlebar.minimal", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22501,6 +23283,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.titlebar.standard", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22528,6 +23311,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.customize", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -22555,6 +23339,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.interfaceSize.increase", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -22582,6 +23367,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.interfaceSize.decrease", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -22609,6 +23395,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.interfaceSize.reset", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -22642,6 +23429,7 @@ ] }, "id" : "appearance.uiScale.increase", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -22675,6 +23463,7 @@ ] }, "id" : "appearance.uiScale.decrease", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -22708,6 +23497,7 @@ ] }, "id" : "appearance.uiScale.reset", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -22735,6 +23525,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.chromeStyle.default", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22762,6 +23553,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.chromeStyle.card", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22789,6 +23581,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.chromeStyle.cardOutlined", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22816,6 +23609,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.chromeStyle.flat", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22843,6 +23637,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.chromeStyle.flatSidebarLine", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22870,6 +23665,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.chromeStyle.bare", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22897,6 +23693,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.chromeStyle.panes", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22924,6 +23721,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.chromeStyle.twoTone", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -22938,6 +23736,34 @@ "title_key" : "action.appearance.chromeStyle.twoTone", "title_table" : "SettingsActions" }, + { + "cli" : "offered", + "cli_name" : "settings window-style-floating", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.chromeStyle.floating", + "main_menu" : null, + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Window Style: Floating Panes", + "title_key" : "action.appearance.chromeStyle.floating", + "title_table" : "SettingsActions" + }, { "cli" : "offered", "cli_name" : "settings change-background", @@ -22952,6 +23778,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appearance.changeBackground", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -22979,6 +23806,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.hibernation.off", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -23006,6 +23834,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.hibernation.moderate", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -23033,6 +23862,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "browser.hibernation.aggressive", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -23060,6 +23890,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "hibernateTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23087,6 +23918,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "wakeTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23114,6 +23946,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.moveToNewSplit", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23141,6 +23974,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.moveToNewColumn", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23168,6 +24002,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.moveToWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23195,6 +24030,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.moveToNewWindow", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23222,6 +24058,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.moveLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23249,6 +24086,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tabGroup.moveRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23277,6 +24115,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "splitLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23305,6 +24144,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "splitUp", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23332,6 +24172,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "swapPaneLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23359,6 +24200,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "swapPaneRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23386,6 +24228,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "swapPaneUp", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23413,6 +24256,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "swapPaneDown", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23440,6 +24284,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "closePane", + "main_menu" : null, "mcp" : "ownerVerb", "palette" : "offered", "palette_section" : { @@ -23467,6 +24312,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "pane.closeOthers", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23494,6 +24340,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "renamePane", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23521,6 +24368,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.focusLeft", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -23548,6 +24396,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.focusRight", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -23575,6 +24424,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.moveLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23602,6 +24452,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.moveRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23629,6 +24480,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.center", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23656,6 +24508,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.widthOneThird", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23683,6 +24536,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.widthHalf", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23710,6 +24564,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.widthTwoThirds", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23737,6 +24592,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.widthFull", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23764,6 +24620,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.cycleWidth", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -23791,6 +24648,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.cycleWidthBack", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -23824,6 +24682,7 @@ ] }, "id" : "column.dock", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23851,6 +24710,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.dockLeft", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23878,6 +24738,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.dockRight", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23905,6 +24766,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.dockTop", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23932,6 +24794,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.dockBottom", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23959,6 +24822,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.float", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -23986,6 +24850,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "column.undock", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24013,6 +24878,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tab.moveToNewDockColumn", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24040,6 +24906,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "layout.toggleStripScrollbar", + "main_menu" : null, "mcp" : "systemChange", "palette" : "offered", "palette_section" : { @@ -24067,6 +24934,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.selectAll", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -24099,6 +24967,7 @@ ] }, "id" : "terminal.clear", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24131,6 +25000,7 @@ ] }, "id" : "terminal.increaseFontSize", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -24163,6 +25033,7 @@ ] }, "id" : "terminal.decreaseFontSize", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -24195,6 +25066,7 @@ ] }, "id" : "terminal.resetFontSize", + "main_menu" : "view", "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -24222,6 +25094,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.sendText", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24249,6 +25122,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.scrollPageUp", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -24276,6 +25150,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.scrollPageDown", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -24303,6 +25178,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.scrollToTop", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -24330,6 +25206,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "terminal.scrollToBottom", + "main_menu" : null, "mcp" : "stepAdjust", "palette" : "offered", "palette_section" : { @@ -24370,6 +25247,7 @@ ], "default_shortcut" : null, "id" : "terminal.scrollToSelection", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -24412,6 +25290,7 @@ ] }, "id" : "focusHistoryBack", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -24455,6 +25334,7 @@ ] }, "id" : "focusHistoryForward", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -24482,6 +25362,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "focusHistoryLast", + "main_menu" : "window", "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -24509,6 +25390,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "recentlyFocused", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -24536,6 +25418,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "recentlyClosed", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -24563,6 +25446,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "history.commands", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -24590,6 +25474,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "history.show", + "main_menu" : "window", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24617,6 +25502,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "agentHistory.show", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -24649,6 +25535,7 @@ ] }, "id" : "browserShowHistory", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -24676,6 +25563,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "history.search", + "main_menu" : "window", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -24703,6 +25591,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "history.resumeAgentSession", + "main_menu" : "window", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24730,6 +25619,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "history.reopen", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24757,6 +25647,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "history.goTo", + "main_menu" : null, "mcp" : "focusMove", "palette" : "focusMove", "palette_section" : { @@ -24784,6 +25675,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "history.open", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24811,6 +25703,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "history.clear", + "main_menu" : "window", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24838,6 +25731,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "layout.undo", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24865,6 +25759,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "music.open", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -24892,6 +25787,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "music.stop", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -24921,6 +25817,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.addPage", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24949,6 +25846,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.addAllTabs", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -24976,6 +25874,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.add", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25004,6 +25903,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.newFolder", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25031,6 +25931,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.open", + "main_menu" : "view", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25058,6 +25959,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.openInNewTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25085,6 +25987,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.openInBackgroundTab", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25112,6 +26015,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.openAll", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25139,6 +26043,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.edit", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25166,6 +26071,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.copyLink", + "main_menu" : null, "mcp" : "clipboard", "palette" : "offered", "palette_section" : { @@ -25193,6 +26099,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.setIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25220,6 +26127,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.clearIcon", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25247,6 +26155,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.move", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25274,6 +26183,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.remove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25308,6 +26218,7 @@ ] }, "id" : "bookmark.toggleBar", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -25336,6 +26247,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.manager", + "main_menu" : "view", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -25363,6 +26275,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.import", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25390,6 +26303,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.importFromBrowser", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25417,6 +26331,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "bookmark.export", + "main_menu" : "file", "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25444,6 +26359,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.home.add", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25471,6 +26387,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.home.remove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25499,6 +26416,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.item.add", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25526,6 +26444,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.item.removeEverywhere", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25553,6 +26472,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.item.remove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25581,6 +26501,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.item.hideApp", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25608,6 +26529,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.item.toggleLabel", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25636,6 +26558,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.add", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25663,6 +26586,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.rename", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25690,6 +26614,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.moveToTop", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25717,6 +26642,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.moveToScrolling", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25744,6 +26670,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.moveToBottom", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25771,6 +26698,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.useBuiltInLook", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25798,6 +26726,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.useListLook", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25825,6 +26754,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.layoutList", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25852,6 +26782,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.layoutInline", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25879,6 +26810,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.layoutGrid", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25906,6 +26838,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.setAlignment", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25933,6 +26866,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.setGap", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25960,6 +26894,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.setColumns", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -25987,6 +26922,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.toggleTitle", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26014,6 +26950,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.toggleSpaceScope", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26041,6 +26978,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.setMaxRows", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26068,6 +27006,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.toggleCollapsed", + "main_menu" : null, "mcp" : "focusMove", "palette" : "offered", "palette_section" : { @@ -26095,6 +27034,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.remove", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26122,6 +27062,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.closeWorkspaces", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26149,6 +27090,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.newWorkspace", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26176,6 +27118,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.section.hide", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26203,6 +27146,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.projects.hide", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26230,6 +27174,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.sections.showHidden", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26249,7 +27194,7 @@ "cli_name" : "action sidebar-spaces-hide", "context_menu" : "offered", "context_menus" : [ - "profile" + "space" ], "default_aliases" : [ @@ -26257,6 +27202,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.spaces.hide", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26284,6 +27230,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.spaces.show", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26311,6 +27258,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.profileControl.hide", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26338,6 +27286,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.profileControl.show", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26365,6 +27314,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.groupBy.none", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26392,6 +27342,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.groupBy.folder", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26419,6 +27370,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.density.compact", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26446,6 +27398,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.density.comfortable", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26473,6 +27426,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.density.expanded", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26501,6 +27455,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.workspaceTabs.toggle", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26528,6 +27483,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "sidebar.layout.reset", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26555,6 +27511,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appStore.show", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26582,6 +27539,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "appStore.showInstalled", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26609,6 +27567,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "app.hide", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26636,6 +27595,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "app.unhide", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26663,6 +27623,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "app.open", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26690,6 +27651,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "app.command.run", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26717,6 +27679,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tasks.show", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26744,6 +27707,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tasks.new", + "main_menu" : null, "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26771,6 +27735,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "tasks.showMine", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26798,6 +27763,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "link.open", + "main_menu" : null, "mcp" : "offered", "palette" : "offered", "palette_section" : { @@ -26825,6 +27791,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "app.shareCmux", + "main_menu" : "help", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26852,6 +27819,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "server.makeThisMacAServer", + "main_menu" : null, "mcp" : "devOnly", "palette" : "offered", "palette_section" : { @@ -26879,6 +27847,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "server.stopServing", + "main_menu" : null, "mcp" : "devOnly", "palette" : "offered", "palette_section" : { @@ -26906,6 +27875,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "server.addServer", + "main_menu" : "server", "mcp" : "credentials", "palette" : "offered", "palette_section" : { @@ -26933,6 +27903,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "server.showPanel", + "main_menu" : "server", "mcp" : "guiOnly", "palette" : "offered", "palette_section" : { @@ -26960,6 +27931,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "server.showHealth", + "main_menu" : null, "mcp" : "devOnly", "palette" : "offered", "palette_section" : { @@ -26992,6 +27964,7 @@ ] }, "id" : "markdownSave", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -27025,6 +27998,7 @@ ] }, "id" : "markdownLink", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -27057,6 +28031,7 @@ ] }, "id" : "markdownBack", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -27089,6 +28064,7 @@ ] }, "id" : "markdownForward", + "main_menu" : null, "mcp" : "liveInput", "palette" : "offered", "palette_section" : { @@ -27137,6 +28113,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "list.next", + "main_menu" : null, "mcp" : "liveInput", "palette" : "liveInput", "palette_section" : { @@ -27185,6 +28162,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "list.previous", + "main_menu" : null, "mcp" : "liveInput", "palette" : "liveInput", "palette_section" : { @@ -27233,6 +28211,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.firstItem", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27281,6 +28260,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.lastItem", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27318,6 +28298,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.pageUp", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27355,6 +28336,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.pageDown", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27425,6 +28407,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.submit", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27474,6 +28457,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.submitAlternate", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27511,6 +28495,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.openActions", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27548,6 +28533,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.closeActions", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27575,6 +28561,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.toggleActions", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27612,6 +28599,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.escape", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27649,6 +28637,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.enterRow", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27697,6 +28686,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.leaveLevel", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27734,6 +28724,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.back", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27771,6 +28762,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.filterDeleteBackward", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -27808,6 +28800,7 @@ "default_chord" : null, "default_shortcut" : null, "id" : "paletteKey.closeItem", + "main_menu" : null, "mcp" : "liveInput", "palette" : "paletteInternal", "palette_section" : { @@ -30107,11 +31100,47 @@ ] } }, + { + "children" : [ + { + "enabled_when" : "can_perform", + "id" : "splitLeft", + "kind" : "action", + "order" : 0, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "splitUp", + "kind" : "action", + "order" : 1, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + } + ], + "folder" : "split", + "kind" : "folder", + "order" : 2, + "title" : "Split", + "title_key" : "menu.folder.split", + "title_table" : "Localizable" + }, { "enabled_when" : "can_perform", "id" : "toggleSplitZoom", "kind" : "action", - "order" : 2, + "order" : 3, "visible_when" : { "debug_only" : false, "feature" : null, @@ -30124,7 +31153,7 @@ "enabled_when" : "can_perform", "id" : "renamePane", "kind" : "action", - "order" : 3, + "order" : 4, "visible_when" : { "debug_only" : false, "feature" : null, @@ -30163,37 +31192,11 @@ ] } }, - { - "enabled_when" : "can_perform", - "id" : "splitLeft", - "kind" : "action", - "order" : 2, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "splitUp", - "kind" : "action", - "order" : 3, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, { "enabled_when" : "can_perform", "id" : "splitBrowserRight", "kind" : "action", - "order" : 4, + "order" : 2, "visible_when" : { "debug_only" : false, "feature" : null, @@ -30206,7 +31209,7 @@ "enabled_when" : "can_perform", "id" : "splitBrowserDown", "kind" : "action", - "order" : 5, + "order" : 3, "visible_when" : { "debug_only" : false, "feature" : null, @@ -30219,7 +31222,7 @@ "enabled_when" : "can_perform", "id" : "remote.openTerminalHere", "kind" : "action", - "order" : 6, + "order" : 4, "visible_when" : { "debug_only" : false, "feature" : "remoteHosts", @@ -30232,7 +31235,7 @@ "enabled_when" : "can_perform", "id" : "newPaneAutoLayout", "kind" : "action", - "order" : 7, + "order" : 5, "visible_when" : { "debug_only" : false, "feature" : null, @@ -30243,13 +31246,13 @@ }, { "kind" : "separator", - "order" : 8 + "order" : 6 }, { "enabled_when" : "can_perform", "id" : "palette.newSimulatorPane", "kind" : "action", - "order" : 9, + "order" : 7, "visible_when" : { "debug_only" : false, "feature" : null, @@ -30270,10 +31273,6 @@ "kind" : "separator", "order" : 1 }, - { - "kind" : "separator", - "order" : 2 - }, { "children" : [ { @@ -30370,14 +31369,14 @@ ], "folder" : "move", "kind" : "folder", - "order" : 3, + "order" : 2, "title" : "Move", "title_key" : "menu.folder.move", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 4 + "order" : 3 }, { "children" : [ @@ -30523,6 +31522,7 @@ "enabled_when" : "can_perform", "id" : "column.widthOneThird", "kind" : "action", + "label" : "One-Third Width", "order" : 12, "visible_when" : { "debug_only" : false, @@ -30536,6 +31536,7 @@ "enabled_when" : "can_perform", "id" : "column.widthHalf", "kind" : "action", + "label" : "Half Width", "order" : 13, "visible_when" : { "debug_only" : false, @@ -30549,6 +31550,7 @@ "enabled_when" : "can_perform", "id" : "column.widthTwoThirds", "kind" : "action", + "label" : "Two-Thirds Width", "order" : 14, "visible_when" : { "debug_only" : false, @@ -30562,6 +31564,7 @@ "enabled_when" : "can_perform", "id" : "column.widthFull", "kind" : "action", + "label" : "Full Width", "order" : 15, "visible_when" : { "debug_only" : false, @@ -30588,6 +31591,7 @@ "enabled_when" : "can_perform", "id" : "agentPaneZoomIn", "kind" : "action", + "label" : "Zoom In Chat", "order" : 17, "visible_when" : { "debug_only" : false, @@ -30601,6 +31605,7 @@ "enabled_when" : "can_perform", "id" : "agentPaneZoomOut", "kind" : "action", + "label" : "Zoom Out Chat", "order" : 18, "visible_when" : { "debug_only" : false, @@ -30614,6 +31619,7 @@ "enabled_when" : "can_perform", "id" : "agentPaneZoomReset", "kind" : "action", + "label" : "Reset Chat Zoom", "order" : 19, "visible_when" : { "debug_only" : false, @@ -30626,14 +31632,14 @@ ], "folder" : "layout", "kind" : "folder", - "order" : 5, + "order" : 4, "title" : "Layout", "title_key" : "menu.folder.layout", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 6 + "order" : 5 }, { "children" : [ @@ -30666,20 +31672,20 @@ ], "folder" : "connection", "kind" : "folder", - "order" : 7, + "order" : 6, "title" : "Connection", "title_key" : "menu.folder.connection", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 8 + "order" : 7 }, { "enabled_when" : "can_perform", "id" : "triggerFlash", "kind" : "action", - "order" : 9, + "order" : 8, "visible_when" : { "debug_only" : false, "feature" : null, @@ -30719,20 +31725,20 @@ ], "folder" : "copy", "kind" : "folder", - "order" : 10, + "order" : 9, "title" : "Copy", "title_key" : "menu.folder.copy", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 11 + "order" : 10 }, { "enabled_when" : "can_perform", "id" : "pane.closeOthers", "kind" : "action", - "order" : 12, + "order" : 11, "visible_when" : { "debug_only" : false, "feature" : null, @@ -30744,457 +31750,25 @@ ], "folder" : "more", "kind" : "folder", - "order" : 4, + "order" : 5, "title" : "More", "title_key" : "menu.folder.more", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 5 + "order" : 6 }, { "enabled_when" : "can_perform", "id" : "closePane", "kind" : "action", - "order" : 6, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - } - ], - "implied" : [ - - ] - }, - "profile" : { - "entries" : [ - { - "enabled_when" : "can_perform", - "id" : "space.newWorkspace", - "kind" : "action", - "label" : "New Workspace", - "order" : 0, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.newWindow", - "kind" : "action", - "label" : "New Window", - "order" : 1, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "kind" : "separator", - "order" : 2 - }, - { - "enabled_when" : "can_perform", - "id" : "space.setIcon", - "kind" : "action", - "label" : "Change Space Icon…", - "order" : 3, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.clearIcon", - "kind" : "action", - "label" : "Remove Space Icon", - "order" : 4, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.rename", - "kind" : "action", - "order" : 5, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "children" : [ - { - "enabled_when" : "can_perform", - "id" : "space.color.grey", - "kind" : "action", - "order" : 0, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.color.blue", - "kind" : "action", - "order" : 1, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.color.red", - "kind" : "action", - "order" : 2, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.color.yellow", - "kind" : "action", - "order" : 3, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.color.green", - "kind" : "action", - "order" : 4, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.color.pink", - "kind" : "action", - "order" : 5, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.color.purple", - "kind" : "action", - "order" : 6, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.color.cyan", - "kind" : "action", - "order" : 7, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.color.orange", - "kind" : "action", - "order" : 8, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "kind" : "separator", - "order" : 9 - }, - { - "enabled_when" : "can_perform", - "id" : "space.clearColor", - "kind" : "action", - "order" : 10, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "kind" : "separator", - "order" : 11 - }, - { - "choices" : { - "argument" : "theme", - "more_opens_palette" : true, - "values" : [ - { - "title" : "Use Ghostty Config", - "title_key" : null, - "title_table" : null, - "value" : "config" - }, - { - "title" : "Rose Pine", - "title_key" : null, - "title_table" : null, - "value" : "Rose Pine" - }, - { - "title" : "Rose Pine Moon", - "title_key" : null, - "title_table" : null, - "value" : "Rose Pine Moon" - }, - { - "title" : "Catppuccin Mocha", - "title_key" : null, - "title_table" : null, - "value" : "Catppuccin Mocha" - }, - { - "title" : "TokyoNight", - "title_key" : null, - "title_table" : null, - "value" : "TokyoNight" - }, - { - "title" : "Gruvbox Dark", - "title_key" : null, - "title_table" : null, - "value" : "Gruvbox Dark" - }, - { - "title" : "Nord", - "title_key" : null, - "title_table" : null, - "value" : "Nord" - }, - { - "title" : "Vesper", - "title_key" : null, - "title_table" : null, - "value" : "Vesper" - }, - { - "title" : "Soft Blue", - "title_key" : null, - "title_table" : null, - "value" : "Soft Blue" - }, - { - "title" : "Clay Orange", - "title_key" : null, - "title_table" : null, - "value" : "Clay Orange" - }, - { - "title" : "Olive Grey", - "title_key" : null, - "title_table" : null, - "value" : "Olive Grey" - }, - { - "title" : "Rose Pine Dawn", - "title_key" : null, - "title_table" : null, - "value" : "Rose Pine Dawn" - }, - { - "title" : "Catppuccin Latte", - "title_key" : null, - "title_table" : null, - "value" : "Catppuccin Latte" - }, - { - "title" : "GitHub Light Default", - "title_key" : null, - "title_table" : null, - "value" : "GitHub Light Default" - }, - { - "title" : "Soft Blue Light", - "title_key" : null, - "title_table" : null, - "value" : "Soft Blue Light" - }, - { - "title" : "Clay Orange Light", - "title_key" : null, - "title_table" : null, - "value" : "Clay Orange Light" - } - ] - }, - "enabled_when" : "can_perform", - "id" : "space.setTheme", - "kind" : "choices", - "order" : 12, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "space.clearTheme", - "kind" : "action", - "order" : 13, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - } - ], - "id" : "space.setColor", - "kind" : "submenu", - "label" : "Edit Theme Color…", - "order" : 6, - "title_from" : "action", - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "choices" : { - "argument" : "browserProfile", - "target_kind" : "browser-profile" - }, - "enabled_when" : "can_perform", - "id" : "browserProfile.setSpaceDefault", - "kind" : "choices", - "label" : "Set Browser Profile…", "order" : 7, "visible_when" : { "debug_only" : false, "feature" : null, "requires" : [ - ] - } - }, - { - "kind" : "separator", - "order" : 8 - }, - { - "enabled_when" : "can_perform", - "id" : "space.newGroup", - "kind" : "action", - "label" : "New Group", - "order" : 9, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "kind" : "separator", - "order" : 10 - }, - { - "enabled_when" : "can_perform", - "id" : "sidebar.spaces.hide", - "kind" : "action", - "order" : 11, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "kind" : "separator", - "order" : 12 - }, - { - "enabled_when" : "can_perform", - "id" : "space.delete", - "kind" : "action", - "order" : 13, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - ] } } @@ -31288,6 +31862,7 @@ "enabled_when" : "can_perform", "id" : "screen.togglePin", "kind" : "action", + "label" : "Pin\/Unpin Screen", "order" : 5, "visible_when" : { "debug_only" : false, @@ -31305,6 +31880,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.grey", "kind" : "action", + "label" : "Grey", "order" : 0, "visible_when" : { "debug_only" : false, @@ -31318,6 +31894,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.blue", "kind" : "action", + "label" : "Blue", "order" : 1, "visible_when" : { "debug_only" : false, @@ -31331,6 +31908,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.red", "kind" : "action", + "label" : "Red", "order" : 2, "visible_when" : { "debug_only" : false, @@ -31344,6 +31922,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.yellow", "kind" : "action", + "label" : "Yellow", "order" : 3, "visible_when" : { "debug_only" : false, @@ -31357,6 +31936,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.green", "kind" : "action", + "label" : "Green", "order" : 4, "visible_when" : { "debug_only" : false, @@ -31370,6 +31950,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.pink", "kind" : "action", + "label" : "Pink", "order" : 5, "visible_when" : { "debug_only" : false, @@ -31383,6 +31964,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.purple", "kind" : "action", + "label" : "Purple", "order" : 6, "visible_when" : { "debug_only" : false, @@ -31396,6 +31978,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.cyan", "kind" : "action", + "label" : "Cyan", "order" : 7, "visible_when" : { "debug_only" : false, @@ -31409,6 +31992,7 @@ "enabled_when" : "can_perform", "id" : "screen.color.orange", "kind" : "action", + "label" : "Orange", "order" : 8, "visible_when" : { "debug_only" : false, @@ -31422,6 +32006,7 @@ "enabled_when" : "can_perform", "id" : "screen.clearColor", "kind" : "action", + "label" : "Remove Color", "order" : 9, "visible_when" : { "debug_only" : false, @@ -31434,6 +32019,7 @@ ], "id" : "screen.setColor", "kind" : "submenu", + "label" : "Color", "order" : 0, "title_from" : "action", "visible_when" : { @@ -31543,6 +32129,7 @@ "enabled_when" : "can_perform", "id" : "screen.moveLeft", "kind" : "action", + "label" : "Move Left", "order" : 0, "visible_when" : { "debug_only" : false, @@ -31556,6 +32143,7 @@ "enabled_when" : "can_perform", "id" : "screen.moveRight", "kind" : "action", + "label" : "Move Right", "order" : 1, "visible_when" : { "debug_only" : false, @@ -31569,6 +32157,7 @@ "enabled_when" : "can_perform", "id" : "screen.moveToWorkspace", "kind" : "action", + "label" : "Move to Workspace…", "order" : 2, "visible_when" : { "debug_only" : false, @@ -31582,6 +32171,7 @@ "enabled_when" : "can_perform", "id" : "screen.moveToNewWorkspace", "kind" : "action", + "label" : "Move to New Workspace", "order" : 3, "visible_when" : { "debug_only" : false, @@ -31595,6 +32185,7 @@ "enabled_when" : "can_perform", "id" : "screen.moveToNewWindow", "kind" : "action", + "label" : "Move to New Window", "order" : 4, "visible_when" : { "debug_only" : false, @@ -31752,6 +32343,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.rename", "kind" : "action", + "label" : "Rename Group…", "order" : 2, "visible_when" : { "debug_only" : false, @@ -31769,6 +32361,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.grey", "kind" : "action", + "label" : "Grey", "order" : 0, "visible_when" : { "debug_only" : false, @@ -31782,6 +32375,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.blue", "kind" : "action", + "label" : "Blue", "order" : 1, "visible_when" : { "debug_only" : false, @@ -31795,6 +32389,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.red", "kind" : "action", + "label" : "Red", "order" : 2, "visible_when" : { "debug_only" : false, @@ -31808,6 +32403,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.yellow", "kind" : "action", + "label" : "Yellow", "order" : 3, "visible_when" : { "debug_only" : false, @@ -31821,6 +32417,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.green", "kind" : "action", + "label" : "Green", "order" : 4, "visible_when" : { "debug_only" : false, @@ -31834,6 +32431,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.pink", "kind" : "action", + "label" : "Pink", "order" : 5, "visible_when" : { "debug_only" : false, @@ -31847,6 +32445,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.purple", "kind" : "action", + "label" : "Purple", "order" : 6, "visible_when" : { "debug_only" : false, @@ -31860,6 +32459,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.cyan", "kind" : "action", + "label" : "Cyan", "order" : 7, "visible_when" : { "debug_only" : false, @@ -31873,6 +32473,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.color.orange", "kind" : "action", + "label" : "Orange", "order" : 8, "visible_when" : { "debug_only" : false, @@ -31885,6 +32486,7 @@ ], "id" : "screenGroup.setColor", "kind" : "submenu", + "label" : "Color", "order" : 0, "title_from" : "action", "visible_when" : { @@ -31939,6 +32541,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.toggleCollapsed", "kind" : "action", + "label" : "Collapse\/Expand Group", "order" : 5, "visible_when" : { "debug_only" : false, @@ -31994,6 +32597,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.moveLeft", "kind" : "action", + "label" : "Move Left", "order" : 0, "visible_when" : { "debug_only" : false, @@ -32007,6 +32611,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.moveRight", "kind" : "action", + "label" : "Move Right", "order" : 1, "visible_when" : { "debug_only" : false, @@ -32020,6 +32625,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.moveToWorkspace", "kind" : "action", + "label" : "Move to Workspace…", "order" : 2, "visible_when" : { "debug_only" : false, @@ -32033,6 +32639,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.moveToNewWorkspace", "kind" : "action", + "label" : "Move to New Workspace", "order" : 3, "visible_when" : { "debug_only" : false, @@ -32046,6 +32653,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.moveToNewWindow", "kind" : "action", + "label" : "Move to New Window", "order" : 4, "visible_when" : { "debug_only" : false, @@ -32071,6 +32679,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.ungroup", "kind" : "action", + "label" : "Ungroup", "order" : 10, "visible_when" : { "debug_only" : false, @@ -32084,6 +32693,7 @@ "enabled_when" : "can_perform", "id" : "screenGroup.close", "kind" : "action", + "label" : "Close Group", "order" : 11, "visible_when" : { "debug_only" : false, @@ -32362,6 +32972,7 @@ "enabled_when" : "can_perform", "id" : "sidebar.density.compact", "kind" : "action", + "label" : "Compact Rows", "order" : 3, "visible_when" : { "debug_only" : false, @@ -32375,6 +32986,7 @@ "enabled_when" : "can_perform", "id" : "sidebar.density.comfortable", "kind" : "action", + "label" : "Comfortable Rows", "order" : 4, "visible_when" : { "debug_only" : false, @@ -32388,6 +33000,7 @@ "enabled_when" : "can_perform", "id" : "sidebar.density.expanded", "kind" : "action", + "label" : "Expanded Rows", "order" : 5, "visible_when" : { "debug_only" : false, @@ -32534,6 +33147,479 @@ } } ], + "folder" : "sort", + "kind" : "folder", + "order" : 10, + "title" : "Sort", + "title_key" : "menu.folder.sort", + "title_table" : "Localizable" + }, + { + "kind" : "separator", + "order" : 11 + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.projects.hide", + "kind" : "action", + "order" : 12, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.sections.showHidden", + "kind" : "action", + "order" : 13, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "kind" : "separator", + "order" : 14 + }, + { + "enabled_when" : "can_perform", + "id" : "toggleSidebar", + "kind" : "action", + "order" : 15, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "appStore.show", + "kind" : "action", + "order" : 16, + "visible_when" : { + "debug_only" : false, + "feature" : "apps", + "requires" : [ + + ] + } + } + ], + "implied" : [ + + ] + }, + "sidebarFooter" : { + "entries" : [ + { + "enabled_when" : "can_perform", + "id" : "sidebar.spaces.show", + "kind" : "action", + "order" : 0, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.profileControl.show", + "kind" : "action", + "order" : 1, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + } + ], + "implied" : [ + + ] + }, + "sidebarItem" : { + "entries" : [ + { + "enabled_when" : "can_perform", + "id" : "sidebar.item.toggleLabel", + "kind" : "action", + "label" : "Show\/Hide Label", + "order" : 0, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "kind" : "separator", + "order" : 1 + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.profileControl.hide", + "kind" : "action", + "order" : 2, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "kind" : "separator", + "order" : 3 + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.item.removeEverywhere", + "kind" : "action", + "order" : 4, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.item.remove", + "kind" : "action", + "order" : 5, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.item.hideApp", + "kind" : "action", + "label" : "Hide App", + "order" : 6, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + } + ], + "implied" : [ + + ] + }, + "sidebarSection" : { + "entries" : [ + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.add", + "kind" : "action", + "order" : 0, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.newWorkspace", + "kind" : "action", + "order" : 1, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.item.add", + "kind" : "action", + "order" : 2, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "kind" : "separator", + "order" : 3 + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.rename", + "kind" : "action", + "order" : 4, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "children" : [ + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.useBuiltInLook", + "kind" : "action", + "order" : 0, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.useListLook", + "kind" : "action", + "order" : 1, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.toggleTitle", + "kind" : "action", + "label" : "Show\/Hide Section Title", + "order" : 2, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.layoutList", + "kind" : "action", + "order" : 3, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.layoutInline", + "kind" : "action", + "order" : 4, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.layoutGrid", + "kind" : "action", + "order" : 5, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.setAlignment", + "kind" : "action", + "order" : 6, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.setGap", + "kind" : "action", + "order" : 7, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.setColumns", + "kind" : "action", + "order" : 8, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + } + ], + "folder" : "appearance", + "kind" : "folder", + "order" : 5, + "title" : "Appearance", + "title_key" : "menu.folder.appearance", + "title_table" : "Localizable" + }, + { + "children" : [ + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.toggleSpaceScope", + "kind" : "action", + "order" : 0, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.setMaxRows", + "kind" : "action", + "order" : 1, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + } + ], + "folder" : "options", + "kind" : "folder", + "order" : 6, + "title" : "Options", + "title_key" : "menu.folder.options", + "title_table" : "Localizable" + }, + { + "kind" : "separator", + "order" : 7 + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.toggleCollapsed", + "kind" : "action", + "label" : "Collapse\/Expand Section", + "order" : 8, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "kind" : "separator", + "order" : 9 + }, + { + "children" : [ + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.moveToTop", + "kind" : "action", + "order" : 0, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.moveToScrolling", + "kind" : "action", + "order" : 1, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.section.moveToBottom", + "kind" : "action", + "order" : 2, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + } + ], "folder" : "move", "kind" : "folder", "order" : 10, @@ -32547,7 +33633,7 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.projects.hide", + "id" : "sidebar.section.hide", "kind" : "action", "order" : 12, "visible_when" : { @@ -32558,11 +33644,15 @@ ] } }, + { + "kind" : "separator", + "order" : 13 + }, { "enabled_when" : "can_perform", - "id" : "sidebar.sections.showHidden", + "id" : "sidebar.section.remove", "kind" : "action", - "order" : 13, + "order" : 14, "visible_when" : { "debug_only" : false, "feature" : null, @@ -32571,13 +33661,9 @@ ] } }, - { - "kind" : "separator", - "order" : 14 - }, { "enabled_when" : "can_perform", - "id" : "toggleSidebar", + "id" : "sidebar.section.closeWorkspaces", "kind" : "action", "order" : 15, "visible_when" : { @@ -32590,12 +33676,13 @@ }, { "enabled_when" : "can_perform", - "id" : "appStore.show", + "id" : "sidebar.item.hideApp", "kind" : "action", + "label" : "Hide App", "order" : 16, "visible_when" : { "debug_only" : false, - "feature" : "apps", + "feature" : null, "requires" : [ ] @@ -32606,12 +33693,13 @@ ] }, - "sidebarFooter" : { + "space" : { "entries" : [ { "enabled_when" : "can_perform", - "id" : "sidebar.spaces.show", + "id" : "space.newWorkspace", "kind" : "action", + "label" : "New Workspace", "order" : 0, "visible_when" : { "debug_only" : false, @@ -32623,46 +33711,28 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.profileControl.show", + "id" : "space.newWindow", "kind" : "action", + "label" : "New Window", "order" : 1, "visible_when" : { "debug_only" : false, "feature" : null, "requires" : [ - ] - } - } - ], - "implied" : [ - - ] - }, - "sidebarItem" : { - "entries" : [ - { - "enabled_when" : "can_perform", - "id" : "sidebar.item.toggleLabel", - "kind" : "action", - "order" : 0, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - ] } }, { "kind" : "separator", - "order" : 1 + "order" : 2 }, { "enabled_when" : "can_perform", - "id" : "sidebar.profileControl.hide", + "id" : "space.setIcon", "kind" : "action", - "order" : 2, + "label" : "Change Space Icon…", + "order" : 3, "visible_when" : { "debug_only" : false, "feature" : null, @@ -32671,14 +33741,11 @@ ] } }, - { - "kind" : "separator", - "order" : 3 - }, { "enabled_when" : "can_perform", - "id" : "sidebar.item.removeEverywhere", + "id" : "space.clearIcon", "kind" : "action", + "label" : "Remove Space Icon", "order" : 4, "visible_when" : { "debug_only" : false, @@ -32690,7 +33757,7 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.item.remove", + "id" : "space.rename", "kind" : "action", "order" : 5, "visible_when" : { @@ -32701,88 +33768,13 @@ ] } }, - { - "enabled_when" : "can_perform", - "id" : "sidebar.item.hideApp", - "kind" : "action", - "order" : 6, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - } - ], - "implied" : [ - - ] - }, - "sidebarSection" : { - "entries" : [ - { - "enabled_when" : "can_perform", - "id" : "sidebar.section.add", - "kind" : "action", - "order" : 0, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "sidebar.section.newWorkspace", - "kind" : "action", - "order" : 1, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "enabled_when" : "can_perform", - "id" : "sidebar.item.add", - "kind" : "action", - "order" : 2, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "kind" : "separator", - "order" : 3 - }, - { - "enabled_when" : "can_perform", - "id" : "sidebar.section.rename", - "kind" : "action", - "order" : 4, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, { "children" : [ { "enabled_when" : "can_perform", - "id" : "sidebar.section.useBuiltInLook", + "id" : "space.color.grey", "kind" : "action", + "label" : "Grey", "order" : 0, "visible_when" : { "debug_only" : false, @@ -32794,8 +33786,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.useListLook", + "id" : "space.color.blue", "kind" : "action", + "label" : "Blue", "order" : 1, "visible_when" : { "debug_only" : false, @@ -32807,8 +33800,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.toggleTitle", + "id" : "space.color.red", "kind" : "action", + "label" : "Red", "order" : 2, "visible_when" : { "debug_only" : false, @@ -32820,8 +33814,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.layoutList", + "id" : "space.color.yellow", "kind" : "action", + "label" : "Yellow", "order" : 3, "visible_when" : { "debug_only" : false, @@ -32833,8 +33828,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.layoutInline", + "id" : "space.color.green", "kind" : "action", + "label" : "Green", "order" : 4, "visible_when" : { "debug_only" : false, @@ -32846,8 +33842,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.layoutGrid", + "id" : "space.color.pink", "kind" : "action", + "label" : "Pink", "order" : 5, "visible_when" : { "debug_only" : false, @@ -32859,8 +33856,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.setAlignment", + "id" : "space.color.purple", "kind" : "action", + "label" : "Purple", "order" : 6, "visible_when" : { "debug_only" : false, @@ -32872,8 +33870,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.setGap", + "id" : "space.color.cyan", "kind" : "action", + "label" : "Cyan", "order" : 7, "visible_when" : { "debug_only" : false, @@ -32885,8 +33884,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.setColumns", + "id" : "space.color.orange", "kind" : "action", + "label" : "Orange", "order" : 8, "visible_when" : { "debug_only" : false, @@ -32895,35 +33895,17 @@ ] } - } - ], - "folder" : "appearance", - "kind" : "folder", - "order" : 5, - "title" : "Appearance", - "title_key" : "menu.folder.appearance", - "title_table" : "Localizable" - }, - { - "children" : [ + }, { - "enabled_when" : "can_perform", - "id" : "sidebar.section.toggleSpaceScope", - "kind" : "action", - "order" : 0, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } + "kind" : "separator", + "order" : 9 }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.setMaxRows", + "id" : "space.clearColor", "kind" : "action", - "order" : 1, + "label" : "Remove Color", + "order" : 10, "visible_when" : { "debug_only" : false, "feature" : null, @@ -32931,56 +33913,118 @@ ] } - } - ], - "folder" : "options", - "kind" : "folder", - "order" : 6, - "title" : "Options", - "title_key" : "menu.folder.options", - "title_table" : "Localizable" - }, - { - "kind" : "separator", - "order" : 7 - }, - { - "enabled_when" : "can_perform", - "id" : "sidebar.section.toggleCollapsed", - "kind" : "action", - "order" : 8, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - }, - { - "kind" : "separator", - "order" : 9 - }, - { - "children" : [ + }, { - "enabled_when" : "can_perform", - "id" : "sidebar.section.moveToTop", - "kind" : "action", - "order" : 0, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } + "kind" : "separator", + "order" : 11 }, { + "choices" : { + "argument" : "theme", + "more_opens_palette" : true, + "values" : [ + { + "title" : "Use Ghostty Config", + "title_key" : null, + "title_table" : null, + "value" : "config" + }, + { + "title" : "Rose Pine", + "title_key" : null, + "title_table" : null, + "value" : "Rose Pine" + }, + { + "title" : "Rose Pine Moon", + "title_key" : null, + "title_table" : null, + "value" : "Rose Pine Moon" + }, + { + "title" : "Catppuccin Mocha", + "title_key" : null, + "title_table" : null, + "value" : "Catppuccin Mocha" + }, + { + "title" : "TokyoNight", + "title_key" : null, + "title_table" : null, + "value" : "TokyoNight" + }, + { + "title" : "Gruvbox Dark", + "title_key" : null, + "title_table" : null, + "value" : "Gruvbox Dark" + }, + { + "title" : "Nord", + "title_key" : null, + "title_table" : null, + "value" : "Nord" + }, + { + "title" : "Vesper", + "title_key" : null, + "title_table" : null, + "value" : "Vesper" + }, + { + "title" : "Soft Blue", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue" + }, + { + "title" : "Clay Orange", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange" + }, + { + "title" : "Olive Grey", + "title_key" : null, + "title_table" : null, + "value" : "Olive Grey" + }, + { + "title" : "Rose Pine Dawn", + "title_key" : null, + "title_table" : null, + "value" : "Rose Pine Dawn" + }, + { + "title" : "Catppuccin Latte", + "title_key" : null, + "title_table" : null, + "value" : "Catppuccin Latte" + }, + { + "title" : "GitHub Light Default", + "title_key" : null, + "title_table" : null, + "value" : "GitHub Light Default" + }, + { + "title" : "Soft Blue Light", + "title_key" : null, + "title_table" : null, + "value" : "Soft Blue Light" + }, + { + "title" : "Clay Orange Light", + "title_key" : null, + "title_table" : null, + "value" : "Clay Orange Light" + } + ] + }, "enabled_when" : "can_perform", - "id" : "sidebar.section.moveToScrolling", - "kind" : "action", - "order" : 1, + "id" : "space.setTheme", + "kind" : "choices", + "order" : 12, "visible_when" : { "debug_only" : false, "feature" : null, @@ -32991,9 +34035,9 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.moveToBottom", + "id" : "space.clearTheme", "kind" : "action", - "order" : 2, + "order" : 13, "visible_when" : { "debug_only" : false, "feature" : null, @@ -33003,22 +34047,29 @@ } } ], - "folder" : "move", - "kind" : "folder", - "order" : 10, - "title" : "Move", - "title_key" : "menu.folder.move", - "title_table" : "Localizable" - }, - { - "kind" : "separator", - "order" : 11 + "id" : "space.setColor", + "kind" : "submenu", + "label" : "Edit Theme Color…", + "order" : 6, + "title_from" : "action", + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } }, { + "choices" : { + "argument" : "browserProfile", + "target_kind" : "browser-profile" + }, "enabled_when" : "can_perform", - "id" : "sidebar.section.hide", - "kind" : "action", - "order" : 12, + "id" : "browserProfile.setSpaceDefault", + "kind" : "choices", + "label" : "Set Browser Profile…", + "order" : 7, "visible_when" : { "debug_only" : false, "feature" : null, @@ -33029,13 +34080,14 @@ }, { "kind" : "separator", - "order" : 13 + "order" : 8 }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.remove", + "id" : "space.newGroup", "kind" : "action", - "order" : 14, + "label" : "New Group", + "order" : 9, "visible_when" : { "debug_only" : false, "feature" : null, @@ -33044,11 +34096,15 @@ ] } }, + { + "kind" : "separator", + "order" : 10 + }, { "enabled_when" : "can_perform", - "id" : "sidebar.section.closeWorkspaces", + "id" : "sidebar.spaces.hide", "kind" : "action", - "order" : 15, + "order" : 11, "visible_when" : { "debug_only" : false, "feature" : null, @@ -33057,11 +34113,15 @@ ] } }, + { + "kind" : "separator", + "order" : 12 + }, { "enabled_when" : "can_perform", - "id" : "sidebar.item.hideApp", + "id" : "space.delete", "kind" : "action", - "order" : 16, + "order" : 13, "visible_when" : { "debug_only" : false, "feature" : null, @@ -33584,6 +34644,7 @@ "enabled_when" : "can_perform", "id" : "agentChat.toggleNotificationMute", "kind" : "action", + "label" : "Mute\/Unmute Chat Notifications", "order" : 6, "visible_when" : { "debug_only" : false, @@ -33736,10 +34797,6 @@ "kind" : "separator", "order" : 6 }, - { - "kind" : "separator", - "order" : 7 - }, { "children" : [ { @@ -33957,14 +35014,14 @@ ], "folder" : "move", "kind" : "folder", - "order" : 8, + "order" : 7, "title" : "Move", "title_key" : "menu.folder.move", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 9 + "order" : 8 }, { "children" : [ @@ -34049,14 +35106,14 @@ ], "folder" : "copy", "kind" : "folder", - "order" : 10, + "order" : 9, "title" : "Copy", "title_key" : "menu.folder.copy", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 11 + "order" : 10 }, { "children" : [ @@ -34102,7 +35159,7 @@ ], "folder" : "close", "kind" : "folder", - "order" : 12, + "order" : 11, "title" : "Close", "title_key" : "menu.folder.close", "title_table" : "Localizable" @@ -34160,6 +35217,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.rename", "kind" : "action", + "label" : "Rename Group…", "order" : 2, "visible_when" : { "debug_only" : false, @@ -34177,6 +35235,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.grey", "kind" : "action", + "label" : "Grey", "order" : 0, "visible_when" : { "debug_only" : false, @@ -34190,6 +35249,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.blue", "kind" : "action", + "label" : "Blue", "order" : 1, "visible_when" : { "debug_only" : false, @@ -34203,6 +35263,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.red", "kind" : "action", + "label" : "Red", "order" : 2, "visible_when" : { "debug_only" : false, @@ -34216,6 +35277,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.yellow", "kind" : "action", + "label" : "Yellow", "order" : 3, "visible_when" : { "debug_only" : false, @@ -34229,6 +35291,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.green", "kind" : "action", + "label" : "Green", "order" : 4, "visible_when" : { "debug_only" : false, @@ -34242,6 +35305,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.pink", "kind" : "action", + "label" : "Pink", "order" : 5, "visible_when" : { "debug_only" : false, @@ -34255,6 +35319,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.purple", "kind" : "action", + "label" : "Purple", "order" : 6, "visible_when" : { "debug_only" : false, @@ -34268,6 +35333,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.cyan", "kind" : "action", + "label" : "Cyan", "order" : 7, "visible_when" : { "debug_only" : false, @@ -34281,6 +35347,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.color.orange", "kind" : "action", + "label" : "Orange", "order" : 8, "visible_when" : { "debug_only" : false, @@ -34293,6 +35360,7 @@ ], "id" : "tabGroup.setColor", "kind" : "submenu", + "label" : "Color", "order" : 0, "title_from" : "action", "visible_when" : { @@ -34347,6 +35415,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.toggleCollapsed", "kind" : "action", + "label" : "Collapse\/Expand Group", "order" : 5, "visible_when" : { "debug_only" : false, @@ -34402,6 +35471,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.moveLeft", "kind" : "action", + "label" : "Move Left", "order" : 0, "visible_when" : { "debug_only" : false, @@ -34415,6 +35485,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.moveRight", "kind" : "action", + "label" : "Move Right", "order" : 1, "visible_when" : { "debug_only" : false, @@ -34428,6 +35499,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.moveToNewSplit", "kind" : "action", + "label" : "Move to New Split", "order" : 2, "visible_when" : { "debug_only" : false, @@ -34441,6 +35513,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.moveToNewColumn", "kind" : "action", + "label" : "Move to New Column", "order" : 3, "visible_when" : { "debug_only" : false, @@ -34454,6 +35527,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.moveToNewWorkspace", "kind" : "action", + "label" : "Move to New Workspace", "order" : 4, "visible_when" : { "debug_only" : false, @@ -34467,6 +35541,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.moveToWorkspace", "kind" : "action", + "label" : "Move to Workspace…", "order" : 5, "visible_when" : { "debug_only" : false, @@ -34480,6 +35555,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.moveToNewWindow", "kind" : "action", + "label" : "Move to New Window", "order" : 6, "visible_when" : { "debug_only" : false, @@ -34505,6 +35581,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.ungroup", "kind" : "action", + "label" : "Ungroup", "order" : 10, "visible_when" : { "debug_only" : false, @@ -34518,6 +35595,7 @@ "enabled_when" : "can_perform", "id" : "tabGroup.close", "kind" : "action", + "label" : "Close Group", "order" : 11, "visible_when" : { "debug_only" : false, @@ -34931,6 +36009,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.grey", "kind" : "action", + "label" : "Grey", "order" : 0, "visible_when" : { "debug_only" : false, @@ -34944,6 +36023,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.blue", "kind" : "action", + "label" : "Blue", "order" : 1, "visible_when" : { "debug_only" : false, @@ -34957,6 +36037,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.red", "kind" : "action", + "label" : "Red", "order" : 2, "visible_when" : { "debug_only" : false, @@ -34970,6 +36051,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.yellow", "kind" : "action", + "label" : "Yellow", "order" : 3, "visible_when" : { "debug_only" : false, @@ -34983,6 +36065,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.green", "kind" : "action", + "label" : "Green", "order" : 4, "visible_when" : { "debug_only" : false, @@ -34996,6 +36079,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.pink", "kind" : "action", + "label" : "Pink", "order" : 5, "visible_when" : { "debug_only" : false, @@ -35009,6 +36093,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.purple", "kind" : "action", + "label" : "Purple", "order" : 6, "visible_when" : { "debug_only" : false, @@ -35022,6 +36107,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.cyan", "kind" : "action", + "label" : "Cyan", "order" : 7, "visible_when" : { "debug_only" : false, @@ -35035,6 +36121,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.color.orange", "kind" : "action", + "label" : "Orange", "order" : 8, "visible_when" : { "debug_only" : false, @@ -35047,6 +36134,7 @@ ], "id" : "workspaceGroup.setColor", "kind" : "submenu", + "label" : "Color", "order" : 0, "title_from" : "action", "visible_when" : { @@ -35150,6 +36238,7 @@ "enabled_when" : "can_perform", "id" : "toggleFocusedWorkspaceGroupCollapsed", "kind" : "action", + "label" : "Collapse\/Expand Group", "order" : 8, "visible_when" : { "debug_only" : false, @@ -35205,6 +36294,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.moveUp", "kind" : "action", + "label" : "Move Up", "order" : 0, "visible_when" : { "debug_only" : false, @@ -35218,6 +36308,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.moveDown", "kind" : "action", + "label" : "Move Down", "order" : 1, "visible_when" : { "debug_only" : false, @@ -35231,6 +36322,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.moveToNewWindow", "kind" : "action", + "label" : "Move to New Window", "order" : 2, "visible_when" : { "debug_only" : false, @@ -35244,6 +36336,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.moveToWindow", "kind" : "action", + "label" : "Move to Window…", "order" : 3, "visible_when" : { "debug_only" : false, @@ -35257,6 +36350,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.moveToSpace", "kind" : "action", + "label" : "Move to Space…", "order" : 4, "visible_when" : { "debug_only" : false, @@ -35327,6 +36421,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.ungroup", "kind" : "action", + "label" : "Ungroup", "order" : 0, "visible_when" : { "debug_only" : false, @@ -35340,6 +36435,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.closeWorkspaces", "kind" : "action", + "label" : "Close Group", "order" : 1, "visible_when" : { "debug_only" : false, @@ -35666,6 +36762,19 @@ "feature" : null, "requires" : [ + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "saveLayoutTemplate", + "kind" : "action", + "order" : 9, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + ] } } @@ -35970,6 +37079,7 @@ "enabled_when" : "can_perform", "id" : "notifications.toggleWorkspaceMute", "kind" : "action", + "label" : "Mute\/Unmute Notifications", "order" : 8, "visible_when" : { "debug_only" : false, @@ -36131,33 +37241,6 @@ "kind" : "separator", "order" : 4 }, - { - "children" : [ - { - "enabled_when" : "can_perform", - "id" : "saveLayoutTemplate", - "kind" : "action", - "order" : 0, - "visible_when" : { - "debug_only" : false, - "feature" : null, - "requires" : [ - - ] - } - } - ], - "folder" : "group", - "kind" : "folder", - "order" : 5, - "title" : "Group", - "title_key" : "menu.folder.group", - "title_table" : "Localizable" - }, - { - "kind" : "separator", - "order" : 6 - }, { "children" : [ { @@ -36293,14 +37376,14 @@ ], "folder" : "move", "kind" : "folder", - "order" : 7, + "order" : 5, "title" : "Move", "title_key" : "menu.folder.move", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 8 + "order" : 6 }, { "children" : [ @@ -36385,7 +37468,7 @@ ], "folder" : "copy", "kind" : "folder", - "order" : 9, + "order" : 7, "title" : "Copy", "title_key" : "menu.folder.copy", "title_table" : "Localizable" @@ -36434,14 +37517,14 @@ ], "folder" : "tools", "kind" : "folder", - "order" : 10, + "order" : 8, "title" : "Tools", "title_key" : "menu.folder.tools", "title_table" : "Localizable" }, { "kind" : "separator", - "order" : 11 + "order" : 9 }, { "children" : [ @@ -36513,7 +37596,7 @@ ], "folder" : "close", "kind" : "folder", - "order" : 12, + "order" : 10, "title" : "Close", "title_key" : "menu.folder.close", "title_table" : "Localizable" @@ -36595,8 +37678,68 @@ "source" : "CmuxNextApp\/Notifications\/Panel\/NotificationsPanelController.swift" }, { - "name" : "homeTranscriptRow", - "source" : "CmuxNextHome\/NativeTranscript\/HomeRowHostView.swift" + "name" : "agentPaneMessage", + "source" : "CmuxNextAgentPane\/AgentPaneContextMenu.swift" + }, + { + "name" : "paneToolbarMore", + "source" : "CmuxNextApp\/PaneToolbar.swift" + }, + { + "name" : "browserReload", + "source" : "CmuxNextBrowser\/UI\/BrowserReloadMenu.swift" + }, + { + "name" : "browserDownloads", + "source" : "CmuxNextApp\/Handlers\/BrowserDownloadsMenu.swift" + }, + { + "name" : "browserMedia", + "source" : "CmuxNextApp\/Handlers\/BrowserMediaMenu.swift" + }, + { + "name" : "browserDevice", + "source" : "CmuxNextBrowser\/UI\/BrowserDeviceMenu.swift" + }, + { + "name" : "browserMachineBadge", + "source" : "CmuxNextApp\/AppServices+BrowserTabMachines.swift" + }, + { + "name" : "remoteBrowserPage", + "source" : "CmuxNextRemoteBrowser\/Native\/RemoteBrowserNativeUI.swift" + }, + { + "name" : "sidebarNoticeCard", + "source" : "CmuxNextSidebar\/Views\/SidebarNoticeCardView.swift" + }, + { + "name" : "sidebarUpdateCard", + "source" : "CmuxNextSidebar\/Views\/SidebarUpdateCardView.swift" + }, + { + "name" : "debugSidebarToggleIcon", + "source" : "CmuxNextApp\/Windows\/SidebarToggleIconMenu.swift" + }, + { + "name" : "debugStatusIconSet", + "source" : "CmuxNextApp\/Windows\/StatusIconSetMenu.swift" + }, + { + "name" : "homeBackground", + "source" : "CmuxNextHomeEngine\/HomePageMenus.swift" + }, + { + "name" : "historyToolbar", + "source" : "CmuxNextHistory\/HistoryPageView.swift" + }, + { + "name" : "historyRow", + "source" : "CmuxNextHistory\/HistoryPageRow.swift" + }, + { + "name" : "taskAssignee", + "source" : "CmuxNextTasks\/Views\/AssigneeMenu.swift" } ], "version" : 1 diff --git a/plans/cmux-next/actions.md b/plans/cmux-next/actions.md index a6dc532ba1ef..e2fdb431c0a0 100644 --- a/plans/cmux-next/actions.md +++ b/plans/cmux-next/actions.md @@ -47,22 +47,22 @@ Declare its placements (menu, group, rank) and whether the CLI offers it, or nam - `agents`: 60 - `cloud`: 52 - `remote`: 11 -- `settings`: 83 +- `settings`: 84 -## Counts (965 actions) +## Counts (966 actions) -Palette 929, CLI verbs 523, right-click 499, MCP tools 449. +Palette 930, CLI verbs 524, right-click 499, MCP tools 449. ## Menus -- **tab**: tab.restart renameTab palette.toggleTabPin newTab.toRight duplicateTab | tabGroup.create tabGroup.addTab tabGroup.removeTab | [more] > ([new] > (newTab.ofKind newSurface openBrowser.chromium) | [openIn] > (browser.openInChromium browserProfile.moveTab browserProfile.duplicateTab) | browser.resetPinnedTab [options] > (reloadTab | palette.clearTabName palette.toggleTabUnread toggleTabAudioMute terminal.setTheme[choices] agentChat.toggleNotificationMute terminal.clearTheme tab.setIcon tab.clearIcon terminal.keep | palette.toggleFullWidthTab | hibernateTab wakeTab disconnectRemoteTab | tab.showResources) | | [move] > (cloudBringBack cloudMove | moveSurfaceToPaneLeft moveSurfaceToPaneRight moveSurfaceToPaneUp moveSurfaceToPaneDown tab.moveToNewSplit tab.moveToNewColumn palette.moveTabToNewWorkspace tab.moveToNewWindow tab.moveToNewDockColumn moveSurfaceLeft moveSurfaceRight moveSurfaceToPreviousPane moveSurfaceToNextPane tab.moveToWorkspace) | [copy] > (palette.copySurfaceID palette.copySurfaceLink palette.copyIdentifiers palette.copyPaneID palette.copyPaneLink agentChat.copyIdentifier) | [close] > (closeTabsToLeft closeTabsToRight closeOtherTabsInPane)) | closeTab +- **tab**: tab.restart renameTab palette.toggleTabPin newTab.toRight duplicateTab | tabGroup.create tabGroup.addTab tabGroup.removeTab | [more] > ([new] > (newTab.ofKind newSurface openBrowser.chromium) | [openIn] > (browser.openInChromium browserProfile.moveTab browserProfile.duplicateTab) | browser.resetPinnedTab [options] > (reloadTab | palette.clearTabName palette.toggleTabUnread toggleTabAudioMute terminal.setTheme[choices] agentChat.toggleNotificationMute terminal.clearTheme tab.setIcon tab.clearIcon terminal.keep | palette.toggleFullWidthTab | hibernateTab wakeTab disconnectRemoteTab | tab.showResources) | [move] > (cloudBringBack cloudMove | moveSurfaceToPaneLeft moveSurfaceToPaneRight moveSurfaceToPaneUp moveSurfaceToPaneDown tab.moveToNewSplit tab.moveToNewColumn palette.moveTabToNewWorkspace tab.moveToNewWindow tab.moveToNewDockColumn moveSurfaceLeft moveSurfaceRight moveSurfaceToPreviousPane moveSurfaceToNextPane tab.moveToWorkspace) | [copy] > (palette.copySurfaceID palette.copySurfaceLink palette.copyIdentifiers palette.copyPaneID palette.copyPaneLink agentChat.copyIdentifier) | [close] > (closeTabsToLeft closeTabsToRight closeOtherTabsInPane)) | closeTab - **tabGroup**: tabGroup.newTab | tabGroup.rename [appearance] > (tabGroup.setColor > (tabGroup.color.grey tabGroup.color.blue tabGroup.color.red tabGroup.color.yellow tabGroup.color.green tabGroup.color.pink tabGroup.color.purple tabGroup.color.cyan tabGroup.color.orange) tabGroup.setIcon tabGroup.clearIcon) | tabGroup.toggleCollapsed [group] > (tabGroup.save tabGroup.unsave) | [move] > (tabGroup.moveLeft tabGroup.moveRight tabGroup.moveToNewSplit tabGroup.moveToNewColumn tabGroup.moveToNewWorkspace tabGroup.moveToWorkspace tabGroup.moveToNewWindow) | tabGroup.ungroup tabGroup.close - **screen**: screen.new [new] > (screen.duplicate screen.newWith) | screen.rename screen.clearName screen.togglePin [appearance] > (screen.setColor > (screen.color.grey screen.color.blue screen.color.red screen.color.yellow screen.color.green screen.color.pink screen.color.purple screen.color.cyan screen.color.orange screen.clearColor) screen.setIcon screen.clearIcon) | [group] > (screenGroup.create screenGroup.addScreen screenGroup.removeScreen) | [move] > (screen.moveLeft screen.moveRight screen.moveToWorkspace screen.moveToNewWorkspace screen.moveToNewWindow) | screen.close [close] > (screen.closeOthers screen.closeToRight screen.closeToLeft) - **screenGroup**: screenGroup.newScreen | screenGroup.rename [appearance] > (screenGroup.setColor > (screenGroup.color.grey screenGroup.color.blue screenGroup.color.red screenGroup.color.yellow screenGroup.color.green screenGroup.color.pink screenGroup.color.purple screenGroup.color.cyan screenGroup.color.orange) screenGroup.setIcon screenGroup.clearIcon) | screenGroup.toggleCollapsed [group] > (screenGroup.save screenGroup.unsave) | [move] > (screenGroup.moveLeft screenGroup.moveRight screenGroup.moveToWorkspace screenGroup.moveToNewWorkspace screenGroup.moveToNewWindow) | screenGroup.ungroup screenGroup.close -- **pane**: splitRight splitDown toggleSplitZoom renamePane [more] > ([new] > (newColumn newRow splitLeft splitUp splitBrowserRight splitBrowserDown remote.openTerminalHere newPaneAutoLayout | palette.newSimulatorPane) | | [move] > (pane.moveToNewWorkspace swapPaneLeft swapPaneRight swapPaneUp swapPaneDown column.moveLeft column.moveRight) | [layout] > (equalizeSplits toggleCanvasLayout pane.toggleTabBar | column.dock column.undock column.float column.dockLeft column.dockRight column.dockTop column.dockBottom | column.widthOneThird column.widthHalf column.widthTwoThirds column.widthFull column.center agentPaneZoomIn agentPaneZoomOut agentPaneZoomReset) | [connection] > (palette.swapWithSession reconnectPane) | triggerFlash [copy] > (palette.copyPaneID palette.copyPaneLink) | pane.closeOthers) | closePane -- **workspaceRow**: renameWorkspace palette.toggleWorkspacePin palette.workspaceColor[choices] workspace.newBelow | moveWorkspaceToGroup[choices] groupSelectedWorkspaces removeWorkspaceFromGroup | [more] > ([new] > (workspace.newLikeThis workspace.newAbove workspace.newInNewGroup workspace.duplicate workspace.duplicateTerminalsOnly | palette.openFolderInVSCodeInline palette.openWorkspaceTodoPane palette.layout.open) | [appearance] > (palette.resetWorkspaceColor workspace.setIcon workspace.clearIcon workspace.setTheme[choices] workspace.clearTheme) [options] > (palette.clearWorkspaceName editWorkspaceDescription palette.clearWorkspaceDescription palette.workspaceStatus markWorkspaceDone palette.markWorkspaceRead notifications.dismissHighlight palette.markWorkspaceUnread notifications.toggleWorkspaceMute clearWorkspaceNotifications palette.addWorkspaceChecklistItem | browserProfile.setWorkspaceDefault browserProfile.clearWorkspaceDefault | reconnectWorkspace disconnectWorkspace workspace.setRemoteLocalhost[choices] | sidebar.workspaceTabs.toggle) | [group] > (saveLayoutTemplate) | [move] > (moveWorkspaceUp moveWorkspaceDown palette.moveWorkspaceToTop workspace.moveToBottom moveWorkspaceToWindow moveWorkspaceToNewWindow workspace.toggleTop workspace.moveToSpace workspace.duplicateToSpace workspace.mergeInto) | [copy] > (workspace.copyPath palette.copyWorkspaceID palette.copyWorkspaceLink palette.copyWorkspaceIDAndRef copyWorkspaceSSHError agentChat.copyIdentifier) [tools] > (workspace.showResources revealWorkspaceInFinder palette.openWorkspacePullRequests) | [close] > (palette.closeOtherWorkspaces workspace.closeOthersInGroup palette.closeWorkspacesBelow palette.closeWorkspacesAbove workspace.closeAll)) | closeWorkspace +- **pane**: splitRight splitDown [split] > (splitLeft splitUp) toggleSplitZoom renamePane [more] > ([new] > (newColumn newRow splitBrowserRight splitBrowserDown remote.openTerminalHere newPaneAutoLayout | palette.newSimulatorPane) | [move] > (pane.moveToNewWorkspace swapPaneLeft swapPaneRight swapPaneUp swapPaneDown column.moveLeft column.moveRight) | [layout] > (equalizeSplits toggleCanvasLayout pane.toggleTabBar | column.dock column.undock column.float column.dockLeft column.dockRight column.dockTop column.dockBottom | column.widthOneThird column.widthHalf column.widthTwoThirds column.widthFull column.center agentPaneZoomIn agentPaneZoomOut agentPaneZoomReset) | [connection] > (palette.swapWithSession reconnectPane) | triggerFlash [copy] > (palette.copyPaneID palette.copyPaneLink) | pane.closeOthers) | closePane +- **workspaceRow**: renameWorkspace palette.toggleWorkspacePin palette.workspaceColor[choices] workspace.newBelow | moveWorkspaceToGroup[choices] groupSelectedWorkspaces removeWorkspaceFromGroup | [more] > ([new] > (workspace.newLikeThis workspace.newAbove workspace.newInNewGroup workspace.duplicate workspace.duplicateTerminalsOnly | palette.openFolderInVSCodeInline palette.openWorkspaceTodoPane palette.layout.open saveLayoutTemplate) | [appearance] > (palette.resetWorkspaceColor workspace.setIcon workspace.clearIcon workspace.setTheme[choices] workspace.clearTheme) [options] > (palette.clearWorkspaceName editWorkspaceDescription palette.clearWorkspaceDescription palette.workspaceStatus markWorkspaceDone palette.markWorkspaceRead notifications.dismissHighlight palette.markWorkspaceUnread notifications.toggleWorkspaceMute clearWorkspaceNotifications palette.addWorkspaceChecklistItem | browserProfile.setWorkspaceDefault browserProfile.clearWorkspaceDefault | reconnectWorkspace disconnectWorkspace workspace.setRemoteLocalhost[choices] | sidebar.workspaceTabs.toggle) | [move] > (moveWorkspaceUp moveWorkspaceDown palette.moveWorkspaceToTop workspace.moveToBottom moveWorkspaceToWindow moveWorkspaceToNewWindow workspace.toggleTop workspace.moveToSpace workspace.duplicateToSpace workspace.mergeInto) | [copy] > (workspace.copyPath palette.copyWorkspaceID palette.copyWorkspaceLink palette.copyWorkspaceIDAndRef copyWorkspaceSSHError agentChat.copyIdentifier) [tools] > (workspace.showResources revealWorkspaceInFinder palette.openWorkspacePullRequests) | [close] > (palette.closeOtherWorkspaces workspace.closeOthersInGroup palette.closeWorkspacesBelow palette.closeWorkspacesAbove workspace.closeAll)) | closeWorkspace - **workspaceGroup**: workspaceGroup.newWorkspace workspaceGroup.duplicate | workspaceGroup.rename workspaceGroup.togglePin [appearance] > (workspaceGroup.setColor > (workspaceGroup.color.grey workspaceGroup.color.blue workspaceGroup.color.red workspaceGroup.color.yellow workspaceGroup.color.green workspaceGroup.color.pink workspaceGroup.color.purple workspaceGroup.color.cyan workspaceGroup.color.orange) workspaceGroup.setIcon workspaceGroup.clearIcon) [options] > (workspaceGroup.markRead workspaceGroup.markUnread workspaceGroup.clearNotifications) | toggleFocusedWorkspaceGroupCollapsed [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll) | [move] > (workspaceGroup.moveUp workspaceGroup.moveDown workspaceGroup.moveToNewWindow workspaceGroup.moveToWindow workspaceGroup.moveToSpace workspaceGroup.addToSection) | workspaceGroup.editConfig workspaceGroup.copyID | [close] > (workspaceGroup.ungroup workspaceGroup.closeWorkspaces workspaceGroup.delete workspaceGroup.closeOthers) -- **sidebarBackground**: newTab newBrowserWorkspace openFolder newWorkspaceGroup [new] > (workspace.newAtTop workspace.newAtBottom workspace.newOnMachine space.new reopenClosedWorkspace reopenPreviousSession | newCloudWorkspace remote.connect | sidebar.home.add sidebar.item.add sidebar.section.add) | [options] > (sidebar.workspaceTabs.toggle palette.enableMinimalMode palette.disableMinimalMode sidebar.density.compact sidebar.density.comfortable sidebar.density.expanded | sidebar.layout.reset) | [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll sidebar.groupBy.none sidebar.groupBy.folder) | [move] > (workspace.sortByName workspace.sortByLastUsed workspace.sortByDirectory) | sidebar.projects.hide sidebar.sections.showHidden | toggleSidebar appStore.show +- **sidebarBackground**: newTab newBrowserWorkspace openFolder newWorkspaceGroup [new] > (workspace.newAtTop workspace.newAtBottom workspace.newOnMachine space.new reopenClosedWorkspace reopenPreviousSession | newCloudWorkspace remote.connect | sidebar.home.add sidebar.item.add sidebar.section.add) | [options] > (sidebar.workspaceTabs.toggle palette.enableMinimalMode palette.disableMinimalMode sidebar.density.compact sidebar.density.comfortable sidebar.density.expanded | sidebar.layout.reset) | [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll sidebar.groupBy.none sidebar.groupBy.folder) | [sort] > (workspace.sortByName workspace.sortByLastUsed workspace.sortByDirectory) | sidebar.projects.hide sidebar.sections.showHidden | toggleSidebar appStore.show - **terminalSelection**: terminalCopy terminalPaste terminal.selectAll useSelectionForFind terminal.sendText | [split] > (splitRight splitDown splitLeft splitUp) [agent] > (palette.forkAgentConversationRight palette.forkAgentConversationNewTab palette.forkAgentConversationLeft palette.forkAgentConversationTop palette.forkAgentConversationBottom palette.forkAgentConversationNewWorkspace) | [options] > (clearScreenKeepScrollback resetTerminal terminal.clear) | toggleSplitZoom | palette.terminalOpenDirectory - **browserPage**: browserBack browserForward browserReload browserHardReload browser.copyURL browser.openLocally | browserTheme[choices] [options] > (toggleBrowserFocusMode toggleBrowserDesignMode) | browserPrint | bookmark.addPage | toggleBrowserDeveloperTools [tools] > (palette.browserOpenDefault browserScreenshotPage browserScreenshotSection browserDevice | browser.pageInfo > (browser.pageInfo browser.pageInfo.connection browser.pageInfo.certificate browser.pageInfo.reenableCertificateWarnings | browser.pageInfo.setPermission browser.pageInfo.resetPermissions | browser.pageInfo.cookies browser.pageInfo.manageSiteData browser.pageInfo.deleteSiteData | browser.pageInfo.siteSettings browser.pageInfo.aboutThisPage) showBrowserJavaScriptConsole inspectBrowserElement | browser.extensions.menu browser.extensions.manage browser.extensions.webStore browser.extensions.loadUnpacked) - **browserLink**: browser.link.openInNewTab browser.link.openInNewWindow browser.link.openInNewSpace browser.link.openInNewWorkspace browser.link.openInSplit openLinkInDefaultBrowser | browser.link.openInIncognitoWindow | browser.link.saveAs browser.link.copy browser.link.copyText @@ -138,7 +138,7 @@ Palette 929, CLI verbs 523, right-click 499, MCP tools 449. **guiOnly** (20): `browser.media.show`, `browser.downloads.show`, `browser.profile.choose`, `browser.overflow.menu`, `browser.download.openBlockedSiteSettings`, `agentPane.continueIn`, `agentPane.switchModel`, `agentPane.createCheckpoint`, `agentPane.searchFiles`, `agentPane.copyLastCodeBlock`, `agentPane.archiveChat`, `agentPane.previousTurn`, `agentPane.nextTurn`, `agentPane.permission.allowOnce`, `agentPane.permission.allowChat`, `agentPane.permission.deny`, `agentPane.permission.expand`, `agentPane.permission.retry`, `agentPane.permission.revoke`, `agentPane.permission.refresh` -**noObject** (194): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `reopenClosedBrowserPanel`, `home.show`, `home.tab`, `home.channels`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.saveFlightRecording`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `stats.show`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `agentActivity.open`, `computerUseStop`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.customize`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `music.open`, `music.stop`, `bookmark.add`, `bookmark.import`, `bookmark.importFromBrowser`, `bookmark.export`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth` +**noObject** (195): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `reopenClosedBrowserPanel`, `home.show`, `home.tab`, `home.channels`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.saveFlightRecording`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `stats.show`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `agentActivity.open`, `computerUseStop`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.customize`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `appearance.chromeStyle.floating`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `music.open`, `music.stop`, `bookmark.add`, `bookmark.import`, `bookmark.importFromBrowser`, `bookmark.export`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth` **noTargetSurface** (81): `taskManager.killProcess`, `toggleChecklistItemComplete`, `canvasOverview`, `canvasTidy`, `canvasAlignLeft`, `canvasAlignRight`, `canvasAlignTop`, `canvasAlignBottom`, `canvasEqualizeWidths`, `canvasEqualizeHeights`, `canvasDistributeHorizontally`, `canvasDistributeVertically`, `simulatorHome`, `simulatorRotateLeft`, `simulatorRotateRight`, `simulatorToggleAppearance`, `simulatorToggleSoftwareKeyboard`, `file.open`, `home.archiveChief`, `tabGroup.deleteSaved`, `tabGroup.reopenSaved`, `screenGroup.reopenSaved`, `screenGroup.deleteSaved`, `resumeCommandSet`, `resumeCommandEdit`, `resumeCommandClear`, `saveFilePreview`, `toggleFileEditorWordWrap`, `filePreviewOpenWith`, `filePreviewOpenExternally`, `filePreviewRevealInFinder`, `diffViewerNextLine`, `diffViewerPreviousLine`, `diffViewerHalfPageDown`, `diffViewerHalfPageUp`, `diffViewerNextHunk`, `diffViewerPreviousHunk`, `diffViewerGoToBottom`, `diffViewerGoToTop`, `diffViewerSearch`, `diffViewerNextFile`, `diffViewerPreviousFile`, `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart`, `browser.openOnMachine`, `openDiffViewer`, `palette.openDirectoryDiffViewer`, `openMarkdownFile`, `fileEditorGotoLine`, `fileEditorReplace`, `fileEditorAction`, `browser.certificateWarning.proceed`, `browser.certificateWarning.goBack`, `browserProfile.openLink`, `fileExplorerOpenSelection`, `fileExplorerOpenSelectionFinderAlias`, `fileExplorerOpenInCmux`, `fileExplorerReveal`, `fileExplorerCopyPath`, `fileExplorerCopyRelativePath`, `fileExplorerOpenWith`, `vaultOpenSession`, `vaultResumeInNewWorkspace`, `vaultCopyResumeCommand`, `vaultOpenPullRequest`, `checklistEditItem`, `checklistMarkInProgress`, `checklistCompleteItem`, `checklistRemoveItem`, `checklistOpenAsPane`, `checklistAttachImages`, `agentPane.git.commit`, `agentPane.git.push`, `agentPane.searchChats`, `cloudExec`, `cloudDomainList`, `cloudPublicationList`, `markdownSave`, `markdownLink`, `markdownBack`, `markdownForward` @@ -160,7 +160,7 @@ Palette 929, CLI verbs 523, right-click 499, MCP tools 449. **endsApp** (4): `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything` -**systemChange** (63): `keepMacAwake`, `workspace.importClassic`, `palette.toggleMatchTerminalBackground`, `palette.enableMinimalMode`, `palette.disableMinimalMode`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `agentPaneZoomIn`, `agentPaneZoomOut`, `agentPaneZoomReset`, `workspace.setRemoteLocalhost`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `help.showCrashLogs`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar` +**systemChange** (64): `keepMacAwake`, `workspace.importClassic`, `palette.toggleMatchTerminalBackground`, `palette.enableMinimalMode`, `palette.disableMinimalMode`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `agentPaneZoomIn`, `agentPaneZoomOut`, `agentPaneZoomReset`, `workspace.setRemoteLocalhost`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `help.showCrashLogs`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `appearance.chromeStyle.floating`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar` Every action without a CLI verb is also no MCP tool, for the CLI's reason. diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index f0f379bd844e..24ccbd08fa6f 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -9973,7 +9973,8 @@ "flat-sidebar-line", "bare", "panes", - "two-tone" + "two-tone", + "floating" ], "agent_refusal" : null, "agent_settable" : true, @@ -10033,6 +10034,13 @@ "text" : "Two-Tone" }, "value" : "two-tone" + }, + { + "title" : { + "key" : "settings.choice.chromeStyle.floating", + "text" : "Floating Panes" + }, + "value" : "floating" } ], "consumers" : [ @@ -10065,7 +10073,9 @@ "sidebar", "two-tone", "minimal", - "bare" + "bare", + "floating", + "gaps" ], "kind" : "choice", "page_hidden" : false, @@ -23713,7 +23723,7 @@ "validation" : "portable" } ], - "schema_hash" : "733bc132e1aeecdbd50471c07d0510954641fa7f2a10ec59eb6746f42615ad23", + "schema_hash" : "9dcda0838cf75d7a440dd5b189e174e55926e46bedbfadb1d07aaf4268bdf3db", "sections" : [ { "id" : "general", diff --git a/webviews/src/pages/settings/generated/strings.json b/webviews/src/pages/settings/generated/strings.json index aa82e713dc08..422658037610 100644 --- a/webviews/src/pages/settings/generated/strings.json +++ b/webviews/src/pages/settings/generated/strings.json @@ -245,6 +245,7 @@ "settings.choice.chromeStyle.default": "Default", "settings.choice.chromeStyle.flat": "Flat", "settings.choice.chromeStyle.flatSidebarLine": "Flat with Sidebar Line", + "settings.choice.chromeStyle.floating": "Floating Panes", "settings.choice.chromeStyle.panes": "Panes", "settings.choice.chromeStyle.twoTone": "Two-Tone", "settings.choice.click": "You Click or Type in the Pane", @@ -1262,6 +1263,7 @@ "settings.choice.chromeStyle.default": "افتراضي", "settings.choice.chromeStyle.flat": "مسطح", "settings.choice.chromeStyle.flatSidebarLine": "مسطح مع خط الشريط الجانبي", + "settings.choice.chromeStyle.floating": "أجزاء عائمة", "settings.choice.chromeStyle.panes": "أجزاء", "settings.choice.chromeStyle.twoTone": "لونان", "settings.choice.click": "تنقر أو تكتب في الجزء", @@ -2279,6 +2281,7 @@ "settings.choice.chromeStyle.default": "Zadano", "settings.choice.chromeStyle.flat": "Ravno", "settings.choice.chromeStyle.flatSidebarLine": "Ravno s linijom bočne trake", + "settings.choice.chromeStyle.floating": "Plutajuća okna", "settings.choice.chromeStyle.panes": "Okna", "settings.choice.chromeStyle.twoTone": "Dvobojno", "settings.choice.click": "Kliknete ili kucate u oknu", @@ -3296,6 +3299,7 @@ "settings.choice.chromeStyle.default": "Standard", "settings.choice.chromeStyle.flat": "Flad", "settings.choice.chromeStyle.flatSidebarLine": "Flad med sidepanelstreg", + "settings.choice.chromeStyle.floating": "Svævende ruder", "settings.choice.chromeStyle.panes": "Ruder", "settings.choice.chromeStyle.twoTone": "Totonet", "settings.choice.click": "Du klikker eller skriver i ruden", @@ -4313,6 +4317,7 @@ "settings.choice.chromeStyle.default": "Standard", "settings.choice.chromeStyle.flat": "Flach", "settings.choice.chromeStyle.flatSidebarLine": "Flach mit Seitenleistenlinie", + "settings.choice.chromeStyle.floating": "Schwebende Bereiche", "settings.choice.chromeStyle.panes": "Bereiche", "settings.choice.chromeStyle.twoTone": "Zweifarbig", "settings.choice.click": "Du in den Bereich klickst oder tippst", @@ -5330,6 +5335,7 @@ "settings.choice.chromeStyle.default": "Predeterminado", "settings.choice.chromeStyle.flat": "Plano", "settings.choice.chromeStyle.flatSidebarLine": "Plano con línea de barra lateral", + "settings.choice.chromeStyle.floating": "Paneles flotantes", "settings.choice.chromeStyle.panes": "Paneles", "settings.choice.chromeStyle.twoTone": "Bicolor", "settings.choice.click": "Haces clic o escribes en el panel", @@ -6347,6 +6353,7 @@ "settings.choice.chromeStyle.default": "Par défaut", "settings.choice.chromeStyle.flat": "Plat", "settings.choice.chromeStyle.flatSidebarLine": "Plat avec ligne de barre latérale", + "settings.choice.chromeStyle.floating": "Panneaux flottants", "settings.choice.chromeStyle.panes": "Panneaux", "settings.choice.chromeStyle.twoTone": "Bicolore", "settings.choice.click": "Vous cliquez ou tapez dans le panneau", @@ -7364,6 +7371,7 @@ "settings.choice.chromeStyle.default": "Predefinito", "settings.choice.chromeStyle.flat": "Piatto", "settings.choice.chromeStyle.flatSidebarLine": "Piatto con linea della barra laterale", + "settings.choice.chromeStyle.floating": "Riquadri flottanti", "settings.choice.chromeStyle.panes": "Riquadri", "settings.choice.chromeStyle.twoTone": "Bicolore", "settings.choice.click": "Fai clic o scrivi nel riquadro", @@ -8381,6 +8389,7 @@ "settings.choice.chromeStyle.default": "デフォルト", "settings.choice.chromeStyle.flat": "フラット", "settings.choice.chromeStyle.flatSidebarLine": "フラット(サイドバー線あり)", + "settings.choice.chromeStyle.floating": "フローティングペイン", "settings.choice.chromeStyle.panes": "ペイン", "settings.choice.chromeStyle.twoTone": "ツートーン", "settings.choice.click": "ペインをクリックまたは入力したとき", @@ -9398,6 +9407,7 @@ "settings.choice.chromeStyle.default": "លំនាំដើម", "settings.choice.chromeStyle.flat": "រាបស្មើ", "settings.choice.chromeStyle.flatSidebarLine": "រាបស្មើមានបន្ទាត់របារចំហៀង", + "settings.choice.chromeStyle.floating": "ផ្ទាំងអណ្ដែត", "settings.choice.chromeStyle.panes": "ផ្ទាំង", "settings.choice.chromeStyle.twoTone": "ពីរពណ៌", "settings.choice.click": "អ្នកចុច ឬវាយក្នុងផ្ទាំង", @@ -10415,6 +10425,7 @@ "settings.choice.chromeStyle.default": "기본값", "settings.choice.chromeStyle.flat": "플랫", "settings.choice.chromeStyle.flatSidebarLine": "사이드바 선 있는 플랫", + "settings.choice.chromeStyle.floating": "떠 있는 창 분할", "settings.choice.chromeStyle.panes": "창 분할", "settings.choice.chromeStyle.twoTone": "투톤", "settings.choice.click": "창을 클릭하거나 입력할 때", @@ -11432,6 +11443,7 @@ "settings.choice.chromeStyle.default": "Standard", "settings.choice.chromeStyle.flat": "Flat", "settings.choice.chromeStyle.flatSidebarLine": "Flat med sidepanellinje", + "settings.choice.chromeStyle.floating": "Flytende ruter", "settings.choice.chromeStyle.panes": "Ruter", "settings.choice.chromeStyle.twoTone": "Tofarget", "settings.choice.click": "Du klikker eller skriver i ruten", @@ -12449,6 +12461,7 @@ "settings.choice.chromeStyle.default": "Domyślny", "settings.choice.chromeStyle.flat": "Płaski", "settings.choice.chromeStyle.flatSidebarLine": "Płaski z linią paska bocznego", + "settings.choice.chromeStyle.floating": "Pływające panele", "settings.choice.chromeStyle.panes": "Panele", "settings.choice.chromeStyle.twoTone": "Dwukolorowy", "settings.choice.click": "Klikniesz lub piszesz w panelu", @@ -13466,6 +13479,7 @@ "settings.choice.chromeStyle.default": "Padrão", "settings.choice.chromeStyle.flat": "Plano", "settings.choice.chromeStyle.flatSidebarLine": "Plano com linha da barra lateral", + "settings.choice.chromeStyle.floating": "Painéis flutuantes", "settings.choice.chromeStyle.panes": "Painéis", "settings.choice.chromeStyle.twoTone": "Duas cores", "settings.choice.click": "Você clica ou digita no painel", @@ -14483,6 +14497,7 @@ "settings.choice.chromeStyle.default": "По умолчанию", "settings.choice.chromeStyle.flat": "Плоский", "settings.choice.chromeStyle.flatSidebarLine": "Плоский с линией боковой панели", + "settings.choice.chromeStyle.floating": "Плавающие панели", "settings.choice.chromeStyle.panes": "Панели", "settings.choice.chromeStyle.twoTone": "Двухцветный", "settings.choice.click": "Вы щёлкаете или печатаете в панели", @@ -15500,6 +15515,7 @@ "settings.choice.chromeStyle.default": "ค่าเริ่มต้น", "settings.choice.chromeStyle.flat": "แบน", "settings.choice.chromeStyle.flatSidebarLine": "แบนมีเส้นแถบด้านข้าง", + "settings.choice.chromeStyle.floating": "บานหน้าต่างลอย", "settings.choice.chromeStyle.panes": "บานหน้าต่าง", "settings.choice.chromeStyle.twoTone": "สองโทน", "settings.choice.click": "คุณคลิกหรือพิมพ์ในแผง", @@ -16517,6 +16533,7 @@ "settings.choice.chromeStyle.default": "Varsayılan", "settings.choice.chromeStyle.flat": "Düz", "settings.choice.chromeStyle.flatSidebarLine": "Kenar çubuğu çizgili düz", + "settings.choice.chromeStyle.floating": "Yüzen Bölmeler", "settings.choice.chromeStyle.panes": "Bölmeler", "settings.choice.chromeStyle.twoTone": "İki tonlu", "settings.choice.click": "Bölmeye Tıkladığınızda veya Yazdığınızda", @@ -17534,6 +17551,7 @@ "settings.choice.chromeStyle.default": "Типовий", "settings.choice.chromeStyle.flat": "Плаский", "settings.choice.chromeStyle.flatSidebarLine": "Плаский з лінією бічної панелі", + "settings.choice.chromeStyle.floating": "Плаваючі панелі", "settings.choice.chromeStyle.panes": "Панелі", "settings.choice.chromeStyle.twoTone": "Двоколірний", "settings.choice.click": "Ви клацаєте чи друкуєте в панелі", @@ -18551,6 +18569,7 @@ "settings.choice.chromeStyle.default": "Mặc định", "settings.choice.chromeStyle.flat": "Phẳng", "settings.choice.chromeStyle.flatSidebarLine": "Phẳng có đường thanh bên", + "settings.choice.chromeStyle.floating": "Ngăn nổi", "settings.choice.chromeStyle.panes": "Ngăn", "settings.choice.chromeStyle.twoTone": "Hai tông màu", "settings.choice.click": "Bạn nhấp hoặc gõ trong khung", @@ -19568,6 +19587,7 @@ "settings.choice.chromeStyle.default": "默认", "settings.choice.chromeStyle.flat": "扁平", "settings.choice.chromeStyle.flatSidebarLine": "扁平(带边栏线)", + "settings.choice.chromeStyle.floating": "浮动窗格", "settings.choice.chromeStyle.panes": "窗格", "settings.choice.chromeStyle.twoTone": "双色", "settings.choice.click": "在窗格中点按或输入时", @@ -20585,6 +20605,7 @@ "settings.choice.chromeStyle.default": "預設", "settings.choice.chromeStyle.flat": "扁平", "settings.choice.chromeStyle.flatSidebarLine": "扁平(帶側邊欄線)", + "settings.choice.chromeStyle.floating": "浮動窗格", "settings.choice.chromeStyle.panes": "窗格", "settings.choice.chromeStyle.twoTone": "雙色", "settings.choice.click": "在窗格中點按或輸入時", From 61d6c2d451cc529679d38e339d1fda7783f7f340 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:34:35 -0700 Subject: [PATCH 086/183] cmux-tui: rustfmt main's OpenCode semantic_kind arms (main sync 2026-10-14, cx-q1lnl) Co-Authored-By: Claude Opus 5.5 (1M context) --- cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs index 5d5efd0fefd2..70e32b89de13 100644 --- a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs +++ b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs @@ -422,14 +422,12 @@ fn semantic_kind( ("antigravity" | "hermes-agent", "sessionend" | "onsessionend") => "agent.turn.completed", ("opencode", "sessioncreated") => "agent.session.started", ("opencode", "sessionidle") => "agent.turn.completed", - ( - "opencode", - "sessionexecutionsucceeded" | "sessionexecutioninterrupted", - ) => "agent.turn.completed", - ( - "opencode", - "sessionexecutionfailed" | "sessionnextstepfailed" | "sessionerror", - ) => "agent.error.reported", + ("opencode", "sessionexecutionsucceeded" | "sessionexecutioninterrupted") => { + "agent.turn.completed" + } + ("opencode", "sessionexecutionfailed" | "sessionnextstepfailed" | "sessionerror") => { + "agent.error.reported" + } ("opencode", "sessiondeleted") => "agent.session.ended", (_, event) if is_child_spawn(event) => "agent.child.spawned", (_, event) if is_child_completion(event) => "agent.child.completed", From 506597557ee55af1d658ba0f07eb3dd2700b5bfc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:35:34 -0700 Subject: [PATCH 087/183] chief.profile.adopt: the Chief-credential check and the mint run under the registry lock Review finding: a mint that passed the Chief check before an adopt could overwrite the brain's credential after it. conversation-agent-token now checks and mints inside one registry-locked step (registry, then the conversation store, the adopt's order). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cmux-tui-core/src/chief_registry.rs | 5 ++- .../src/server/chief_registry_resource.rs | 41 ++++++++++++++----- .../cmux-tui-core/src/server/conversations.rs | 23 ++++------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs index 547d13d38240..a0d7334bd482 100644 --- a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs @@ -548,7 +548,10 @@ pub(crate) fn adopt( } // Last, inside this write: the participant must have no bound connection, // and every credential issued for it is revoked (`claim`), so the old - // agent session never speaks as the Chief. + // agent session never speaks as the Chief. The revoke commits in the + // conversation store; should the insert below then fail, the agent stays + // without a credential and no Chief is made (fails safe: the person mints + // a new credential). if !claim()? { return Ok(Err(Refusal::ParticipantBound)); } diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs index 8dfaa02a5524..d4bc33968a26 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs @@ -502,17 +502,38 @@ fn with_store( run(store.as_mut().expect("chief registry opened above")) } -/// Whether `participant` is a live Chief record other than the default Chief -/// (its credential is its brain's, or the person's on a daemon with no brain -/// supervisor). An unreadable registry answers true: fail closed. -pub(super) fn is_owned_chief(mux: &Mux, participant: &str) -> bool { - if participant == chief_registry::DEFAULT_CHIEF || !participant.starts_with("agent_") { - return false; - } - with_store(mux, Op::ChiefProfileGet, |store| { - store.get(participant).map_err(|e| internal(Op::ChiefProfileGet, &e)) +/// `conversation-agent-token`'s mint, under the registry lock (lock order: +/// registry, then the conversation store, as `chief.profile.adopt`): for a +/// live Chief record other than the default Chief, refused while a brain +/// supervisor runs (its brain holds the credential) and refused unless +/// `terminal` (the person's own terminal: no page, no paired or remote +/// device); any other participant mints as before. An unreadable registry +/// refuses the mint (fail closed). +pub(super) fn mint_credential( + mux: &Mux, + participant: &str, + terminal: bool, +) -> anyhow::Result { + let operation = Op::ChiefProfileGet; + let minted = with_store(mux, operation, |store| { + let chief = participant != chief_registry::DEFAULT_CHIEF + && participant.starts_with("agent_") + && store.get(participant).map_err(|e| internal(operation, &e))?.is_some(); + if chief && crate::chief_brains::taken().is_some() { + return Ok(Err(format!("{participant} is a Chief: its brain holds its credential"))); + } + if chief && !terminal { + return Ok(Err( + "only the person, from a terminal on this machine, mints a Chief's credential" + .to_owned(), + )); + } + mux.with_conversations(|conversations| conversations.mint_agent_token(participant)) + .map(Ok) + .map_err(|e| internal(operation, &e)) }) - .map_or(true, |record| record.is_some()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + minted.map_err(|refusal| anyhow::anyhow!(refusal)) } /// The participant `chief.profile.adopt` names: its id, and its display name diff --git a/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs b/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs index 4e48dcac4e26..db9e9977add4 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs @@ -605,21 +605,14 @@ pub(super) fn agent_token( // A Chief record's credential (other than the default Chief's): with a // brain supervisor only its brain gets one (minted in process); without // one, only the person's own terminal mints it, never a page, a paired or - // remote device or an agent (chief.profile.adopt, hq-6d 2026-10-14). - if super::chief_registry_resource::is_owned_chief(mux, ¶ms.participant) { - anyhow::ensure!( - crate::chief_brains::taken().is_none(), - "{} is a Chief: its brain holds its credential", - params.participant - ); - anyhow::ensure!( - !mux.is_remote_client(client) - && super::origin_gate::connection_page(mux, client).is_none() - && !super::origin_gate::is_page(mux, client), - "only the person, from a terminal on this machine, mints a Chief's credential" - ); - } - let token = mux.with_conversations(|store| store.mint_agent_token(¶ms.participant))?; + // remote device or an agent (chief.profile.adopt, hq-6d 2026-10-14). The + // check and the mint run under the registry lock, so an adopt cannot land + // between them. + let terminal = !mux.is_remote_client(client) + && super::origin_gate::connection_page(mux, client).is_none() + && !super::origin_gate::is_page(mux, client); + let token = + super::chief_registry_resource::mint_credential(mux, ¶ms.participant, terminal)?; // A replaced token also ends the connections bound with the old one. mux.unbind_conversation_participant(¶ms.participant); Ok(json!({"participant": params.participant, "token": token})) From 653c4d7dc3c5b8cf034aa6dbe00ffad5878b99be Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:35:46 -0700 Subject: [PATCH 088/183] Restore the diff page catalog: rename detection put main's legacy Resources/Localizable.xcstrings keys there (main sync 2026-10-14, cx-q1lnl) Co-Authored-By: Claude Opus 5.5 (1M context) --- webviews/src/pages/diff/Localizable.xcstrings | 177 ------------------ 1 file changed, 177 deletions(-) diff --git a/webviews/src/pages/diff/Localizable.xcstrings b/webviews/src/pages/diff/Localizable.xcstrings index 405c62fcf99f..8850c583cda1 100644 --- a/webviews/src/pages/diff/Localizable.xcstrings +++ b/webviews/src/pages/diff/Localizable.xcstrings @@ -15185,183 +15185,6 @@ } } } - }, - "settings.account.max.activeSubtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Your Max subscription is active. It includes everything in Pro." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "اشتراكك في Max نشط. يتضمن كل ما في Pro." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Dein Max-Abonnement ist aktiv. Es enthält alles aus Pro." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Tu suscripción Max está activa. Incluye todo lo de Pro." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Votre abonnement Max est actif. Il inclut tout ce que contient Pro." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Maxサブスクリプションは有効です。Proのすべての機能が含まれます。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Max 구독이 활성화되어 있습니다. Pro의 모든 기능이 포함됩니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "你的 Max 订阅已生效,包含 Pro 的全部功能。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "你的 Max 訂閱已啟用,包含 Pro 的所有功能。" - } - } - } - }, - "settings.account.max.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "cmux Max" - } - } - } - }, - "settings.account.max.upgrade": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Upgrade to Max…" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "الترقية إلى Max…" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Auf Max upgraden…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Actualizar a Max…" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Passer à Max…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Maxにアップグレード…" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Max로 업그레이드…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "升级到 Max…" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "升級至 Max…" - } - } - } } }, "version": "1.0" From 0e67e0c1fadb6c3c95039fec8136c7d27881e71e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:35:58 -0700 Subject: [PATCH 089/183] Regenerate bindings after merge Co-Authored-By: Claude Opus 5.5 (1M context) --- cmux-tui/bindings/cpp/.cmux-resource-api.json | 2 +- cmux-tui/bindings/go/.cmux-resource-api.json | 2 +- cmux-tui/bindings/java/.cmux-resource-api.json | 2 +- cmux-tui/bindings/python/.cmux-resource-api.json | 2 +- cmux-tui/bindings/rust/.cmux-resource-api.json | 2 +- cmux-tui/bindings/typescript/.cmux-resource-api.json | 2 +- cmux-tui/bindings/zig/.cmux-resource-api.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmux-tui/bindings/cpp/.cmux-resource-api.json b/cmux-tui/bindings/cpp/.cmux-resource-api.json index 2e953eadda52..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/cpp/.cmux-resource-api.json +++ b/cmux-tui/bindings/cpp/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/go/.cmux-resource-api.json b/cmux-tui/bindings/go/.cmux-resource-api.json index 2e953eadda52..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/go/.cmux-resource-api.json +++ b/cmux-tui/bindings/go/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/java/.cmux-resource-api.json b/cmux-tui/bindings/java/.cmux-resource-api.json index 2e953eadda52..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/java/.cmux-resource-api.json +++ b/cmux-tui/bindings/java/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/python/.cmux-resource-api.json b/cmux-tui/bindings/python/.cmux-resource-api.json index 2e953eadda52..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/python/.cmux-resource-api.json +++ b/cmux-tui/bindings/python/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/rust/.cmux-resource-api.json b/cmux-tui/bindings/rust/.cmux-resource-api.json index 2e953eadda52..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/rust/.cmux-resource-api.json +++ b/cmux-tui/bindings/rust/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/typescript/.cmux-resource-api.json b/cmux-tui/bindings/typescript/.cmux-resource-api.json index 2e953eadda52..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/typescript/.cmux-resource-api.json +++ b/cmux-tui/bindings/typescript/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/zig/.cmux-resource-api.json b/cmux-tui/bindings/zig/.cmux-resource-api.json index 2e953eadda52..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/zig/.cmux-resource-api.json +++ b/cmux-tui/bindings/zig/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "4d29b11bda313d72c70601975ea9f3f1eb92ceb98045bccb4cf25cfa277d1198", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" From 02f1dca0c9430ccda79f03949afd7de1094c97eb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:36:21 -0700 Subject: [PATCH 090/183] labs.classicHome: settings schema and MDM exports regenerated on the tip (fleet export-settings, aws-m4pro-9) (cx-ebm.59.5) --- docs/mdm/com.manaflow.cmux.json | 6 ++++ docs/mdm/com.manaflow.cmux.plist | 12 +++++++ docs/mdm/managed-preferences.md | 1 + schemas/settings/settings-schema.json | 51 ++++++++++++++++++++++++++- 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index 4c1b02656ad4..512717cdc5bb 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -1461,6 +1461,12 @@ "title" : "Keep Location in Photos and Videos", "type" : "boolean" }, + "labs.classicHome" : { + "default" : false, + "description" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next.", + "title" : "Classic Home", + "type" : "boolean" + }, "labs.previewFeatures" : { "default" : false, "description" : "Unfinished surfaces, such as the agent session's coverage label and Pull requests view.", diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index c1c6e9f50079..f2b2a96caeec 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -4472,6 +4472,18 @@ pfm_type boolean
+ + pfm_default + + pfm_description + Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next. + pfm_name + labs.classicHome + pfm_title + Classic Home + pfm_type + boolean + pfm_default diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index ed4b6e555afd..1517affe20b0 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -316,6 +316,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `notifications.attention.moveToTop` | boolean | `true` | | Move the Workspace to the Top. A notification moves its workspace to the top of the unpinned rows, unless you are looking at its pane. | | `notifications.mutedWorkspaces` | array | `[]` | | Muted Workspaces. Workspace ids whose notifications are silent. Mute a workspace from its sidebar row menu. | | `labs.previewFeatures` | boolean | `false` | | Show Preview Features. Unfinished surfaces, such as the agent session's coverage label and Pull requests view. | +| `labs.classicHome` | boolean | `false` | | Classic Home. Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next. | | `feed.github.enabled` | boolean | `false` | | Connect GitHub. Uses your gh login to read notifications and review requests on this Mac. Sign in with gh auth login first. | | `feed.github.pollIntervalSeconds` | real | `120` | 60 to 900 | Refresh Interval. Seconds between GitHub refreshes. Refresh in the Inbox runs immediately. | | `agentPane.links.outsideRoots` | string | `"open"` | `confirm`, `text`, `open` | Files Outside the Project. What a file link in a reply does when the file is outside the chat's folders. Keys and .env files never open. | diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index f95ee79ef08a..372329f3d426 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -907,6 +907,7 @@ "key" : "settings.group.labs", "rows" : [ "labs.previewFeatures", + "labs.classicHome", "appearance.experimentalControls" ], "title" : { @@ -22159,6 +22160,54 @@ }, "validation" : "portable" }, + { + "accepts" : [ + true, + false + ], + "agent_refusal" : null, + "agent_settable" : true, + "consumers" : [ + "cmux-next" + ], + "default" : false, + "default_label" : null, + "group" : { + "key" : "settings.group.labs", + "text" : "Labs" + }, + "help" : { + "key" : "settings.labs.classicHome.help", + "text" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next." + }, + "kept_on_reset_all" : false, + "key" : "labs.classicHome", + "keywords" : [ + "home", + "chief", + "classic", + "dock", + "page" + ], + "kind" : "toggle", + "page_hidden" : false, + "path" : [ + "labs", + "classicHome" + ], + "refuses" : [ + "true", + 1, + null + ], + "section" : "advanced", + "summary" : null, + "title" : { + "key" : "settings.labs.classicHome", + "text" : "Classic Home" + }, + "validation" : "portable" + }, { "accepts" : [ true, @@ -23562,7 +23611,7 @@ "validation" : "portable" } ], - "schema_hash" : "f590edffcb3e42e89831d8e96c6ba3eece1917143936341f49819ccb666cb37c", + "schema_hash" : "587efecfb08f75a4172a225a35e7a8b95af869702c61cefb7d5541329024bd80", "sections" : [ { "id" : "general", From 32bd233dbd16c295e542f48f79f3086e838f6ae5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:41:07 -0700 Subject: [PATCH 091/183] cmux-tui: behavior test: server compact-registry refuses a running owner, shrinks a legacy registry offline (red, cx-ossub) --- .../tests/legacy_registry_maintenance.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs b/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs index 593eb98cd6e3..ae67fb4a0f5d 100644 --- a/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs +++ b/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs @@ -9,6 +9,11 @@ //! shape, the owner is started on it with a short maintenance interval and a //! small live-data bound (debug-build overrides), and the registry file is //! read after maintenance ticks. +//! +//! `server compact-registry` (cx-ossub) gives a large legacy registry's free +//! pages back offline: refused while the owner runs, and with the owner +//! stopped it rewrites the file smaller with the same rows and incremental +//! auto-vacuum, which the next owner opens. #![cfg(all(unix, debug_assertions))] @@ -204,3 +209,94 @@ fn a_small_legacy_registry_is_converted() { owner.stop(); assert_eq!(state, (2, true), "a small legacy registry converts to incremental auto-vacuum"); } + +/// Row count of every table, in name order: the content a compaction must keep. +fn table_rows(registry: &Path) -> Vec<(String, i64)> { + let db = + Connection::open_with_flags(registry, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + let tables: Vec = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + tables + .into_iter() + .map(|table| { + let count = db + .query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |row| row.get(0)) + .unwrap(); + (table, count) + }) + .collect() +} + +fn compact(dir: &Path, session: &str) -> (bool, String) { + let output = command(dir) + .args(["--json", "server", "compact-registry", "--session", session]) + .output() + .unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + (output.status.success(), text) +} + +#[test] +fn compact_registry_is_refused_while_the_owner_runs_and_shrinks_it_offline() { + let dir = state_dir("compact"); + let session = "lrm-compact"; + let registry = first_start(&dir.0, session); + make_legacy(®istry, 2 * TEST_BOUND_BYTES); + { + // Free most of the filler: a legacy file keeps those pages. + let db = Connection::open(®istry).unwrap(); + db.execute("DELETE FROM legacy_filler WHERE rowid > 4", []).unwrap(); + db.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").unwrap(); + } + let before_len = fs::metadata(®istry).unwrap().len(); + let before_rows = table_rows(®istry); + + let owner = Owner::start(&dir.0, session); + let (ok, text) = compact(&dir.0, session); + assert!(!ok, "compaction must be refused while the owner runs: {text}"); + assert!(text.contains("server.compact_owner_running"), "refusal code: {text}"); + assert!(owner.answers(), "the owner keeps serving after a refused compaction"); + owner.stop(); + let stopped_rows = table_rows(®istry); + + let (ok, text) = compact(&dir.0, session); + assert!(ok, "compaction with the owner stopped: {text}"); + assert!(text.contains("\"compacted\""), "status: {text}"); + let after_len = fs::metadata(®istry).unwrap().len(); + assert!( + after_len + TEST_BOUND_BYTES as u64 <= before_len, + "the file gives its free pages back: {before_len} -> {after_len} bytes" + ); + assert_eq!(table_rows(®istry), stopped_rows, "every table keeps its rows"); + assert_eq!( + before_rows.iter().find(|(table, _)| table == "legacy_filler"), + Some(&("legacy_filler".to_string(), 4)), + "the fixture kept 4 filler rows" + ); + { + let db = Connection::open_with_flags(®istry, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .unwrap(); + assert_eq!(auto_vacuum(&db), 2, "the compacted registry is incremental auto-vacuum"); + } + + // The next owner opens the compacted registry and marks it migrated. + let owner = Owner::start(&dir.0, session); + let deadline = Instant::now() + Duration::from_secs(20); + let mut state = conversion(®istry); + while state != (2, true) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + state = conversion(®istry); + } + assert!(owner.answers(), "the owner serves the compacted registry"); + owner.stop(); + assert_eq!(state, (2, true), "the owner marks the compacted registry migrated"); +} From e7e462c474a4c2e615682c2ad1b778cf4b3af836 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:43:51 -0700 Subject: [PATCH 092/183] test(cmux-next): click proof comment states what the lost click is and what is not measured (cx-ebm.59.5) --- scripts/cmux-next/chief-create-click-proof.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/cmux-next/chief-create-click-proof.py b/scripts/cmux-next/chief-create-click-proof.py index aa73c86bb32d..f41af230de0e 100644 --- a/scripts/cmux-next/chief-create-click-proof.py +++ b/scripts/cmux-next/chief-create-click-proof.py @@ -195,11 +195,11 @@ class P(ctypes.Structure): cg.CGEventCreateMouseEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, P, ctypes.c_uint32] cg.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p] cg.CFRelease.argtypes = [ctypes.c_void_p] - # The app runs with CMUX_NEXT_NO_ACTIVATE, so every click lands on an inactive app: mouse-down activates it, - # and NoActivateKeyboardGuard keeps that activation only when it sees a mouse button still pressed - # (NSEvent.pressedMouseButtons) at didBecomeActive. With an 80 ms press the button was often up by then: the - # guard gave the activation back ("keyboard given back: app_activated, no_user_input"), WebKit got no click, - # and the hovered primary button (grey hover background) looked disabled. A human press is longer; hold it. + # The app runs with CMUX_NEXT_NO_ACTIVATE, so a click lands on an inactive app: NoActivateKeyboardGuard logs + # "keyboard given back: app_activated, no_user_input" and WebKit sometimes gets no click at all (cx-ebm.59.5: + # 4 of 5 runs at 76bca7f6a85b, 0 of 3 at a5341bc5d774e; the page's submit handler never ran, no page call reached the host, and the hovered + # primary button's grey hover style looked like a busy sheet). The press is held like a human press; whether + # that lowers the miss rate is not measured yet. for kind, hold in ((5, 0.08), (1, 0.4), (2, 0.08)): # mouseMoved, leftMouseDown (held), leftMouseUp e = cg.CGEventCreateMouseEvent(None, kind, P(x, y), 0) cg.CGEventPost(0, e) From a1833f9087fc0876f4bc8b8df2be9c3ead862740 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:46:59 -0700 Subject: [PATCH 093/183] Drop main-sync leftovers: unused test imports in journal_checkpoint, legacy-only CmuxPlanTier (cx-q1lnl) The three imports served only main's dropped unit tests (clippy -D warnings fails on them). CmuxPlanTier (#20587) has no user outside its own unit test; the iOS billing screens it serves are deleted on feat-cmux-next. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sources/CMUXMobileCore/CmuxPlanTier.swift | 87 ---------------- .../CmuxPlanTierTests.swift | 99 ------------------- .../cmux-tui-core/src/journal_checkpoint.rs | 3 - 3 files changed, 189 deletions(-) delete mode 100644 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmuxPlanTier.swift delete mode 100644 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmuxPlanTierTests.swift diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmuxPlanTier.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmuxPlanTier.swift deleted file mode 100644 index cef47ceaccfe..000000000000 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmuxPlanTier.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation - -/// A cmux subscription tier, ordered so each tier includes every feature of -/// the tiers below it. -/// -/// Gate features on the ordering (``includesPro``, ``includesMax``) rather -/// than on a plan string, so Max and any later higher tier keep everything -/// Pro has. -public enum CmuxPlanTier: Int, Sendable, Comparable, CaseIterable { - /// No paid plan. - case free - /// The entry Cloud plan: a small machine allowance, no Pro features. - case go - /// Pro, and the plans that grant the Pro feature set (Team, Founder's Edition). - case pro - /// Max: everything in Pro plus the larger machine sizes and pool. - case max - - /// Maps a plan ID from the Cloud service, or `nil` for an unknown ID. - /// - /// - Parameter planID: A plan ID such as `free`, `go`, `pro`, `max`, - /// `team`, or `founders`. Case and surrounding whitespace are ignored. - public init?(planID: String?) { - switch planID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "free": self = .free - case "go": self = .go - case "pro", "team", "founders": self = .pro - case "max": self = .max - default: return nil - } - } - - public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } - - /// Whether the tier includes Cloud machines. - public var includesCloud: Bool { self >= .go } - - /// Whether the tier includes every Pro feature. - public var includesPro: Bool { self >= .pro } - - /// Whether the tier includes every Max feature. - public var includesMax: Bool { self >= .max } - - /// The tier an upgrade from this one should offer, or `nil` at the top. - public var nextUpgrade: CmuxPlanTier? { - switch self { - case .free, .go: .pro - case .pro: .max - case .max: nil - } - } - - /// The account's effective tier from a `/api/billing/plan` response: the - /// higher of the personal plan and the active team's plan. - /// - /// - Parameters: - /// - subscriptionPlanID: The exact personal plan (`free`, `go`, `pro`, - /// or `max`), or `nil` from servers that predate it. - /// - planID: The legacy personal plan family, `free` or `pro`. A Max - /// or Go subscriber is reported as `pro` here. - /// - teamPlanID: The active team's plan, if any. - /// - isPro: The legacy paid flag, used only when no plan ID is known. - public static func resolved( - subscriptionPlanID: String?, - planID: String?, - teamPlanID: String?, - isPro: Bool - ) -> CmuxPlanTier { - let personal = CmuxPlanTier(planID: subscriptionPlanID) - ?? CmuxPlanTier(planID: planID) - ?? (isPro ? .pro : .free) - let team = CmuxPlanTier(planID: teamPlanID) ?? .free - return Swift.max(personal, team) - } - - /// The tier an upgrade entrypoint should offer, or `nil` to hide it. - /// - /// - Parameters: - /// - isSignedIn: Whether an account is signed in. - /// - knownTier: The signed-in account's tier, or `nil` while it is - /// loading or after its fetch failed. An unknown tier is never read - /// as Free, so it offers nothing. - public static func upgradeOffer(isSignedIn: Bool, knownTier: CmuxPlanTier?) -> CmuxPlanTier? { - guard isSignedIn else { return .pro } - return knownTier?.nextUpgrade - } -} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmuxPlanTierTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmuxPlanTierTests.swift deleted file mode 100644 index 8cb6e9cb85c0..000000000000 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmuxPlanTierTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Testing -@testable import CMUXMobileCore - -@Suite struct CmuxPlanTierTests { - @Test func everyHigherTierIncludesTheFeaturesBelowIt() { - #expect(CmuxPlanTier.free.includesCloud == false) - #expect(CmuxPlanTier.free.includesPro == false) - #expect(CmuxPlanTier.free.includesMax == false) - - #expect(CmuxPlanTier.go.includesCloud) - #expect(CmuxPlanTier.go.includesPro == false) - #expect(CmuxPlanTier.go.includesMax == false) - - #expect(CmuxPlanTier.pro.includesCloud) - #expect(CmuxPlanTier.pro.includesPro) - #expect(CmuxPlanTier.pro.includesMax == false) - - #expect(CmuxPlanTier.max.includesCloud) - #expect(CmuxPlanTier.max.includesPro) - #expect(CmuxPlanTier.max.includesMax) - } - - @Test func planIDsMapToTiers() { - #expect(CmuxPlanTier(planID: "free") == .free) - #expect(CmuxPlanTier(planID: "go") == .go) - #expect(CmuxPlanTier(planID: "pro") == .pro) - #expect(CmuxPlanTier(planID: " MAX ") == .max) - // Team and Founder's Edition grant the Pro feature set. - #expect(CmuxPlanTier(planID: "team") == .pro) - #expect(CmuxPlanTier(planID: "founders") == .pro) - #expect(CmuxPlanTier(planID: "enterprise") == nil) - #expect(CmuxPlanTier(planID: "") == nil) - #expect(CmuxPlanTier(planID: nil) == nil) - } - - @Test func nextUpgradeOffersTheTierAbove() { - #expect(CmuxPlanTier.free.nextUpgrade == .pro) - #expect(CmuxPlanTier.go.nextUpgrade == .pro) - #expect(CmuxPlanTier.pro.nextUpgrade == .max) - #expect(CmuxPlanTier.max.nextUpgrade == nil) - } - - @Test func maxSubscriberResolvesToMaxFromTheExactPlan() { - // `/api/billing/plan` reports a Max subscriber as planId "pro" for - // older clients and carries "max" in subscriptionPlanId. - let tier = CmuxPlanTier.resolved( - subscriptionPlanID: "max", - planID: "pro", - teamPlanID: "free", - isPro: true - ) - #expect(tier == .max) - #expect(tier.nextUpgrade == nil) - } - - @Test func goSubscriberIsNotPromotedToProByTheLegacyFlag() { - // The service sets isPro for every paid personal plan, Go included. - let tier = CmuxPlanTier.resolved( - subscriptionPlanID: "go", - planID: "pro", - teamPlanID: "free", - isPro: true - ) - #expect(tier == .go) - #expect(tier.nextUpgrade == .pro) - } - - @Test func paidTeamRaisesAFreePersonalPlan() { - let tier = CmuxPlanTier.resolved( - subscriptionPlanID: "free", - planID: "free", - teamPlanID: "team", - isPro: false - ) - #expect(tier == .pro) - } - - @Test func personalMaxIsKeptOnAFreeOrTeamWorkspace() { - #expect(CmuxPlanTier.resolved(subscriptionPlanID: "max", planID: "pro", teamPlanID: "team", isPro: true) == .max) - } - - @Test func olderServersFallBackToTheLegacyPlanFamily() { - #expect(CmuxPlanTier.resolved(subscriptionPlanID: nil, planID: "pro", teamPlanID: nil, isPro: true) == .pro) - #expect(CmuxPlanTier.resolved(subscriptionPlanID: nil, planID: nil, teamPlanID: nil, isPro: true) == .pro) - #expect(CmuxPlanTier.resolved(subscriptionPlanID: nil, planID: nil, teamPlanID: nil, isPro: false) == .free) - } - - @Test func upgradeOfferFollowsTheKnownTier() { - // Signed out: the entry plan is the useful offer. - #expect(CmuxPlanTier.upgradeOffer(isSignedIn: false, knownTier: nil) == .pro) - // Signed in but the plan is still loading or its fetch failed: never - // read that as Free, so offer nothing rather than the wrong plan. - #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: nil) == nil) - #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: .free) == .pro) - #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: .go) == .pro) - #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: .pro) == .max) - #expect(CmuxPlanTier.upgradeOffer(isSignedIn: true, knownTier: .max) == nil) - } -} diff --git a/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs b/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs index a803ee684850..3cba57cdeaea 100644 --- a/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs +++ b/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs @@ -847,9 +847,6 @@ mod tests { JournalAuthority, JournalClass, JournalEventSchema, JournalProducer, JournalProducerManifest, JournalSensitivity, JournalSubject, }; - use base64::Engine; - use flate2::read::GzDecoder; - use std::io::Read; use std::sync::Arc; thread_local! { From 04acaddf5d3a1e577ce6ede836c61bbafc549cd4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:49:07 -0700 Subject: [PATCH 094/183] labs.classicHome is read once at launch, like CMUX_NEXT_CLASSIC_HOME; help says it takes effect after cmux restarts (cx-ebm.59.5 review) Review finding: a live flip of the setting left Home half docked (the dock ensure and the Chief tab run only on connects) and refused Home's actions; a palette preview flipped it while hovering. HomeDock.classic now latches the setting and the env at the first read after launch (the settings file is applied before any window or Home starts). Help text in all 21 locales: takes effect after cmux restarts. de, pl, zh-Hant use the app's word for Home. --- .../Sources/CmuxNextHomeEngine/HomeDock.swift | 8 +++- .../CmuxNextSettings/Localizable.xcstrings | 48 +++++++++---------- .../SettingsSchema+Labs.generated.swift | 2 +- schemas/settings/source/labs.json | 2 +- .../src/pages/settings/generated/strings.json | 48 +++++++++---------- 5 files changed, 56 insertions(+), 52 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeDock.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeDock.swift index 53664b388c92..19885bde2c23 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeDock.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeDock.swift @@ -24,8 +24,12 @@ public final class HomeDock { public static let page = "home-channels" /// Classic Home for one release (`labs.classicHome`): the top page, with the Chief tab in the /// hidden home workspace. `CMUX_NEXT_CLASSIC_HOME=1` at launch selects it too (GUI tests of the - /// native Home, until the setting lands). - public static var classic: Bool { DesignSettings.shared.classicHome || classicAtLaunch } + /// native Home). Both are read once, at the first read after launch (the settings file is applied + /// before any window or Home starts): a change takes effect after cmux restarts, so Home is + /// never half docked (the dock ensure runs only on connects; a live flip would leave the old + /// form's tabs and refuse Home's actions). + public static var classic: Bool { latched } + private static let latched: Bool = DesignSettings.shared.classicHome || classicAtLaunch /// `CMUX_NEXT_CLASSIC_HOME=1` in the app's launch environment, read once. static let classicAtLaunch = ProcessInfo.processInfo.environment["CMUX_NEXT_CLASSIC_HOME"] == "1" public var isClassic: Bool { Self.classic } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings index 14b1c9ff002a..84e0c589114b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings @@ -36319,7 +36319,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Klassische Startseite" + "value": "Klassisches Home" } }, "es": { @@ -36367,7 +36367,7 @@ "pl": { "stringUnit": { "state": "translated", - "value": "Klasyczny ekran główny" + "value": "Klasyczny Start" } }, "pt-BR": { @@ -36415,7 +36415,7 @@ "zh-Hant": { "stringUnit": { "state": "translated", - "value": "經典首頁" + "value": "經典主頁" } } } @@ -36426,127 +36426,127 @@ "en": { "stringUnit": { "state": "translated", - "value": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next." + "value": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts." } }, "ar": { "stringUnit": { "state": "translated", - "value": "عرض الصفحة الرئيسية كصفحة كاملة بدلًا من مساحة عمل يكون فيها Chief مثبتًا على اليسار. يسري التغيير عند فتح الصفحة الرئيسية في المرة التالية." + "value": "عرض الصفحة الرئيسية كصفحة كاملة بدلًا من مساحة عمل يكون فيها Chief مثبتًا على اليسار. يسري التغيير بعد إعادة تشغيل cmux." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Prikaži početnu kao cijelu stranicu umjesto radnog prostora s Chiefom usidrenim lijevo. Primjenjuje se kada se početna sljedeći put otvori." + "value": "Prikaži početnu kao cijelu stranicu umjesto radnog prostora s Chiefom usidrenim lijevo. Primjenjuje se nakon ponovnog pokretanja cmuxa." } }, "da": { "stringUnit": { "state": "translated", - "value": "Vis Hjem som en hel side i stedet for et arbejdsområde med Chief fastgjort til venstre. Træder i kraft, næste gang Hjem åbnes." + "value": "Vis Hjem som en hel side i stedet for et arbejdsområde med Chief fastgjort til venstre. Træder i kraft, når cmux genstartes." } }, "de": { "stringUnit": { "state": "translated", - "value": "Startseite als ganze Seite statt als Arbeitsbereich mit links angedocktem Chief anzeigen. Wirkt beim nächsten Öffnen der Startseite." + "value": "Home als ganze Seite statt als Arbeitsbereich mit links angedocktem Chief anzeigen. Wirkt nach dem Neustart von cmux." } }, "es": { "stringUnit": { "state": "translated", - "value": "Muestra Inicio como una página completa en lugar de un espacio de trabajo con Chief acoplado a la izquierda. Se aplica la próxima vez que se abra Inicio." + "value": "Muestra Inicio como una página completa en lugar de un espacio de trabajo con Chief acoplado a la izquierda. Se aplica después de reiniciar cmux." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Afficher l'Accueil en pleine page plutôt que comme un espace de travail avec Chief ancré à gauche. Prend effet à la prochaine ouverture de l'Accueil." + "value": "Afficher l'Accueil en pleine page plutôt que comme un espace de travail avec Chief ancré à gauche. Prend effet après le redémarrage de cmux." } }, "it": { "stringUnit": { "state": "translated", - "value": "Mostra la Home come pagina intera invece che come spazio di lavoro con Chief agganciato a sinistra. Ha effetto alla prossima apertura della Home." + "value": "Mostra la Home come pagina intera invece che come spazio di lavoro con Chief agganciato a sinistra. Ha effetto dopo il riavvio di cmux." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ホームを、Chief を左にドッキングしたワークスペースではなく、全画面のページとして表示します。次にホームを開いたときに反映されます。" + "value": "ホームを、Chief を左にドッキングしたワークスペースではなく、全画面のページとして表示します。cmux の再起動後に反映されます。" } }, "km": { "stringUnit": { "state": "translated", - "value": "បង្ហាញទំព័រដើមជាទំព័រពេញ ជំនួសឱ្យកន្លែងធ្វើការដែលមាន Chief ភ្ជាប់នៅខាងឆ្វេង។ មានប្រសិទ្ធភាពនៅពេលបើកទំព័រដើមលើកក្រោយ។" + "value": "បង្ហាញទំព័រដើមជាទំព័រពេញ ជំនួសឱ្យកន្លែងធ្វើការដែលមាន Chief ភ្ជាប់នៅខាងឆ្វេង។ មានប្រសិទ្ធភាពបន្ទាប់ពីចាប់ផ្ដើម cmux ឡើងវិញ។" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Chief가 왼쪽에 고정된 작업 공간 대신 홈을 전체 페이지로 표시합니다. 다음에 홈을 열 때 적용됩니다." + "value": "Chief가 왼쪽에 고정된 작업 공간 대신 홈을 전체 페이지로 표시합니다. cmux를 다시 시작한 후 적용됩니다." } }, "nb": { "stringUnit": { "state": "translated", - "value": "Vis Hjem som en hel side i stedet for et arbeidsområde med Chief festet til venstre. Trer i kraft neste gang Hjem åpnes." + "value": "Vis Hjem som en hel side i stedet for et arbeidsområde med Chief festet til venstre. Trer i kraft når cmux startes på nytt." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Pokazuj ekran główny jako pełną stronę zamiast obszaru roboczego z Chiefem zadokowanym po lewej. Zmiana działa przy następnym otwarciu ekranu głównego." + "value": "Pokazuj Start jako pełną stronę zamiast obszaru roboczego z Chiefem zadokowanym po lewej. Zmiana działa po ponownym uruchomieniu cmux." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Mostra o Início como uma página inteira em vez de um espaço de trabalho com o Chief encaixado à esquerda. Vale na próxima vez que o Início for aberto." + "value": "Mostra o Início como uma página inteira em vez de um espaço de trabalho com o Chief encaixado à esquerda. Vale depois que o cmux for reiniciado." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Показывать главную как целую страницу, а не рабочее пространство с Chief, закреплённым слева. Применяется при следующем открытии главной." + "value": "Показывать главную как целую страницу, а не рабочее пространство с Chief, закреплённым слева. Применяется после перезапуска cmux." } }, "th": { "stringUnit": { "state": "translated", - "value": "แสดงหน้าหลักเป็นหน้าเต็มแทนพื้นที่ทำงานที่มี Chief ปักหมุดอยู่ทางซ้าย มีผลเมื่อเปิดหน้าหลักครั้งถัดไป" + "value": "แสดงหน้าหลักเป็นหน้าเต็มแทนพื้นที่ทำงานที่มี Chief ปักหมุดอยู่ทางซ้าย มีผลหลังจากเริ่ม cmux ใหม่" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Ana Sayfa'yı, Chief'in solda sabitlendiği bir çalışma alanı yerine tam sayfa olarak göster. Ana Sayfa bir sonraki açılışta geçerli olur." + "value": "Ana Sayfa'yı, Chief'in solda sabitlendiği bir çalışma alanı yerine tam sayfa olarak göster. cmux yeniden başlatıldıktan sonra geçerli olur." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Показувати головну як цілу сторінку, а не робочий простір із Chief, закріпленим ліворуч. Застосовується під час наступного відкриття головної." + "value": "Показувати головну як цілу сторінку, а не робочий простір із Chief, закріпленим ліворуч. Застосовується після перезапуску cmux." } }, "vi": { "stringUnit": { "state": "translated", - "value": "Hiển thị Trang chủ dạng trang đầy đủ thay vì không gian làm việc có Chief neo bên trái. Có hiệu lực khi mở Trang chủ lần sau." + "value": "Hiển thị Trang chủ dạng trang đầy đủ thay vì không gian làm việc có Chief neo bên trái. Có hiệu lực sau khi khởi động lại cmux." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "将主页显示为完整页面,而不是左侧停靠 Chief 的工作区。下次打开主页时生效。" + "value": "将主页显示为完整页面,而不是左侧停靠 Chief 的工作区。重新启动 cmux 后生效。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "將首頁顯示為完整頁面,而非左側停駐 Chief 的工作區。下次開啟首頁時生效。" + "value": "將主頁顯示為完整頁面,而非左側停駐 Chief 的工作區。重新啟動 cmux 後生效。" } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift index 995f488002a9..d46a553ae64c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift @@ -31,7 +31,7 @@ nonisolated enum LabsSettingsSchema { section: .advanced, group: group, title: SettingsText.keyed("settings.labs.classicHome", "Classic Home"), - help: SettingsText.keyed("settings.labs.classicHome.help", "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next."), + help: SettingsText.keyed("settings.labs.classicHome.help", "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts."), kind: .toggle, default: .bool(CmuxConfigSnapshot.classicHomeDefault), keywords: ["home", "chief", "classic", "dock", "page"] diff --git a/schemas/settings/source/labs.json b/schemas/settings/source/labs.json index f3ad4088e060..a2fc1cb2646f 100644 --- a/schemas/settings/source/labs.json +++ b/schemas/settings/source/labs.json @@ -40,7 +40,7 @@ }, "help": { "key": "settings.labs.classicHome.help", - "text": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next." + "text": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts." }, "kind": "toggle", "default": false, diff --git a/webviews/src/pages/settings/generated/strings.json b/webviews/src/pages/settings/generated/strings.json index 9ea9ec40abce..ed485db9c424 100644 --- a/webviews/src/pages/settings/generated/strings.json +++ b/webviews/src/pages/settings/generated/strings.json @@ -470,7 +470,7 @@ "settings.home.keepLocation": "Keep Location in Photos and Videos", "settings.home.keepLocation.help": "When off, location data is removed from photos and videos before they are attached.", "settings.labs.classicHome": "Classic Home", - "settings.labs.classicHome.help": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next.", + "settings.labs.classicHome.help": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts.", "settings.labs.previewFeatures": "Show Preview Features", "settings.labs.previewFeatures.help": "Unfinished surfaces, such as the agent session's coverage label and Pull requests view.", "settings.layout.centerFocusedColumn": "Center Focused Column", @@ -1484,7 +1484,7 @@ "settings.home.keepLocation": "الاحتفاظ بالموقع في الصور ومقاطع الفيديو", "settings.home.keepLocation.help": "عند الإيقاف، تُزال بيانات الموقع من الصور ومقاطع الفيديو قبل إرفاقها.", "settings.labs.classicHome": "الصفحة الرئيسية الكلاسيكية", - "settings.labs.classicHome.help": "عرض الصفحة الرئيسية كصفحة كاملة بدلًا من مساحة عمل يكون فيها Chief مثبتًا على اليسار. يسري التغيير عند فتح الصفحة الرئيسية في المرة التالية.", + "settings.labs.classicHome.help": "عرض الصفحة الرئيسية كصفحة كاملة بدلًا من مساحة عمل يكون فيها Chief مثبتًا على اليسار. يسري التغيير بعد إعادة تشغيل cmux.", "settings.labs.previewFeatures": "إظهار ميزات المعاينة", "settings.labs.previewFeatures.help": "واجهات غير مكتملة، مثل تسمية تغطية جلسة الوكيل وعرض طلبات السحب.", "settings.layout.centerFocusedColumn": "توسيط العمود المحدد", @@ -2498,7 +2498,7 @@ "settings.home.keepLocation": "Zadrži lokaciju u fotografijama i videozapisima", "settings.home.keepLocation.help": "Kada je isključeno, podaci o lokaciji uklanjaju se iz fotografija i videozapisa prije prilaganja.", "settings.labs.classicHome": "Klasična početna", - "settings.labs.classicHome.help": "Prikaži početnu kao cijelu stranicu umjesto radnog prostora s Chiefom usidrenim lijevo. Primjenjuje se kada se početna sljedeći put otvori.", + "settings.labs.classicHome.help": "Prikaži početnu kao cijelu stranicu umjesto radnog prostora s Chiefom usidrenim lijevo. Primjenjuje se nakon ponovnog pokretanja cmuxa.", "settings.labs.previewFeatures": "Prikaži funkcije u pregledu", "settings.labs.previewFeatures.help": "Nedovršeni dijelovi, poput oznake pokrivenosti sesije agenta i prikaza zahtjeva za povlačenje.", "settings.layout.centerFocusedColumn": "Centriraj fokusiranu kolonu", @@ -3512,7 +3512,7 @@ "settings.home.keepLocation": "Behold lokation i fotos og videoer", "settings.home.keepLocation.help": "Når den er slået fra, fjernes lokationsdata fra fotos og videoer, før de vedhæftes.", "settings.labs.classicHome": "Klassisk Hjem", - "settings.labs.classicHome.help": "Vis Hjem som en hel side i stedet for et arbejdsområde med Chief fastgjort til venstre. Træder i kraft, næste gang Hjem åbnes.", + "settings.labs.classicHome.help": "Vis Hjem som en hel side i stedet for et arbejdsområde med Chief fastgjort til venstre. Træder i kraft, når cmux genstartes.", "settings.labs.previewFeatures": "Vis forhåndsvisningsfunktioner", "settings.labs.previewFeatures.help": "Ufærdige flader, f.eks. agentsessionens dækningsetiket og visningen af pull requests.", "settings.layout.centerFocusedColumn": "Centrér kolonnen i fokus", @@ -4525,8 +4525,8 @@ "settings.history.terminalCommands.summary": "Zeigt beendete Befehle im Verlauf", "settings.home.keepLocation": "Ort in Fotos und Videos behalten", "settings.home.keepLocation.help": "Wenn aus, werden Ortsdaten aus Fotos und Videos entfernt, bevor sie angehängt werden.", - "settings.labs.classicHome": "Klassische Startseite", - "settings.labs.classicHome.help": "Startseite als ganze Seite statt als Arbeitsbereich mit links angedocktem Chief anzeigen. Wirkt beim nächsten Öffnen der Startseite.", + "settings.labs.classicHome": "Klassisches Home", + "settings.labs.classicHome.help": "Home als ganze Seite statt als Arbeitsbereich mit links angedocktem Chief anzeigen. Wirkt nach dem Neustart von cmux.", "settings.labs.previewFeatures": "Vorschaufunktionen anzeigen", "settings.labs.previewFeatures.help": "Unfertige Oberflächen, etwa die Abdeckungsangabe der Agent-Sitzung und die Pull-Request-Ansicht.", "settings.layout.centerFocusedColumn": "Fokussierte Spalte zentrieren", @@ -5540,7 +5540,7 @@ "settings.home.keepLocation": "Mantener la ubicación en fotos y vídeos", "settings.home.keepLocation.help": "Si está desactivado, los datos de ubicación se eliminan de las fotos y los vídeos antes de adjuntarlos.", "settings.labs.classicHome": "Inicio clásico", - "settings.labs.classicHome.help": "Muestra Inicio como una página completa en lugar de un espacio de trabajo con Chief acoplado a la izquierda. Se aplica la próxima vez que se abra Inicio.", + "settings.labs.classicHome.help": "Muestra Inicio como una página completa en lugar de un espacio de trabajo con Chief acoplado a la izquierda. Se aplica después de reiniciar cmux.", "settings.labs.previewFeatures": "Mostrar funciones en vista previa", "settings.labs.previewFeatures.help": "Superficies sin terminar, como la etiqueta de cobertura de la sesión del agente y la vista de solicitudes de incorporación.", "settings.layout.centerFocusedColumn": "Centrar la columna enfocada", @@ -6554,7 +6554,7 @@ "settings.home.keepLocation": "Conserver la position dans les photos et vidéos", "settings.home.keepLocation.help": "Si désactivé, les données de position sont supprimées des photos et vidéos avant leur ajout en pièce jointe.", "settings.labs.classicHome": "Accueil classique", - "settings.labs.classicHome.help": "Afficher l'Accueil en pleine page plutôt que comme un espace de travail avec Chief ancré à gauche. Prend effet à la prochaine ouverture de l'Accueil.", + "settings.labs.classicHome.help": "Afficher l'Accueil en pleine page plutôt que comme un espace de travail avec Chief ancré à gauche. Prend effet après le redémarrage de cmux.", "settings.labs.previewFeatures": "Afficher les fonctionnalités en aperçu", "settings.labs.previewFeatures.help": "Surfaces inachevées, comme l'étiquette de couverture de la session de l'agent et la vue des pull requests.", "settings.layout.centerFocusedColumn": "Centrer la colonne active", @@ -7568,7 +7568,7 @@ "settings.home.keepLocation": "Mantieni la posizione in foto e video", "settings.home.keepLocation.help": "Se disattivato, i dati sulla posizione vengono rimossi da foto e video prima di allegarli.", "settings.labs.classicHome": "Home classica", - "settings.labs.classicHome.help": "Mostra la Home come pagina intera invece che come spazio di lavoro con Chief agganciato a sinistra. Ha effetto alla prossima apertura della Home.", + "settings.labs.classicHome.help": "Mostra la Home come pagina intera invece che come spazio di lavoro con Chief agganciato a sinistra. Ha effetto dopo il riavvio di cmux.", "settings.labs.previewFeatures": "Mostra funzioni in anteprima", "settings.labs.previewFeatures.help": "Superfici non finite, come l'etichetta di copertura della sessione dell'agente e la vista delle pull request.", "settings.layout.centerFocusedColumn": "Centra la colonna attiva", @@ -8582,7 +8582,7 @@ "settings.home.keepLocation": "写真とビデオの位置情報を残す", "settings.home.keepLocation.help": "オフのときは、写真とビデオを添付する前に位置情報を削除します。", "settings.labs.classicHome": "クラシックホーム", - "settings.labs.classicHome.help": "ホームを、Chief を左にドッキングしたワークスペースではなく、全画面のページとして表示します。次にホームを開いたときに反映されます。", + "settings.labs.classicHome.help": "ホームを、Chief を左にドッキングしたワークスペースではなく、全画面のページとして表示します。cmux の再起動後に反映されます。", "settings.labs.previewFeatures": "プレビュー機能を表示", "settings.labs.previewFeatures.help": "エージェントセッションのカバレッジ表示やプルリクエスト表示など、未完成の画面。", "settings.layout.centerFocusedColumn": "フォーカス中のカラムを中央に", @@ -9596,7 +9596,7 @@ "settings.home.keepLocation": "រក្សាទីតាំងក្នុងរូបថត និងវីដេអូ", "settings.home.keepLocation.help": "នៅពេលបិទ ទិន្នន័យទីតាំងត្រូវបានលុបចេញពីរូបថត និងវីដេអូ មុនពេលភ្ជាប់។", "settings.labs.classicHome": "ទំព័រដើមបុរាណ", - "settings.labs.classicHome.help": "បង្ហាញទំព័រដើមជាទំព័រពេញ ជំនួសឱ្យកន្លែងធ្វើការដែលមាន Chief ភ្ជាប់នៅខាងឆ្វេង។ មានប្រសិទ្ធភាពនៅពេលបើកទំព័រដើមលើកក្រោយ។", + "settings.labs.classicHome.help": "បង្ហាញទំព័រដើមជាទំព័រពេញ ជំនួសឱ្យកន្លែងធ្វើការដែលមាន Chief ភ្ជាប់នៅខាងឆ្វេង។ មានប្រសិទ្ធភាពបន្ទាប់ពីចាប់ផ្ដើម cmux ឡើងវិញ។", "settings.labs.previewFeatures": "បង្ហាញមុខងារមើលជាមុន", "settings.labs.previewFeatures.help": "ផ្ទៃដែលមិនទាន់បញ្ចប់ ដូចជាស្លាកវិសាលភាពនៃវគ្គភ្នាក់ងារ និងទិដ្ឋភាពសំណើទាញ។", "settings.layout.centerFocusedColumn": "ដាក់ជួរឈរដែលផ្ដោតនៅកណ្ដាល", @@ -10610,7 +10610,7 @@ "settings.home.keepLocation": "사진 및 비디오의 위치 정보 유지", "settings.home.keepLocation.help": "끄면 사진과 비디오를 첨부하기 전에 위치 데이터를 제거합니다.", "settings.labs.classicHome": "클래식 홈", - "settings.labs.classicHome.help": "Chief가 왼쪽에 고정된 작업 공간 대신 홈을 전체 페이지로 표시합니다. 다음에 홈을 열 때 적용됩니다.", + "settings.labs.classicHome.help": "Chief가 왼쪽에 고정된 작업 공간 대신 홈을 전체 페이지로 표시합니다. cmux를 다시 시작한 후 적용됩니다.", "settings.labs.previewFeatures": "미리 보기 기능 표시", "settings.labs.previewFeatures.help": "에이전트 세션의 적용 범위 레이블과 풀 리퀘스트 보기처럼 아직 완성되지 않은 화면입니다.", "settings.layout.centerFocusedColumn": "포커스된 열 가운데 정렬", @@ -11624,7 +11624,7 @@ "settings.home.keepLocation": "Behold posisjon i bilder og videoer", "settings.home.keepLocation.help": "Når den er av, fjernes posisjonsdata fra bilder og videoer før de legges ved.", "settings.labs.classicHome": "Klassisk Hjem", - "settings.labs.classicHome.help": "Vis Hjem som en hel side i stedet for et arbeidsområde med Chief festet til venstre. Trer i kraft neste gang Hjem åpnes.", + "settings.labs.classicHome.help": "Vis Hjem som en hel side i stedet for et arbeidsområde med Chief festet til venstre. Trer i kraft når cmux startes på nytt.", "settings.labs.previewFeatures": "Vis forhåndsvisningsfunksjoner", "settings.labs.previewFeatures.help": "Uferdige flater, for eksempel agentøktens dekningsetikett og visningen av pull-forespørsler.", "settings.layout.centerFocusedColumn": "Midtstill kolonnen i fokus", @@ -12637,8 +12637,8 @@ "settings.history.terminalCommands.summary": "Pokazuje zakończone polecenia w Historii", "settings.home.keepLocation": "Zachowaj lokalizację w zdjęciach i filmach", "settings.home.keepLocation.help": "Gdy wyłączone, dane lokalizacji są usuwane ze zdjęć i filmów przed ich załączeniem.", - "settings.labs.classicHome": "Klasyczny ekran główny", - "settings.labs.classicHome.help": "Pokazuj ekran główny jako pełną stronę zamiast obszaru roboczego z Chiefem zadokowanym po lewej. Zmiana działa przy następnym otwarciu ekranu głównego.", + "settings.labs.classicHome": "Klasyczny Start", + "settings.labs.classicHome.help": "Pokazuj Start jako pełną stronę zamiast obszaru roboczego z Chiefem zadokowanym po lewej. Zmiana działa po ponownym uruchomieniu cmux.", "settings.labs.previewFeatures": "Pokaż funkcje w wersji zapoznawczej", "settings.labs.previewFeatures.help": "Niedokończone elementy, takie jak etykieta pokrycia sesji agenta i widok pull requestów.", "settings.layout.centerFocusedColumn": "Wyśrodkuj aktywną kolumnę", @@ -13652,7 +13652,7 @@ "settings.home.keepLocation": "Manter a localização em fotos e vídeos", "settings.home.keepLocation.help": "Quando desativado, os dados de localização são removidos de fotos e vídeos antes de serem anexados.", "settings.labs.classicHome": "Início clássico", - "settings.labs.classicHome.help": "Mostra o Início como uma página inteira em vez de um espaço de trabalho com o Chief encaixado à esquerda. Vale na próxima vez que o Início for aberto.", + "settings.labs.classicHome.help": "Mostra o Início como uma página inteira em vez de um espaço de trabalho com o Chief encaixado à esquerda. Vale depois que o cmux for reiniciado.", "settings.labs.previewFeatures": "Mostrar recursos em prévia", "settings.labs.previewFeatures.help": "Superfícies inacabadas, como o rótulo de cobertura da sessão do agente e a visualização de pull requests.", "settings.layout.centerFocusedColumn": "Centralizar coluna em foco", @@ -14666,7 +14666,7 @@ "settings.home.keepLocation": "Сохранять геопозицию в фото и видео", "settings.home.keepLocation.help": "Если выключено, данные о местоположении удаляются из фото и видео перед вложением.", "settings.labs.classicHome": "Классическая главная", - "settings.labs.classicHome.help": "Показывать главную как целую страницу, а не рабочее пространство с Chief, закреплённым слева. Применяется при следующем открытии главной.", + "settings.labs.classicHome.help": "Показывать главную как целую страницу, а не рабочее пространство с Chief, закреплённым слева. Применяется после перезапуска cmux.", "settings.labs.previewFeatures": "Показывать предварительные функции", "settings.labs.previewFeatures.help": "Незавершённые элементы, например метка покрытия сеанса агента и представление запросов на слияние.", "settings.layout.centerFocusedColumn": "Центрировать активную колонку", @@ -15680,7 +15680,7 @@ "settings.home.keepLocation": "เก็บตำแหน่งที่ตั้งไว้ในรูปภาพและวิดีโอ", "settings.home.keepLocation.help": "เมื่อปิด ข้อมูลตำแหน่งที่ตั้งจะถูกลบออกจากรูปภาพและวิดีโอก่อนแนบ", "settings.labs.classicHome": "หน้าหลักแบบคลาสสิก", - "settings.labs.classicHome.help": "แสดงหน้าหลักเป็นหน้าเต็มแทนพื้นที่ทำงานที่มี Chief ปักหมุดอยู่ทางซ้าย มีผลเมื่อเปิดหน้าหลักครั้งถัดไป", + "settings.labs.classicHome.help": "แสดงหน้าหลักเป็นหน้าเต็มแทนพื้นที่ทำงานที่มี Chief ปักหมุดอยู่ทางซ้าย มีผลหลังจากเริ่ม cmux ใหม่", "settings.labs.previewFeatures": "แสดงฟีเจอร์ตัวอย่าง", "settings.labs.previewFeatures.help": "ส่วนที่ยังไม่เสร็จ เช่น ป้ายความครอบคลุมของเซสชันเอเจนต์และมุมมองคำขอดึง", "settings.layout.centerFocusedColumn": "จัดคอลัมน์ที่โฟกัสไว้ตรงกลาง", @@ -16694,7 +16694,7 @@ "settings.home.keepLocation": "Fotoğraf ve videolarda konumu koru", "settings.home.keepLocation.help": "Kapalıyken, konum verileri fotoğraf ve videolar eklenmeden önce kaldırılır.", "settings.labs.classicHome": "Klasik Ana Sayfa", - "settings.labs.classicHome.help": "Ana Sayfa'yı, Chief'in solda sabitlendiği bir çalışma alanı yerine tam sayfa olarak göster. Ana Sayfa bir sonraki açılışta geçerli olur.", + "settings.labs.classicHome.help": "Ana Sayfa'yı, Chief'in solda sabitlendiği bir çalışma alanı yerine tam sayfa olarak göster. cmux yeniden başlatıldıktan sonra geçerli olur.", "settings.labs.previewFeatures": "Önizleme Özelliklerini Göster", "settings.labs.previewFeatures.help": "Ajan oturumunun kapsam etiketi ve çekme istekleri görünümü gibi bitmemiş yüzeyler.", "settings.layout.centerFocusedColumn": "Odaktaki Sütunu Ortala", @@ -17708,7 +17708,7 @@ "settings.home.keepLocation": "Зберігати геопозицію у фото й відео", "settings.home.keepLocation.help": "Якщо вимкнено, дані про геопозицію видаляються з фото й відео перед вкладенням.", "settings.labs.classicHome": "Класична головна", - "settings.labs.classicHome.help": "Показувати головну як цілу сторінку, а не робочий простір із Chief, закріпленим ліворуч. Застосовується під час наступного відкриття головної.", + "settings.labs.classicHome.help": "Показувати головну як цілу сторінку, а не робочий простір із Chief, закріпленим ліворуч. Застосовується після перезапуску cmux.", "settings.labs.previewFeatures": "Показувати попередні функції", "settings.labs.previewFeatures.help": "Незавершені елементи, як-от позначка покриття сеансу агента та подання запитів на злиття.", "settings.layout.centerFocusedColumn": "Центрувати активну колонку", @@ -18722,7 +18722,7 @@ "settings.home.keepLocation": "Giữ vị trí trong ảnh và video", "settings.home.keepLocation.help": "Khi tắt, dữ liệu vị trí sẽ bị xóa khỏi ảnh và video trước khi đính kèm.", "settings.labs.classicHome": "Trang chủ cổ điển", - "settings.labs.classicHome.help": "Hiển thị Trang chủ dạng trang đầy đủ thay vì không gian làm việc có Chief neo bên trái. Có hiệu lực khi mở Trang chủ lần sau.", + "settings.labs.classicHome.help": "Hiển thị Trang chủ dạng trang đầy đủ thay vì không gian làm việc có Chief neo bên trái. Có hiệu lực sau khi khởi động lại cmux.", "settings.labs.previewFeatures": "Hiện tính năng xem trước", "settings.labs.previewFeatures.help": "Các phần chưa hoàn thiện, như nhãn phạm vi của phiên tác tử và chế độ xem yêu cầu kéo.", "settings.layout.centerFocusedColumn": "Căn giữa cột đang chọn", @@ -19736,7 +19736,7 @@ "settings.home.keepLocation": "在照片和视频中保留位置", "settings.home.keepLocation.help": "关闭时,照片和视频在附加前会移除位置数据。", "settings.labs.classicHome": "经典主页", - "settings.labs.classicHome.help": "将主页显示为完整页面,而不是左侧停靠 Chief 的工作区。下次打开主页时生效。", + "settings.labs.classicHome.help": "将主页显示为完整页面,而不是左侧停靠 Chief 的工作区。重新启动 cmux 后生效。", "settings.labs.previewFeatures": "显示预览功能", "settings.labs.previewFeatures.help": "尚未完成的界面,例如智能体会话的覆盖范围标签和拉取请求视图。", "settings.layout.centerFocusedColumn": "居中显示焦点列", @@ -20749,8 +20749,8 @@ "settings.history.terminalCommands.summary": "在歷史記錄中列出已完成的指令", "settings.home.keepLocation": "在照片和影片中保留位置", "settings.home.keepLocation.help": "關閉時,照片和影片在附加前會移除位置資料。", - "settings.labs.classicHome": "經典首頁", - "settings.labs.classicHome.help": "將首頁顯示為完整頁面,而非左側停駐 Chief 的工作區。下次開啟首頁時生效。", + "settings.labs.classicHome": "經典主頁", + "settings.labs.classicHome.help": "將主頁顯示為完整頁面,而非左側停駐 Chief 的工作區。重新啟動 cmux 後生效。", "settings.labs.previewFeatures": "顯示預覽功能", "settings.labs.previewFeatures.help": "尚未完成的介面,例如代理程式工作階段的涵蓋範圍標籤與提取要求檢視。", "settings.layout.centerFocusedColumn": "置中顯示焦點欄", From 592cd6c1d44b2b6b254e3ad27a4cf86e75b2cc17 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:33:20 -0700 Subject: [PATCH 095/183] cmux-coderouter: a Mac with no default keychain mints keys with a random per-run install secret (cx-f4thx) router_lifetime::a_kept_agent_keeps_its_router_and_the_next_daemon_adopts_it failed on the macOS fleet (cmux-ci step 48394b53e9d0 on aws-m4pro-9, base 7b86415e2d82): ask_router read EOF at router_lifetime.rs:80. A probe on an AWS worker (step d979bfd9ea29) shows why: KeychainInstallSecretStore::load fails with "A default keychain could not be found." Every mint then failed ("install secret unavailable"), admin_connection returned the error, and the admin socket closed with no reply, so no route key could be minted on such a Mac (fleet workers, sessions without a login keychain). serve_owned now asks the Keychain once at start. When it answers, the router keeps the Keychain store as before; when it does not, this run uses a random in-memory install secret (RandomInstallSecretStore, as on Linux) and logs a warning. Keys live in memory for one router run, so the random secret is as strong. The choice is made once: the key digest reads the store on every check and must not switch during a run. --- cmux-tui/crates/cmux-coderouter/src/lib.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-coderouter/src/lib.rs b/cmux-tui/crates/cmux-coderouter/src/lib.rs index 0bd5f5470f63..d3ede5899aa1 100644 --- a/cmux-tui/crates/cmux-coderouter/src/lib.rs +++ b/cmux-tui/crates/cmux-coderouter/src/lib.rs @@ -503,7 +503,7 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { // Keys live in memory for one router run. macOS keeps the install secret in // the Keychain; elsewhere a fresh random secret per run is equivalent. #[cfg(target_os = "macos")] - let store: Arc = Arc::new(KeychainInstallSecretStore::new("default")); + let store = macos_install_secret_store()?; #[cfg(not(target_os = "macos"))] let store: Arc = Arc::new(RandomInstallSecretStore::new("default")?); let keys = Arc::new(tokio::sync::RwLock::new(KeyRing::new(store)?)); @@ -521,6 +521,26 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { Ok(()) } +/// The Keychain store when the Keychain answers at start, else a random +/// in-memory secret for this run (a Mac with no default keychain: a fleet +/// worker, a session without a login keychain). Keys never outlive one router +/// run, so the random secret is as strong; without it every mint failed and +/// the admin socket closed with no reply. Decided once: the key digest reads +/// the store on every check, so it must never switch during a run. +#[cfg(target_os = "macos")] +fn macos_install_secret_store() -> anyhow::Result> { + let keychain = KeychainInstallSecretStore::new("default"); + match keychain.load() { + Ok(_) => Ok(Arc::new(keychain)), + Err(e) => { + tracing::warn!( + "the Keychain install secret is unavailable ({e:#}); this router run uses a random in-memory secret" + ); + Ok(Arc::new(RandomInstallSecretStore::new("default")?)) + } + } +} + /// Start the real loopback data plane for integration tests and composition. pub async fn spawn_data_plane( keys: Arc>, From 73d60125203404127e10670f3ecb9f34b55598bc Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:50:59 -0700 Subject: [PATCH 096/183] cmux-coderouter: status says where the install secret lives (install_secret: keychain | ephemeral); behavior test with no keychain (cx-f4thx) Chief conditions for the keychain fallback: the fallback is visible and tested. The router records once at start where its install secret lives and `status` reports it as install_secret: "keychain" (macOS Keychain) or "ephemeral" (random, in memory, this run only: Linux, or a Mac with no default keychain), so the app can show it. The secret itself goes to no file, log or environment variable; the warning names only the Keychain error. Debug builds take the no-keychain path on any Mac with CMUX_CODEROUTER_TEST_NO_KEYCHAIN (release builds have no seam). tests/router_install_secret.rs drives `acpmux router serve` through its admin socket and loopback data plane: status says ephemeral; a minted key is accepted within the run (503, no upstream) and a changed one is refused (401); after a restart the old key is refused (401); the key's secret is in no file under the router's home (including its stderr log). --- .../acpmux/tests/router_install_secret.rs | 134 ++++++++++++++++++ cmux-tui/crates/cmux-coderouter/src/lib.rs | 34 ++++- 2 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 cmux-tui/crates/acpmux/tests/router_install_secret.rs diff --git a/cmux-tui/crates/acpmux/tests/router_install_secret.rs b/cmux-tui/crates/acpmux/tests/router_install_secret.rs new file mode 100644 index 000000000000..a5f35b41b301 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/router_install_secret.rs @@ -0,0 +1,134 @@ +//! The local router with no default keychain (cx-f4thx): it runs on a random +//! in-memory install secret, says so in `status` (`install_secret: +//! ephemeral`), and the keys it mints work for that run only. Driven through +//! `acpmux router serve`, its admin socket and its loopback data plane. On +//! Linux the router always runs this way; on macOS the debug-only +//! `CMUX_CODEROUTER_TEST_NO_KEYCHAIN` takes the same path as a Mac without a +//! login keychain. +#![cfg(unix)] + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +fn scratch() -> PathBuf { + // Short: the socket path must stay under the macOS limit. + let dir = std::env::temp_dir().join(format!("amx-rsec-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// `acpmux router serve` on `home` (no owner FIFO: it runs until `shutdown`). +fn start_router(home: &Path, stderr: &Path) -> Child { + let _ = std::fs::remove_file(home.join("router").join("router.sock")); + let child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + .args(["router", "serve"]) + .env("ACPMUX_HOME", home) + .env("CMUX_CODEROUTER_TEST_NO_KEYCHAIN", "1") + .env("RUST_LOG", "warn") + .env_remove("CMUX_ROUTER_OWNER_FIFO") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(std::fs::OpenOptions::new().create(true).append(true).open(stderr).unwrap()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + while UnixStream::connect(home.join("router").join("router.sock")).is_err() { + assert!(Instant::now() < deadline, "the router never opened its admin socket"); + std::thread::sleep(Duration::from_millis(50)); + } + child +} + +/// One admin request; the reply line as JSON. +fn ask(home: &Path, request: Value) -> Value { + let mut stream = UnixStream::connect(home.join("router").join("router.sock")).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + stream.write_all(format!("{request}\n").as_bytes()).unwrap(); + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).unwrap(); + serde_json::from_str(&line).unwrap_or_else(|e| panic!("{request}: no reply ({e}): {line:?}")) +} + +/// The HTTP status of one chat completion through the data plane with `key`. +fn relay_status(port: u64, key: &str) -> u16 { + let mut stream = std::net::TcpStream::connect(("127.0.0.1", port as u16)).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let body = r#"{"model":"m","messages":[]}"#; + let request = format!( + "POST /v1/chat/completions HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {key}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(request.as_bytes()).unwrap(); + let mut reply = String::new(); + let _ = stream.read_to_string(&mut reply); + reply.split_whitespace().nth(1).and_then(|code| code.parse().ok()).unwrap_or(0) +} + +fn stop(home: &Path, mut router: Child) { + assert_eq!(ask(home, json!({"op": "shutdown"}))["stopping"], json!(true)); + let deadline = Instant::now() + Duration::from_secs(10); + while router.try_wait().unwrap().is_none() { + assert!(Instant::now() < deadline, "the router did not stop"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// Every file under `dir` (the router's home), for a secret scan. +fn files(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + let kind = entry.file_type().unwrap(); + if kind.is_dir() { + files(&path, out); + } else if kind.is_file() { + out.push(path); + } + } +} + +#[test] +fn with_no_keychain_keys_work_for_one_router_run_and_the_status_says_ephemeral() { + let home = scratch(); + let log = home.join("router-stderr.log"); + let router = start_router(&home, &log); + let status = ask(&home, json!({"op": "status"})); + assert_eq!(status["install_secret"], "ephemeral", "{status}"); + let port = status["port"].as_u64().unwrap(); + let minted = ask( + &home, + json!({"op": "mint_key", "scope": {"harness": "fake", "session": "s1", "surfaces": [], "families": ["open_ai_chat"], "expires_at": 0}}), + ); + let key = minted["key"].as_str().unwrap_or_else(|| panic!("no key minted: {minted}")).to_owned(); + // Within the run the key is accepted: the relay answers that no hosted + // upstream is signed in (503), never that the key is unknown (401). + assert_eq!(relay_status(port, &key), 503); + assert_eq!(relay_status(port, &format!("{key}x")), 401); + stop(&home, router); + + // A new run has a new secret: the old key is unknown there. + let router = start_router(&home, &log); + let status = ask(&home, json!({"op": "status"})); + assert_eq!(status["install_secret"], "ephemeral", "{status}"); + let port = status["port"].as_u64().unwrap(); + assert_eq!(relay_status(port, &key), 401, "a key outlived its router run"); + stop(&home, router); + + // The secret part of the key reached no file under the home and no log. + let secret = key.rsplit('_').next().unwrap().to_owned(); + let mut all = Vec::new(); + files(&home, &mut all); + for path in all { + let bytes = std::fs::read(&path).unwrap(); + assert!( + !String::from_utf8_lossy(&bytes).contains(&secret), + "the key's secret is in {}", + path.display() + ); + } + let _ = std::fs::remove_dir_all(&home); +} diff --git a/cmux-tui/crates/cmux-coderouter/src/lib.rs b/cmux-tui/crates/cmux-coderouter/src/lib.rs index d3ede5899aa1..f63805db3a2e 100644 --- a/cmux-tui/crates/cmux-coderouter/src/lib.rs +++ b/cmux-tui/crates/cmux-coderouter/src/lib.rs @@ -385,6 +385,14 @@ pub async fn serve(home: impl AsRef) -> anyhow::Result<()> { #[cfg(unix)] static OWNERS_GONE: std::sync::Mutex = std::sync::Mutex::new(false); +/// Where this run's install secret lives, for `status` (`install_secret`): +/// `keychain` (macOS Keychain) or `ephemeral` (random, in memory, this run +/// only: Linux, or a Mac with no default keychain). +static INSTALL_SECRET_KIND: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); +#[cfg(target_os = "macos")] +const INSTALL_SECRET_KEYCHAIN: &str = "keychain"; +const INSTALL_SECRET_EPHEMERAL: &str = "ephemeral"; + /// Reads the owner FIFO on its own thread (a blocking read: no polling, no /// timer). A read returns 0 when no process holds a write end. Under /// `OWNERS_GONE`'s lock the watch then checks again with a non-blocking read: @@ -503,9 +511,11 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { // Keys live in memory for one router run. macOS keeps the install secret in // the Keychain; elsewhere a fresh random secret per run is equivalent. #[cfg(target_os = "macos")] - let store = macos_install_secret_store()?; + let (store, kind) = macos_install_secret_store()?; #[cfg(not(target_os = "macos"))] - let store: Arc = Arc::new(RandomInstallSecretStore::new("default")?); + let (store, kind): (Arc, &'static str) = + (Arc::new(RandomInstallSecretStore::new("default")?), INSTALL_SECRET_EPHEMERAL); + let _ = INSTALL_SECRET_KIND.set(kind); let keys = Arc::new(tokio::sync::RwLock::new(KeyRing::new(store)?)); let port = listener.local_addr()?.port(); let upstream: SharedUpstream = Arc::new(tokio::sync::RwLock::new(None)); @@ -528,15 +538,25 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { /// the admin socket closed with no reply. Decided once: the key digest reads /// the store on every check, so it must never switch during a run. #[cfg(target_os = "macos")] -fn macos_install_secret_store() -> anyhow::Result> { +fn macos_install_secret_store() -> anyhow::Result<(Arc, &'static str)> { let keychain = KeychainInstallSecretStore::new("default"); - match keychain.load() { - Ok(_) => Ok(Arc::new(keychain)), + // Debug builds only: tests take the no-keychain path on any Mac. + #[cfg(debug_assertions)] + let loaded = if std::env::var_os("CMUX_CODEROUTER_TEST_NO_KEYCHAIN").is_some() { + Err(anyhow::anyhow!("no default keychain (CMUX_CODEROUTER_TEST_NO_KEYCHAIN)")) + } else { + keychain.load().map(drop) + }; + #[cfg(not(debug_assertions))] + let loaded = keychain.load().map(drop); + match loaded { + Ok(()) => Ok((Arc::new(keychain), INSTALL_SECRET_KEYCHAIN)), Err(e) => { + // The error text only; the secret itself never reaches a log. tracing::warn!( "the Keychain install secret is unavailable ({e:#}); this router run uses a random in-memory secret" ); - Ok(Arc::new(RandomInstallSecretStore::new("default")?)) + Ok((Arc::new(RandomInstallSecretStore::new("default")?), INSTALL_SECRET_EPHEMERAL)) } } } @@ -692,7 +712,7 @@ async fn admin_connection( } AdminRequest::Status => { let up = upstream.read().await; - serde_json::json!({"port": port, "build": router_build(), "pid": std::process::id(), "upstream": up.as_ref().map(Upstream::view), "free": free.view()}) + serde_json::json!({"port": port, "build": router_build(), "pid": std::process::id(), "upstream": up.as_ref().map(Upstream::view), "free": free.view(), "install_secret": INSTALL_SECRET_KIND.get().copied()}) } AdminRequest::Adopt => { // The daemon opened the owner FIFO before it asked; a router From ee33d699875125504edf26d35fb2f8d6367f33b8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:01:33 -0700 Subject: [PATCH 097/183] cmux-coderouter: review fixes for the keychain fallback (hq-11, security review; cx-f4thx) Why the fleet had no default keychain: cmux-ci gives each step its own HOME (/home), and the Security framework looks for the login keychain under $HOME/Library/Keychains (hq PR 1967). The fleet keeps steps isolated by design, so the router's fallback is the path CI takes, as it is for a user session with no login keychain. - The fallback warning goes to stderr (eprintln!): `acpmux router serve` installs no log subscriber, so the tracing line was never written. - A mint error (no install secret) is answered with {"error": ...} and the admin connection stays open; before, `?` closed the socket with no reply. - KeychainInstallSecretStore::load creates the item only on errSecItemNotFound; any other error (a locked or unreadable keychain) is returned and never rotates the install secret. - RandomInstallSecretStore keeps its secret in Secret (zeroized on drop) and zeroizes the stack copy; so does the Keychain create path. - router_install_secret.rs: checks the warning (one line per run on macOS), ends the router on failure (Drop guard), uses a new 0700 scratch folder, and the restart step claims only that keys never outlive a run. --- .../acpmux/tests/router_install_secret.rs | 40 ++++++++++++--- cmux-tui/crates/cmux-coderouter/src/lib.rs | 49 ++++++++++++------- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/cmux-tui/crates/acpmux/tests/router_install_secret.rs b/cmux-tui/crates/acpmux/tests/router_install_secret.rs index a5f35b41b301..ffac4f7a524d 100644 --- a/cmux-tui/crates/acpmux/tests/router_install_secret.rs +++ b/cmux-tui/crates/acpmux/tests/router_install_secret.rs @@ -15,15 +15,27 @@ use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; fn scratch() -> PathBuf { - // Short: the socket path must stay under the macOS limit. + use std::os::unix::fs::DirBuilderExt; + // Short: the socket path must stay under the macOS limit. A new, owner-only + // folder: an existing path (a stale run, or another user's link) fails. let dir = std::env::temp_dir().join(format!("amx-rsec-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + std::fs::DirBuilder::new().mode(0o700).create(&dir).unwrap(); dir } +/// Ends the router if the test fails before `stop`. +struct Router(Option); +impl Drop for Router { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + /// `acpmux router serve` on `home` (no owner FIFO: it runs until `shutdown`). -fn start_router(home: &Path, stderr: &Path) -> Child { +fn start_router(home: &Path, stderr: &Path) -> Router { let _ = std::fs::remove_file(home.join("router").join("router.sock")); let child = Command::new(env!("CARGO_BIN_EXE_acpmux")) .args(["router", "serve"]) @@ -36,12 +48,13 @@ fn start_router(home: &Path, stderr: &Path) -> Child { .stderr(std::fs::OpenOptions::new().create(true).append(true).open(stderr).unwrap()) .spawn() .unwrap(); + let router = Router(Some(child)); let deadline = Instant::now() + Duration::from_secs(20); while UnixStream::connect(home.join("router").join("router.sock")).is_err() { assert!(Instant::now() < deadline, "the router never opened its admin socket"); std::thread::sleep(Duration::from_millis(50)); } - child + router } /// One admin request; the reply line as JSON. @@ -69,10 +82,11 @@ fn relay_status(port: u64, key: &str) -> u16 { reply.split_whitespace().nth(1).and_then(|code| code.parse().ok()).unwrap_or(0) } -fn stop(home: &Path, mut router: Child) { +fn stop(home: &Path, mut router: Router) { assert_eq!(ask(home, json!({"op": "shutdown"}))["stopping"], json!(true)); + let mut child = router.0.take().unwrap(); let deadline = Instant::now() + Duration::from_secs(10); - while router.try_wait().unwrap().is_none() { + while child.try_wait().unwrap().is_none() { assert!(Instant::now() < deadline, "the router did not stop"); std::thread::sleep(Duration::from_millis(20)); } @@ -110,7 +124,8 @@ fn with_no_keychain_keys_work_for_one_router_run_and_the_status_says_ephemeral() assert_eq!(relay_status(port, &format!("{key}x")), 401); stop(&home, router); - // A new run has a new secret: the old key is unknown there. + // Keys never outlive a router run (with any store): the old key is + // unknown to the next run. let router = start_router(&home, &log); let status = ask(&home, json!({"op": "status"})); assert_eq!(status["install_secret"], "ephemeral", "{status}"); @@ -118,6 +133,15 @@ fn with_no_keychain_keys_work_for_one_router_run_and_the_status_says_ephemeral() assert_eq!(relay_status(port, &key), 401, "a key outlived its router run"); stop(&home, router); + // The fallback is visible: one warning line on stderr per run (macOS; on + // Linux the random secret is the only kind, with no fallback to report). + let warnings = std::fs::read_to_string(&log) + .unwrap() + .lines() + .filter(|l| l.contains("this router run uses a random in-memory secret")) + .count(); + assert_eq!(warnings, if cfg!(target_os = "macos") { 2 } else { 0 }, "router stderr: {log:?}"); + // The secret part of the key reached no file under the home and no log. let secret = key.rsplit('_').next().unwrap().to_owned(); let mut all = Vec::new(); diff --git a/cmux-tui/crates/cmux-coderouter/src/lib.rs b/cmux-tui/crates/cmux-coderouter/src/lib.rs index f63805db3a2e..7d0cc3583974 100644 --- a/cmux-tui/crates/cmux-coderouter/src/lib.rs +++ b/cmux-tui/crates/cmux-coderouter/src/lib.rs @@ -86,14 +86,16 @@ pub struct StaticInstallSecretStore { /// creation. Each instance creates one OS-CSPRNG secret and reuses it. pub struct RandomInstallSecretStore { install_id: String, - secret: Vec, + secret: Secret>, } impl RandomInstallSecretStore { /// Create one install identity with a fresh 32-byte secret. pub fn new(install_id: impl Into) -> anyhow::Result { let mut secret = [0u8; 32]; getrandom::fill(&mut secret)?; - Ok(Self { install_id: install_id.into(), secret: secret.to_vec() }) + let kept = Secret::new(secret.to_vec()); + secret.zeroize(); + Ok(Self { install_id: install_id.into(), secret: kept }) } } impl InstallSecretStore for RandomInstallSecretStore { @@ -101,7 +103,7 @@ impl InstallSecretStore for RandomInstallSecretStore { &self.install_id } fn load(&self) -> anyhow::Result>> { - Ok(Secret::new(self.secret.clone())) + Ok(Secret::new(self.secret.expose().clone())) } } impl StaticInstallSecretStore { @@ -122,6 +124,10 @@ impl InstallSecretStore for StaticInstallSecretStore { } } +/// `errSecItemNotFound` (Security framework): the Keychain item does not exist yet. +#[cfg(target_os = "macos")] +const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; + /// Production Keychain store. The item is addressed by service and install id; /// the router never persists the returned bytes. pub struct KeychainInstallSecretStore { @@ -142,16 +148,22 @@ impl InstallSecretStore for KeychainInstallSecretStore { let keychain = security_framework::os::macos::keychain::SecKeychain::default()?; match keychain.find_generic_password("com.cmuxterm.coderouter", &self.install_id) { Ok((password, _item)) => Ok(Secret::new(password.to_owned())), - Err(_) => { + // Only a missing item is created: any other error (a locked or + // unreadable keychain) must not rotate the install secret. + Err(e) if e.code() == ERR_SEC_ITEM_NOT_FOUND => { let mut secret = [0u8; 32]; getrandom::fill(&mut secret)?; - keychain.set_generic_password( + let set = keychain.set_generic_password( "com.cmuxterm.coderouter", &self.install_id, &secret, - )?; - Ok(Secret::new(secret.to_vec())) + ); + let kept = Secret::new(secret.to_vec()); + secret.zeroize(); + set?; + Ok(kept) } + Err(e) => Err(e.into()), } } #[cfg(not(target_os = "macos"))] @@ -552,9 +564,10 @@ fn macos_install_secret_store() -> anyhow::Result<(Arc, match loaded { Ok(()) => Ok((Arc::new(keychain), INSTALL_SECRET_KEYCHAIN)), Err(e) => { - // The error text only; the secret itself never reaches a log. - tracing::warn!( - "the Keychain install secret is unavailable ({e:#}); this router run uses a random in-memory secret" + // stderr (the router installs no log subscriber); the error text + // only, the secret itself never reaches a log. + eprintln!( + "cmux-coderouter: warning: the Keychain install secret is unavailable ({e:#}); this router run uses a random in-memory secret" ); Ok((Arc::new(RandomInstallSecretStore::new("default")?), INSTALL_SECRET_EPHEMERAL)) } @@ -701,11 +714,11 @@ async fn admin_connection( } }; let response = match request { - AdminRequest::Mint { key_id, scope } => { - let mut guard = keys.write().await; - let key = guard.mint(key_id, scope)?; - serde_json::json!({"key": key.expose()}) - } + // A mint error (no install secret) is answered, not a closed socket. + AdminRequest::Mint { key_id, scope } => match keys.write().await.mint(key_id, scope) { + Ok(key) => serde_json::json!({"key": key.expose()}), + Err(error) => serde_json::json!({"error": error.to_string()}), + }, AdminRequest::List => serde_json::json!({"keys": keys.read().await.list()}), AdminRequest::Revoke { key_id } => { serde_json::json!({"revoked": keys.write().await.revoke(&key_id)}) @@ -728,8 +741,10 @@ async fn admin_connection( } AdminRequest::MintKey { scope } => { let id = format!("s{}", uuid::Uuid::new_v4().simple()); - let key = keys.write().await.mint(id.clone(), scope)?; - serde_json::json!({"id": id, "key": key.expose()}) + match keys.write().await.mint(id.clone(), scope) { + Ok(key) => serde_json::json!({"id": id, "key": key.expose()}), + Err(error) => serde_json::json!({"error": error.to_string()}), + } } AdminRequest::RevokeKey { id } => { serde_json::json!({"revoked": keys.write().await.revoke(&id)}) From 8464918c3b7a2c5a41af798a76e89eb905598752 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:04:06 -0700 Subject: [PATCH 098/183] acpmux test: rustfmt line break in router_install_secret.rs (cx-f4thx) --- cmux-tui/crates/acpmux/tests/router_install_secret.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/tests/router_install_secret.rs b/cmux-tui/crates/acpmux/tests/router_install_secret.rs index ffac4f7a524d..310c49d36274 100644 --- a/cmux-tui/crates/acpmux/tests/router_install_secret.rs +++ b/cmux-tui/crates/acpmux/tests/router_install_secret.rs @@ -117,7 +117,8 @@ fn with_no_keychain_keys_work_for_one_router_run_and_the_status_says_ephemeral() &home, json!({"op": "mint_key", "scope": {"harness": "fake", "session": "s1", "surfaces": [], "families": ["open_ai_chat"], "expires_at": 0}}), ); - let key = minted["key"].as_str().unwrap_or_else(|| panic!("no key minted: {minted}")).to_owned(); + let key = + minted["key"].as_str().unwrap_or_else(|| panic!("no key minted: {minted}")).to_owned(); // Within the run the key is accepted: the relay answers that no hosted // upstream is signed in (503), never that the key is unknown (401). assert_eq!(relay_status(port, &key), 503); From 1580bdb6c24105525aa80b1753fca54ef97a61a0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:00:50 -0700 Subject: [PATCH 099/183] cmux-coderouter: the Keychain probe runs before the admin socket exists and gives up after 3 s (cx-f4thx) A fleet mini (cmux-ci 489fbd01a326) showed admin requests with no answer within 5 s: the start-time probe ran after the admin socket was bound, so a client could connect while the router still waited on the Keychain. The store is now chosen before the socket exists, and the probe runs on its own thread for at most 3 s (a lookup can block on an access prompt no one answers); then the run uses the random in-memory secret, with the warning. --- cmux-tui/crates/cmux-coderouter/src/lib.rs | 40 ++++++++++++++++------ 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/cmux-tui/crates/cmux-coderouter/src/lib.rs b/cmux-tui/crates/cmux-coderouter/src/lib.rs index 7d0cc3583974..a7fe67fcec3a 100644 --- a/cmux-tui/crates/cmux-coderouter/src/lib.rs +++ b/cmux-tui/crates/cmux-coderouter/src/lib.rs @@ -510,6 +510,16 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { let lock = tokio::time::timeout(std::time::Duration::from_secs(30), lock_rx) .await .map_err(|_| anyhow::anyhow!("another local router holds router.lock"))???; + // Keys live in memory for one router run. macOS keeps the install secret in + // the Keychain; elsewhere a fresh random secret per run is equivalent. + // Chosen before the admin socket exists: a client that reaches the socket + // gets an answer. + #[cfg(target_os = "macos")] + let (store, kind) = macos_install_secret_store()?; + #[cfg(not(target_os = "macos"))] + let (store, kind): (Arc, &'static str) = + (Arc::new(RandomInstallSecretStore::new("default")?), INSTALL_SECRET_EPHEMERAL); + let _ = INSTALL_SECRET_KIND.set(kind); let socket_path = router_dir.join("router.sock"); let _ = tokio::fs::remove_file(&socket_path).await; let admin = UnixListener::bind(&socket_path)?; @@ -520,14 +530,6 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { } let bind_address: SocketAddr = "127.0.0.1:0".parse()?; let listener = TcpListener::bind(LoopbackAddr::try_from(bind_address)?.address()).await?; - // Keys live in memory for one router run. macOS keeps the install secret in - // the Keychain; elsewhere a fresh random secret per run is equivalent. - #[cfg(target_os = "macos")] - let (store, kind) = macos_install_secret_store()?; - #[cfg(not(target_os = "macos"))] - let (store, kind): (Arc, &'static str) = - (Arc::new(RandomInstallSecretStore::new("default")?), INSTALL_SECRET_EPHEMERAL); - let _ = INSTALL_SECRET_KIND.set(kind); let keys = Arc::new(tokio::sync::RwLock::new(KeyRing::new(store)?)); let port = listener.local_addr()?.port(); let upstream: SharedUpstream = Arc::new(tokio::sync::RwLock::new(None)); @@ -557,10 +559,10 @@ fn macos_install_secret_store() -> anyhow::Result<(Arc, let loaded = if std::env::var_os("CMUX_CODEROUTER_TEST_NO_KEYCHAIN").is_some() { Err(anyhow::anyhow!("no default keychain (CMUX_CODEROUTER_TEST_NO_KEYCHAIN)")) } else { - keychain.load().map(drop) + probe_keychain() }; #[cfg(not(debug_assertions))] - let loaded = keychain.load().map(drop); + let loaded = probe_keychain(); match loaded { Ok(()) => Ok((Arc::new(keychain), INSTALL_SECRET_KEYCHAIN)), Err(e) => { @@ -574,6 +576,24 @@ fn macos_install_secret_store() -> anyhow::Result<(Arc, } } +/// Ask the Keychain once, on its own thread and for at most +/// `KEYCHAIN_PROBE_LIMIT`: a lookup can block (an access prompt with no one to +/// answer it), and the router must still start. +#[cfg(target_os = "macos")] +fn probe_keychain() -> anyhow::Result<()> { + let (sent, answer) = std::sync::mpsc::channel(); + std::thread::Builder::new().name("keychain-probe".into()).spawn(move || { + let loaded = KeychainInstallSecretStore::new("default").load().map(drop); + let _ = sent.send(loaded.map_err(|e| format!("{e:#}"))); + })?; + match answer.recv_timeout(KEYCHAIN_PROBE_LIMIT) { + Ok(result) => result.map_err(|e| anyhow::anyhow!(e)), + Err(_) => anyhow::bail!("the Keychain did not answer within {KEYCHAIN_PROBE_LIMIT:?}"), + } +} +#[cfg(target_os = "macos")] +const KEYCHAIN_PROBE_LIMIT: std::time::Duration = std::time::Duration::from_secs(3); + /// Start the real loopback data plane for integration tests and composition. pub async fn spawn_data_plane( keys: Arc>, From da517d59d413ac990b35e03ac42df9f10eb1162c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:41:13 -0700 Subject: [PATCH 100/183] cmux-tui: server compact-registry rewrites a session registry offline with incremental auto-vacuum (cx-ossub) Takes the owner's session guard and writer lease, so it refuses (server.compact_owner_running, exit 3) while an owner holds the session and no write can land between the copy and the swap. VACUUM INTO a staged file with auto_vacuum=INCREMENTAL, quick_check, fsync, remove the registry's checkpointed WAL/SHM, rename over the registry, fsync the directory. On any error the staged file is removed and the registry is unchanged. --- cmux-tui/crates/cmux-tui-core/src/lib.rs | 6 +- .../cmux-tui-core/src/workspace_registry.rs | 2 + .../src/workspace_registry/compact.rs | 228 ++++++++++++++++++ cmux-tui/crates/cmux-tui/src/cli/command.rs | 13 +- cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs | 73 ++++++ .../cmux-tui/src/localization/local_server.rs | 15 ++ .../tests/legacy_registry_maintenance.rs | 21 +- 7 files changed, 346 insertions(+), 12 deletions(-) create mode 100644 cmux-tui/crates/cmux-tui-core/src/workspace_registry/compact.rs diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 63e06ecf0501..4c56a455fa43 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -179,9 +179,9 @@ pub use workspace_registry::{ JournalIngress, JournalProducer, JournalProducerManifest, JournalReplayPolicy, JournalSegment, JournalSensitivity, JournalSubject, PersistentSessionStateReset, PersistentSessionStateResetPreview, PersistentSessionStateResetter, ProjectionCommit, - RegistryCommit, RegistryEvent, RegistryQuarantined, RegistrySnapshot, RegistryWorkspace, - SessionJournalPage, SessionJournalRecord, UnsupportedWorkspaceRegistrySchema, - WorkspaceMutation, WorkspaceRegistry, + RegistryCommit, RegistryCompactError, RegistryCompaction, RegistryEvent, RegistryQuarantined, + RegistrySnapshot, RegistryWorkspace, SessionJournalPage, SessionJournalRecord, + UnsupportedWorkspaceRegistrySchema, WorkspaceMutation, WorkspaceRegistry, compact_registry, }; pub use cmux_remote_protocol::{REMOTE_CLIENT_MESSAGE_MAX_BYTES, REMOTE_SESSION_MESSAGE_MAX_BYTES}; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs index 5769c322851f..6061f4c83d1f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs @@ -32,11 +32,13 @@ use crate::terminal_host_runtime::TerminalHostLiveness; pub use open_guard::RegistryQuarantined; use open_guard::preflight_unsupported_schema; +mod compact; mod detached_terminals; pub(crate) use detached_terminals::DETACHED_TERMINAL_WORKSPACE_KEY; use detached_terminals::require_live_workspace; mod effect_store; pub(crate) mod feed_local_store; +pub use compact::{RegistryCompactError, RegistryCompaction, compact_registry}; mod idle_policy_store; mod journal_extensions; mod mutation_ledger; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/compact.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/compact.rs new file mode 100644 index 000000000000..e7270ce2ec4e --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/compact.rs @@ -0,0 +1,228 @@ +//! Offline compaction of a session's workspace registry (cx-ossub). +//! +//! A registry written before journal maintenance (auto_vacuum=NONE) keeps +//! every free page: sealing reuses them, but the file never shrinks, and a +//! legacy registry with more live data than the in-place bound is never +//! converted (a full VACUUM under the registry stalled its owner for minutes, +//! cx-4bd8m). `cmux-tui server compact-registry` rewrites it while NO owner +//! runs: it takes the same session guard and writer lease an owner takes, so +//! it refuses while an owner holds the session, and no write can happen +//! between the copy and the swap. +//! +//! The copy is `VACUUM INTO` a staged file beside the registry, with +//! incremental auto-vacuum set, so the next owner marks the registry migrated +//! and gives free pages back from then on. The staged file is checked +//! (`quick_check`), synced and renamed over the registry. The registry's WAL +//! is checkpointed into it (the source connection closes before the rename) +//! and its sidecars are removed first, so an old WAL is never applied to the +//! new file. On any error the staged file is removed and the registry is left +//! as it was. + +use super::open_guard::{refuse_orphaned_journal, sidecar_path}; +use super::*; + +/// What [`compact_registry`] did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryCompaction { + /// The registry file that was rewritten. + pub path: PathBuf, + /// Registry file plus WAL bytes before the rewrite. + pub before_bytes: u64, + /// Registry file bytes after the rewrite. + pub after_bytes: u64, + /// Set when the new registry is installed but its directory could not be + /// synced (the swap may not survive a power loss yet). + pub warning: Option, +} + +/// Why [`compact_registry`] did not rewrite the registry. +#[derive(Debug)] +pub enum RegistryCompactError { + /// The session has no registry at this path. + NoRegistry(PathBuf), + /// Another process (a running owner) holds the session. + OwnerRunning(anyhow::Error), + /// The rewrite failed; the registry is unchanged. + Failed(anyhow::Error), +} + +impl std::fmt::Display for RegistryCompactError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoRegistry(path) => write!(f, "no workspace registry at {}", path.display()), + Self::OwnerRunning(error) => { + write!(f, "the session is held by a running owner: {error:#}") + } + Self::Failed(error) => write!(f, "{error:#}"), + } + } +} + +impl std::error::Error for RegistryCompactError {} + +/// Rewrites the registry of `session_name` under the state root `root` into +/// a compact file with incremental auto-vacuum. Refuses while an owner runs. +pub fn compact_registry( + root: &Path, + session_name: &str, +) -> Result { + let root = platform::normalize_filesystem_path(root.to_path_buf()); + let session_dir = + platform::normalize_filesystem_path(root.join(session_storage_component(session_name))); + let db_path = platform::normalize_filesystem_path(session_dir.join(WORKSPACE_REGISTRY_FILE)); + if !db_path.is_file() { + return Err(RegistryCompactError::NoRegistry(db_path)); + } + if let Some(error) = preflight_unsupported_schema(&db_path) { + return Err(RegistryCompactError::Failed(error.into())); + } + // The owner's own locks, held until the swap is durable. + let _session_guard = + acquire_existing_session_guard(&root, session_name).map_err(classify_lock_error)?; + let session_lock = + platform::normalize_filesystem_path(session_dir.join(SESSION_WRITER_LOCK_FILE)); + let _lease = SessionLease::acquire(&session_lock).map_err(classify_lock_error)?; + // Checked again under the locks: the file may have gone meanwhile, and a + // leftover journal of a missing file must not be applied to a new one. + if !db_path.is_file() { + return Err(RegistryCompactError::NoRegistry(db_path)); + } + refuse_orphaned_journal(&db_path).map_err(RegistryCompactError::Failed)?; + compact_locked(&db_path).map_err(RegistryCompactError::Failed) +} + +/// A lock another process holds means a running owner; anything else is a +/// failure to report as is. +fn classify_lock_error(error: anyhow::Error) -> RegistryCompactError { + let held = error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|error| matches!(error, fs4::TryLockError::WouldBlock)) + }); + if held { + RegistryCompactError::OwnerRunning(error) + } else { + RegistryCompactError::Failed(error) + } +} + +fn compact_locked(db_path: &Path) -> anyhow::Result { + let wal = sidecar_path(db_path, "-wal"); + let shm = sidecar_path(db_path, "-shm"); + let before_bytes = file_len(db_path) + file_len(&wal); + let staged = staged_path(db_path)?; + remove_staged(&staged)?; + if let Err(error) = write_staged(db_path, &staged) { + let _ = remove_staged(&staged); + return Err(error); + } + // The source connection is closed: its WAL was checkpointed into the + // registry. A WAL that still holds frames would be applied to the new + // file by the next open, so refuse instead. + if file_len(&wal) > 0 { + let _ = remove_staged(&staged); + anyhow::bail!("the registry WAL still holds data after the copy: {}", wal.display()); + } + let parent = db_path.parent().context("registry path has no directory")?; + // The removal of the old sidecars is durable before the rename: an old + // WAL that came back after a crash would be applied to the new file. + let installed = (|| -> anyhow::Result<()> { + remove_if_present(&wal)?; + remove_if_present(&shm)?; + platform::sync_directory(parent) + .with_context(|| format!("sync registry directory {}", parent.display()))?; + fs::rename(&staged, db_path) + .with_context(|| format!("install compacted registry {}", db_path.display())) + })(); + if let Err(error) = installed { + let _ = remove_staged(&staged); + return Err(error); + } + // The registry is replaced from here on: a failed sync is a warning, not + // an "unchanged" failure. + let warning = platform::sync_directory(parent) + .err() + .map(|error| format!("sync registry directory {}: {error}", parent.display())); + Ok(RegistryCompaction { + path: db_path.to_path_buf(), + before_bytes, + after_bytes: file_len(db_path), + warning, + }) +} + +/// Copies the registry into `staged` with incremental auto-vacuum and checks +/// the copy. Both connections are closed on return. +fn write_staged(db_path: &Path, staged: &Path) -> anyhow::Result<()> { + { + // Never create a database: the registry exists (checked under the locks). + let source = open_registry_database_with_flags( + db_path, + OpenFlags::default().difference(OpenFlags::SQLITE_OPEN_CREATE), + ) + .with_context(|| format!("open workspace registry {}", db_path.display()))?; + source.busy_timeout(std::time::Duration::from_secs(5))?; + // On an existing database this only records the mode; SQLite applies + // it to the VACUUM INTO output. + source.execute_batch("PRAGMA auto_vacuum=INCREMENTAL;")?; + let staged_sqlite = sqlite_filesystem_path(staged)?; + let staged_text = staged_sqlite.to_str().context("compacted registry path is not UTF-8")?; + source + .execute("VACUUM INTO ?1", [staged_text]) + .with_context(|| format!("copy workspace registry {}", db_path.display()))?; + } + { + let copy = open_registry_database(staged) + .with_context(|| format!("open compacted registry {}", staged.display()))?; + let auto_vacuum = copy.query_row("PRAGMA auto_vacuum", [], |row| row.get::<_, i64>(0))?; + if auto_vacuum != 2 { + // This SQLite did not carry the mode into VACUUM INTO: convert + // the copy (a separate file; nothing waits on it). + copy.execute_batch("PRAGMA auto_vacuum=INCREMENTAL; VACUUM;")?; + } + let check = copy.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0))?; + anyhow::ensure!(check == "ok", "compacted registry failed quick_check: {check}"); + let auto_vacuum = copy.query_row("PRAGMA auto_vacuum", [], |row| row.get::<_, i64>(0))?; + anyhow::ensure!(auto_vacuum == 2, "compacted registry is not incremental auto-vacuum"); + } + // The copy's own sidecars, if its connection made any, are empty after + // the close; a non-empty WAL would be lost by the rename. + anyhow::ensure!( + file_len(&sidecar_path(staged, "-wal")) == 0, + "the compacted registry WAL still holds data" + ); + remove_if_present(&sidecar_path(staged, "-wal"))?; + remove_if_present(&sidecar_path(staged, "-shm"))?; + platform::restrict_file(staged)?; + // Windows FlushFileBuffers needs write access. + OpenOptions::new().write(true).open(staged)?.sync_all()?; + Ok(()) +} + +fn staged_path(db_path: &Path) -> anyhow::Result { + let name = db_path.file_name().context("registry path has no file name")?; + let mut staged = std::ffi::OsString::from("."); + staged.push(name); + staged.push(".compact"); + Ok(db_path.with_file_name(staged)) +} + +fn remove_staged(staged: &Path) -> anyhow::Result<()> { + remove_if_present(staged)?; + for suffix in ["-wal", "-shm", "-journal"] { + remove_if_present(&sidecar_path(staged, suffix))?; + } + Ok(()) +} + +fn remove_if_present(path: &Path) -> anyhow::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("remove {}", path.display())), + } +} + +fn file_len(path: &Path) -> u64 { + fs::metadata(path).map(|metadata| metadata.len()).unwrap_or(0) +} diff --git a/cmux-tui/crates/cmux-tui/src/cli/command.rs b/cmux-tui/crates/cmux-tui/src/cli/command.rs index 7801a273fd21..b1d80d01365c 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/command.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/command.rs @@ -198,6 +198,9 @@ fn parse_server(words: &[String], flags: &mut Flags) -> Result super::lifecycle::ServerAction::ReloadConfig, + ["compact-registry"] => super::lifecycle::ServerAction::CompactRegistry { + state_dir: flags.take("state-dir").map(PathBuf::from), + }, ["start"] => { return Err(UsageError::new( crate::localization::catalog().local_server.start_options_after_action, @@ -209,7 +212,15 @@ fn parse_server(words: &[String], flags: &mut Flags) -> Result, + }, } pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { @@ -70,6 +77,9 @@ pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { ); } let expected_session = global.session.clone(); + if let ServerAction::CompactRegistry { state_dir } = plan.action { + return run_compact_registry(expected_session, state_dir, global.output); + } let (socket, socket_is_derived) = match super::wire::resolve_socket_with_origin(&global) { Ok(resolved) => resolved, Err(error) => { @@ -227,6 +237,9 @@ pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { match plan.action { ServerAction::Ensure { .. } => unreachable!("ensure returns before the lifecycle exchange"), + ServerAction::CompactRegistry { .. } => { + unreachable!("compact-registry returns before the lifecycle exchange") + } ServerAction::Status => print_success( json!({ "status":"running", @@ -704,6 +717,66 @@ fn usage_error(message: &str, output: OutputMode) -> i32 { local_error("usage.invalid", message, output, 2) } +/// `server compact-registry --session [--state-dir ]`: offline, +/// with the owner's session locks, so it needs no socket. The session is +/// never guessed: a wrong default would compact another session than the one +/// the user stopped. Exit 3 with `server.compact_owner_running` while an +/// owner holds the session. +fn run_compact_registry( + expected_session: Option, + state_dir: Option, + output: OutputMode, +) -> i32 { + let messages = &crate::localization::catalog().local_server; + let Some(session) = expected_session else { + return local_error("usage.invalid", messages.registry_compact_needs_session, output, 2); + }; + let Some(root) = state_dir.or_else(cmux_tui_core::platform::workspace_state_dir) else { + return local_error_with_details( + "server.compact_no_registry", + messages.registry_compact_no_registry, + json!({"session": session, "reason": "no_state_dir"}), + output, + 3, + ); + }; + match cmux_tui_core::compact_registry(&root, &session) { + Ok(compaction) => print_success( + json!({ + "status": "compacted", + "session": session, + "path": compaction.path.to_string_lossy(), + "before_bytes": compaction.before_bytes, + "after_bytes": compaction.after_bytes, + "warning": compaction.warning, + "message": messages.registry_compacted, + }), + output, + ), + Err(cmux_tui_core::RegistryCompactError::NoRegistry(path)) => local_error_with_details( + "server.compact_no_registry", + messages.registry_compact_no_registry, + json!({"session": session, "path": path.to_string_lossy()}), + output, + 3, + ), + Err(cmux_tui_core::RegistryCompactError::OwnerRunning(error)) => local_error_with_details( + "server.compact_owner_running", + messages.registry_compact_owner_running, + json!({"session": session, "reason": format!("{error:#}")}), + output, + 3, + ), + Err(cmux_tui_core::RegistryCompactError::Failed(error)) => local_error_with_details( + "server.compact_failed", + messages.registry_compact_failed, + json!({"session": session, "reason": format!("{error:#}")}), + output, + 1, + ), + } +} + fn local_error(code: &str, message: &str, output: OutputMode, exit_code: i32) -> i32 { local_error_with_details(code, message, json!({}), output, exit_code) } diff --git a/cmux-tui/crates/cmux-tui/src/localization/local_server.rs b/cmux-tui/crates/cmux-tui/src/localization/local_server.rs index a6e01fbfec50..56f30dfe5ea3 100644 --- a/cmux-tui/crates/cmux-tui/src/localization/local_server.rs +++ b/cmux-tui/crates/cmux-tui/src/localization/local_server.rs @@ -31,6 +31,11 @@ pub(crate) struct LocalServerMessages { pub owner_restoring: &'static str, pub replaced: &'static str, pub replace_refused: &'static str, + pub registry_compacted: &'static str, + pub registry_compact_owner_running: &'static str, + pub registry_compact_no_registry: &'static str, + pub registry_compact_failed: &'static str, + pub registry_compact_needs_session: &'static str, pub reloaded: &'static str, pub connect_failed: &'static str, pub starting: &'static str, @@ -117,6 +122,11 @@ pub(super) const ENGLISH: LocalServerMessages = LocalServerMessages { owner_restoring: "cmux: the session owner is restoring its terminals; waiting for it (up to 2 minutes)", replaced: "the unresponsive session owner was stopped and a new local server started", replace_refused: "cannot replace the unresponsive session owner; inspect the client log under the session state directory and retry", + registry_compacted: "the session registry was compacted", + registry_compact_owner_running: "the session is running; stop it (cmux-tui server stop) and retry the compaction", + registry_compact_no_registry: "the session has no saved registry to compact", + registry_compact_failed: "cannot compact the session registry; it was left unchanged", + registry_compact_needs_session: "server compact-registry needs --session (the stopped session to compact)", reloaded: "local server configuration reloaded", connect_failed: "cannot connect to the local server; check that the named session is running and retry", starting: "the local server is still starting; retry the lifecycle command", @@ -180,6 +190,11 @@ pub(super) const JAPANESE: LocalServerMessages = LocalServerMessages { owner_restoring: "cmux: セッション所有者がターミナルを復元しています。完了を待っています (最大 2 分)", replaced: "応答しないセッション所有者を停止し、新しいローカルサーバーを起動しました", replace_refused: "応答しないセッション所有者を置き換えられません。セッション状態ディレクトリのクライアントログを確認して再試行してください", + registry_compacted: "セッションレジストリを圧縮しました", + registry_compact_owner_running: "セッションが実行中です。停止してから (cmux-tui server stop) 圧縮を再試行してください", + registry_compact_no_registry: "圧縮する保存済みレジストリがセッションにありません", + registry_compact_failed: "セッションレジストリを圧縮できません。レジストリは変更されていません", + registry_compact_needs_session: "server compact-registry には --session <名前> (圧縮する停止済みセッション) が必要です", reloaded: "ローカルサーバーの設定を再読み込みしました", connect_failed: "ローカルサーバーに接続できません。名前付きセッションが実行中であることを確認して再試行してください", starting: "ローカルサーバーは起動中です。ライフサイクルコマンドを再試行してください", diff --git a/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs b/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs index ae67fb4a0f5d..735efdb80039 100644 --- a/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs +++ b/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs @@ -250,15 +250,15 @@ fn compact_registry_is_refused_while_the_owner_runs_and_shrinks_it_offline() { let dir = state_dir("compact"); let session = "lrm-compact"; let registry = first_start(&dir.0, session); - make_legacy(®istry, 2 * TEST_BOUND_BYTES); + // Live data stays above the in-place bound (the owner must not convert + // it itself), and as much again is freed: a legacy file keeps those pages. + let kept_chunks = 2 * TEST_BOUND_BYTES / (1024 * 1024); + make_legacy(®istry, 4 * TEST_BOUND_BYTES); { - // Free most of the filler: a legacy file keeps those pages. let db = Connection::open(®istry).unwrap(); - db.execute("DELETE FROM legacy_filler WHERE rowid > 4", []).unwrap(); + db.execute("DELETE FROM legacy_filler WHERE rowid > ?1", [kept_chunks as i64]).unwrap(); db.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").unwrap(); } - let before_len = fs::metadata(®istry).unwrap().len(); - let before_rows = table_rows(®istry); let owner = Owner::start(&dir.0, session); let (ok, text) = compact(&dir.0, session); @@ -266,7 +266,12 @@ fn compact_registry_is_refused_while_the_owner_runs_and_shrinks_it_offline() { assert!(text.contains("server.compact_owner_running"), "refusal code: {text}"); assert!(owner.answers(), "the owner keeps serving after a refused compaction"); owner.stop(); + assert_eq!(conversion(®istry), (0, false), "the owner left the legacy registry as it was"); let stopped_rows = table_rows(®istry); + let before_len = fs::metadata(®istry).unwrap().len(); + + let output = command(&dir.0).args(["--json", "server", "compact-registry"]).output().unwrap(); + assert!(!output.status.success(), "compaction without --session is refused"); let (ok, text) = compact(&dir.0, session); assert!(ok, "compaction with the owner stopped: {text}"); @@ -278,9 +283,9 @@ fn compact_registry_is_refused_while_the_owner_runs_and_shrinks_it_offline() { ); assert_eq!(table_rows(®istry), stopped_rows, "every table keeps its rows"); assert_eq!( - before_rows.iter().find(|(table, _)| table == "legacy_filler"), - Some(&("legacy_filler".to_string(), 4)), - "the fixture kept 4 filler rows" + stopped_rows.iter().find(|(table, _)| table == "legacy_filler"), + Some(&("legacy_filler".to_string(), kept_chunks as i64)), + "the fixture kept its filler rows" ); { let db = Connection::open_with_flags(®istry, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) From 7bfbc22b493ea64bf5f609ddcce62da10bf7e90a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:52:45 -0700 Subject: [PATCH 101/183] cmux-next motion: frame-strip progress and proof docs move out of CmuxNextApp (MotionFrameProgress in CmuxNextDesign) The stack added 87 lines to CmuxNextApp and the tip grew meanwhile, so the App was 18 over its cap (113768 > 113750). The frame strip's progress/reversal/jump measure and its report fields move into CmuxNextDesign's DEBUG MotionFrameProgress beside MotionTravelSummary (same keys and values; moving == intermediate as before), the proof docs go with it, and debug.motion's trajectory note moves into MotionDebug. CmuxNextApp: 113745 (cap 113750). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxNextApp/Control/DebugFrameStrip.swift | 34 +++----------- .../CmuxNextApp/Control/DebugMotion.swift | 3 -- .../CmuxNextDesign/Motion/MotionDebug.swift | 5 ++- .../Motion/MotionFrameProgress.swift | 44 +++++++++++++++++++ 4 files changed, 55 insertions(+), 31 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFrameProgress.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift index 4566288fd26e..1b4105d2cab2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift @@ -24,15 +24,7 @@ import CmuxNextWakeups /// `content_intermediate` counts frames equal to neither. The pane's strip (its tabs slide) is left /// out of those and of `intermediate`; `strip_intermediate` counts its frames equal to neither, and /// `desync` those where one of strip and content shows the new tab and the other the old one. -/// -/// Motion proofs (scripts/cmux-next/motion-proof.py, hq-1d): `gesture: "plus"` presses the -/// focused strip's + (its intent) instead of a key, `gesture: "drag_new"` drags the strip's first -/// tab while a CLI new tab lands. Outside the content and the strip, each frame's `progress` -/// (0 = before, 1 = final) never goes back (`reversals`) or leaps (`jumps` over `jump`, default -/// 0.6; `max_step`), and `moving` counts the frames between before and final. `content: "none"` -/// (splits) takes no content area. Tab motion is judged from the strip's own `tabs.travel` -/// samples while `debug.motion` records (`tabs_moving_frames`, `tabs_restarts`: a second motion -/// in one gesture, `tabs_peak_travel`): a sliding cell is not monotonic in pixels. +/// Motion proof knobs and fields: ``MotionFrameProgress``. @MainActor enum DebugFrameStrip { typealias Frame = WindowFrame @@ -78,8 +70,7 @@ enum DebugFrameStrip { var rows: [JSONValue] = [] var intermediate = 0, blank = 0, contentIntermediate = 0, desync = 0, stripIntermediate = 0 var settledAt: Double?, firstChange: Double? - var moving = 0, reversals = 0, jumps = 0, previous: Double?, maxStep = 0.0, peak = 0.0 - let jump = params["jump"]?.doubleValue ?? 0.6 + var progress = MotionFrameProgress(jump: params["jump"]?.doubleValue) let shown = frames.firstIndex { $0.milliseconds >= before.milliseconds } ?? frames.endIndex // Off the main actor: six comparisons a frame stalled it past the watchdog in a Debug build. let measures = await WindowFrame.measure(Array(frames[shown...]), before: before, last: last, content: content, strip: strip) @@ -94,16 +85,8 @@ enum DebugFrameStrip { // Neither the content before the key nor the settled one; blank: flat too (an empty terminal is flat). let isBlank = content != nil && m.flatness > 0.995 && insideBefore > tolerance && insideFinal > tolerance let isContentIntermediate = frame.milliseconds > sentAt && insideBefore > tolerance && insideFinal > tolerance - if isIntermediate { intermediate += 1; moving += 1 } - // Progress outside the content and the strip from before (0) to final (1). - let total = m.outsideBefore + m.outsideFinal - let progress = total > 0 ? m.outsideBefore / total : (m.outsideBefore > 0 ? 1 : 0) - if let previous { - maxStep = max(maxStep, progress - previous) - if progress - previous > jump, previous < 0.05 { jumps += 1 } - if progress < peak - 0.05 { reversals += 1 } - } - (peak, previous) = (max(peak, progress), progress) + if isIntermediate { intermediate += 1 } + progress.add(outsideBefore: m.outsideBefore, outsideFinal: m.outsideFinal) if isBlank { blank += 1 } if isContentIntermediate { contentIntermediate += 1 } if frame.milliseconds > sentAt { @@ -121,11 +104,8 @@ enum DebugFrameStrip { ])) } WindowFrame.writeStrip(frames.filter { $0.milliseconds >= before.milliseconds }, to: (dir as NSString).appendingPathComponent("strip.png")) - let tabs = MotionTravelSummary("tabs.travel", after: sentTime) - return .object([ - "moving": .number(Double(moving)), "reversals": .number(Double(reversals)), "jumps": .number(Double(jumps)), - "max_step": .number((maxStep * 1000).rounded() / 1000), "tabs_moving_frames": .number(Double(tabs.movingFrames)), - "tabs_restarts": .number(Double(tabs.restarts)), "tabs_peak_travel": .number(tabs.peak.rounded()), + let motion = progress.fields(moving: intermediate, tabs: MotionTravelSummary("tabs.travel", after: sentTime)).mapValues(JSONValue.number) + return .object(motion.merging([ "key": sent, "dir": .string(dir), "frames": .array(rows), "intermediate": .number(Double(intermediate)), "blank": .number(Double(blank)), "frame_count": .number(Double(rows.count)), "content_intermediate": .number(Double(contentIntermediate)), "desync": .number(Double(desync)), @@ -133,7 +113,7 @@ enum DebugFrameStrip { "settled_ms": settledAt.map { .number($0.rounded()) } ?? .null, "first_change_ms": firstChange.map { .number($0.rounded()) } ?? .null, "spans": .array(spans.map { .object(["name": .string($0.name), "ms": .number(($0.start - sentAt).rounded()), "duration_ms": .number($0.milliseconds)]) }), "mean_interval_ms": .number(frames.count > 1 ? (last.milliseconds - first.milliseconds) / Double(frames.count - 1) : 0), - ]) + ]) { first, _ in first }) } /// `gesture: "drag_new"`: drags the focused strip's first tab right in 12 steps, one per frame diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift index c43b1b15a9fb..c1d9cafe92da 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift @@ -5,9 +5,6 @@ import CmuxNextSidebar import CmuxNextTabs /// `debug.motion`: the wiring of `MotionDebug` (CmuxNextDesign), which documents its actions. -/// `trajectory` returns this port's motion parity corpus (the resolved chrome springs and -/// their frame paths), the same document cmux-motion's `motion_trajectories` example prints; -/// scripts/cmux-next/motion-parity.py compares the two. enum DebugMotion { static func handle(_ params: [String: JSONValue], _ services: AppServices? = nil) -> JSONValue { if params["action"]?.stringValue == "trajectory" { return MotionParityCorpus().document } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionDebug.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionDebug.swift index 8bdf91eff7d3..6b6b1578dedf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionDebug.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionDebug.swift @@ -15,7 +15,10 @@ import CmuxNextWakeups /// The report lists the display links alive (`display_links`: 0 once every spring rests, so /// the window idles). Spans carry `at_ms` (from `start`); `samples` lists per-frame values /// (`clock.dt_ms`, and `tabs.travel`, `layout.travel`, `sidebar.open`: the points an -/// animator has left to move); `notes` lists what an animator decided. `trace_start`, `trace_read` and `trace_stop` sample +/// animator has left to move); `notes` lists what an animator decided. `trajectory` (answered +/// by the app's wiring) returns the port's motion parity corpus (`MotionParityCorpus`), the +/// document cmux-motion's `motion_trajectories` example prints; scripts/cmux-next/motion-parity.py +/// compares the two. `trace_start`, `trace_read` and `trace_stop` sample /// animated geometry on every display frame instead (DEBUG builds, ``MotionFrameTrace``). /// Takes and returns `JSONSerialization` object graphs. public struct MotionDebug { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFrameProgress.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFrameProgress.swift new file mode 100644 index 000000000000..0f17a5cbc051 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFrameProgress.swift @@ -0,0 +1,44 @@ +#if DEBUG +/// How a `debug.new_tab {action: "frame_strip"}` recording moved outside the content and the +/// tab strip (motion proofs, scripts/cmux-next/motion-proof.py, hq-1d). Each frame's `progress` +/// (0 = before, 1 = final, from its pixel shares) never goes back (`reversals`) or leaps (`jumps` +/// over `jump`, default 0.6; `maxStep`). The proof's other knobs: `gesture: "plus"` presses the +/// focused strip's + (its intent) instead of a key, `gesture: "drag_new"` drags the strip's first +/// tab while a CLI new tab lands, `content: "none"` (splits) takes no content area. Tab motion is +/// judged from the strip's own `tabs.travel` samples while `debug.motion` records +/// (``MotionTravelSummary``): a sliding cell is not monotonic in pixels. +public nonisolated struct MotionFrameProgress: Sendable, Equatable { + public var reversals = 0 + public var jumps = 0 + public var maxStep = 0.0 + private var previous: Double? + private var peak = 0.0 + private let jump: Double + + public init(jump: Double?) { + self.jump = jump ?? 0.6 + } + + /// One frame, from its pixel shares outside the content and the strip that match the frame + /// before the gesture and the final frame. + public mutating func add(outsideBefore: Double, outsideFinal: Double) { + let total = outsideBefore + outsideFinal + let progress = total > 0 ? outsideBefore / total : (outsideBefore > 0 ? 1 : 0) + if let previous { + maxStep = max(maxStep, progress - previous) + if progress - previous > jump, previous < 0.05 { jumps += 1 } + if progress < peak - 0.05 { reversals += 1 } + } + (peak, previous) = (max(peak, progress), progress) + } + + /// The report fields: `moving` (frames between before and final), `reversals`, `jumps`, + /// `max_step`, and the strip's `tabs_moving_frames`, `tabs_restarts` (a second motion in one + /// gesture) and `tabs_peak_travel`. + public func fields(moving: Int, tabs: MotionTravelSummary) -> [String: Double] { + ["moving": Double(moving), "reversals": Double(reversals), "jumps": Double(jumps), + "max_step": (maxStep * 1000).rounded() / 1000, "tabs_moving_frames": Double(tabs.movingFrames), + "tabs_restarts": Double(tabs.restarts), "tabs_peak_travel": tabs.peak.rounded()] + } +} +#endif From f998e9d8c03d69856305d04aa0412636861173b6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:53:50 -0700 Subject: [PATCH 102/183] cmux-tui-hooks: pass the session-flags state key by value (clippy needless_borrows, main sync cx-q1lnl) Co-Authored-By: Claude Opus 5.5 (1M context) --- cmux-tui/crates/cmux-tui-hooks/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui-hooks/src/lib.rs b/cmux-tui/crates/cmux-tui-hooks/src/lib.rs index 30409edf2a32..3a34682f277f 100644 --- a/cmux-tui/crates/cmux-tui-hooks/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-hooks/src/lib.rs @@ -2098,7 +2098,7 @@ fn codex_session_hook_settings_with_context( )); let mut entry = toml_edit::InlineTable::new(); entry.insert("trusted_hash", hash.into()); - state.insert(&format!("//config.toml:{label}:0:0"), entry.into()); + state.insert(format!("//config.toml:{label}:0:0"), entry.into()); } settings.push(format!("hooks.state={state}")); Ok(settings) From 780e4c1fa558024fe8f2a811ffdda01f90344584 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:58:30 -0700 Subject: [PATCH 103/183] chief.profile.adopt: clippy (one write struct for mutate, no redundant clone) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/server/chief_adopt_tests.rs | 2 +- .../src/server/chief_registry_resource.rs | 25 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs index 94aee89a8b4f..c0677a19e7ee 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs @@ -246,7 +246,7 @@ fn only_the_owners_own_unix_connection_adopts_an_agent() { ] { assert!(command_result(conn, mint.clone()).is_err(), "{who} minted a Chief credential"); } - let fresh = command(&mux, &owner, mint.clone())["token"].as_str().unwrap().to_owned(); + let fresh = command(&mux, &owner, mint)["token"].as_str().unwrap().to_owned(); let chief = connect(&mux, ClientTransport::Unix); let bound = command( &mux, diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs index d4bc33968a26..bc99f3658940 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs @@ -136,7 +136,13 @@ fn answer(mux: &Mux, client: u64, request: &ParsedResourceRequest) -> Result { let key = request.envelope.idempotency_key.clone().unwrap_or_default(); - mutate(mux, client, store, &caller, operation, fields, key, adoptee.as_ref()) + mutate( + mux, + client, + store, + &caller, + RegistryWrite { operation, fields, key, adoptee: adoptee.as_ref() }, + ) } } }) @@ -155,7 +161,7 @@ pub(super) fn set_chief_avatar( let operation = Op::ChiefProfileAvatarSet; with_store(mux, operation, |store| { let caller = caller(mux, client, store)?; - mutate(mux, client, store, &caller, operation, fields, key, None) + mutate(mux, client, store, &caller, RegistryWrite { operation, fields, key, adoptee: None }) }) } @@ -206,16 +212,23 @@ pub(super) fn set_person_avatar( }) } +/// One registry write: the operation, its fields, its idempotency key and, +/// for `chief.profile.adopt`, the participant it names. +struct RegistryWrite<'a> { + operation: Op, + fields: &'a Map, + key: String, + adoptee: Option<&'a Adoptee>, +} + fn mutate( mux: &Mux, client: u64, store: &mut ChiefStore, caller: &Caller, - operation: Op, - fields: &Map, - key: String, - adoptee: Option<&Adoptee>, + write: RegistryWrite<'_>, ) -> Result { + let RegistryWrite { operation, fields, key, adoptee } = write; // Owner only, from a terminal, before anything else is checked: no page // (a WebSocket or the host's page relay, with or without a page id), no // Chief, no paired or remote device (caller() refuses those). From 8a22cccddcdc3eac38cbb5e921fb6d26d0db087c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:00:18 -0700 Subject: [PATCH 104/183] labs.classicHome: schema and MDM exports with the restart help text (fleet export-settings) (cx-ebm.59.5) --- docs/mdm/com.manaflow.cmux.json | 2 +- docs/mdm/com.manaflow.cmux.plist | 2 +- docs/mdm/managed-preferences.md | 2 +- schemas/settings/settings-schema.json | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index 512717cdc5bb..446e609c99a9 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -1463,7 +1463,7 @@ }, "labs.classicHome" : { "default" : false, - "description" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next.", + "description" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts.", "title" : "Classic Home", "type" : "boolean" }, diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index f2b2a96caeec..e4c9a7ee692f 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -4476,7 +4476,7 @@ pfm_default pfm_description - Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next. + Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts. pfm_name labs.classicHome pfm_title diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index 1517affe20b0..0ec3a2281ab4 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -316,7 +316,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `notifications.attention.moveToTop` | boolean | `true` | | Move the Workspace to the Top. A notification moves its workspace to the top of the unpinned rows, unless you are looking at its pane. | | `notifications.mutedWorkspaces` | array | `[]` | | Muted Workspaces. Workspace ids whose notifications are silent. Mute a workspace from its sidebar row menu. | | `labs.previewFeatures` | boolean | `false` | | Show Preview Features. Unfinished surfaces, such as the agent session's coverage label and Pull requests view. | -| `labs.classicHome` | boolean | `false` | | Classic Home. Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next. | +| `labs.classicHome` | boolean | `false` | | Classic Home. Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts. | | `feed.github.enabled` | boolean | `false` | | Connect GitHub. Uses your gh login to read notifications and review requests on this Mac. Sign in with gh auth login first. | | `feed.github.pollIntervalSeconds` | real | `120` | 60 to 900 | Refresh Interval. Seconds between GitHub refreshes. Refresh in the Inbox runs immediately. | | `agentPane.links.outsideRoots` | string | `"open"` | `confirm`, `text`, `open` | Files Outside the Project. What a file link in a reply does when the file is outside the chat's folders. Keys and .env files never open. | diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index 372329f3d426..709201b02abc 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -22178,7 +22178,7 @@ }, "help" : { "key" : "settings.labs.classicHome.help", - "text" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect when Home opens next." + "text" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts." }, "kept_on_reset_all" : false, "key" : "labs.classicHome", @@ -23611,7 +23611,7 @@ "validation" : "portable" } ], - "schema_hash" : "587efecfb08f75a4172a225a35e7a8b95af869702c61cefb7d5541329024bd80", + "schema_hash" : "eee369bec6eaabcf7fa65ef80149fa808e3da7bb6bda49db8f0c72c507d555e2", "sections" : [ { "id" : "general", From bb480f1f11e15bdc84d86a9cfb5cf8279cf74405 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:25:46 -0700 Subject: [PATCH 105/183] acpmux: ACPMUX_AGENT_LAUNCH=deny / CMUX_AGENTS_DISABLED=1 starts no agent (cx-9ettq) GUI test jobs run tagged apps with real credentials. With either variable in the daemon environment, acpmux refuses at its two spawn points (ChildAgent::spawn_with, agent_host::link::spawn_within) with the ACP error "agent launch is disabled in this build session" (code -32010, data.reason agent_launch_disabled, retryable false). ACP and live model probes, pool prewarm, CLI sign-in checks and auto-updates skip. _acpmux/status reports agentLaunch deny|allow. The app passes the variables through AcpmuxEnvironment.childEnvironment to the daemons and Chief host it starts; HomeBrainHost does not start (canStart false, resolve nil) while launches are off. Behavior test at the daemon socket: tests/agent_launch_deny.rs. --- .../CmuxNextAgentPane/AcpmuxEnvironment.swift | 17 ++ .../CmuxNextHomeEngine/HomeBrainHost.swift | 3 + cmux-tui/crates/acpmux/src/agent.rs | 2 + cmux-tui/crates/acpmux/src/agent_host/link.rs | 2 + .../crates/acpmux/src/harness_health/auto.rs | 4 + .../crates/acpmux/src/harness_health/mod.rs | 5 + .../crates/acpmux/src/harness_health/probe.rs | 5 + cmux-tui/crates/acpmux/src/hub/hosts.rs | 4 +- cmux-tui/crates/acpmux/src/hub/lifecycle.rs | 13 +- cmux-tui/crates/acpmux/src/hub/live_models.rs | 6 + cmux-tui/crates/acpmux/src/hub/pool/mod.rs | 5 + cmux-tui/crates/acpmux/src/hub/views.rs | 2 + cmux-tui/crates/acpmux/src/launch_policy.rs | 75 +++++++ cmux-tui/crates/acpmux/src/lib.rs | 1 + .../crates/acpmux/tests/agent_launch_deny.rs | 212 ++++++++++++++++++ 15 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/launch_policy.rs create mode 100644 cmux-tui/crates/acpmux/tests/agent_launch_deny.rs diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment.swift index 1ed8b16d0b89..55fce3554032 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment.swift @@ -33,6 +33,20 @@ public nonisolated struct AcpmuxEnvironment: Sendable, Equatable { public static let computerUseKeys = ["CMUX_NEXT_CUA_SOCKET", "CMUX_NEXT_CUA_SOCKET_AUTH_TOKEN"] public static let computerUseHostKey = "CMUX_NEXT_CUA_SOCKET_HOST_AUTH_TOKEN" + /// The build-session kill switch (cx-9ettq): with either set, acpmux + /// starts no agent and the Chief host does not start. A GUI test job sets + /// one on the app; `childEnvironment` passes it to every acpmux daemon, + /// router and Chief host the app starts. + public static let agentLaunchKeys = ["ACPMUX_AGENT_LAUNCH", "CMUX_AGENTS_DISABLED"] + + /// Whether `environment` turns agent launches off (`ACPMUX_AGENT_LAUNCH=deny` + /// or `CMUX_AGENTS_DISABLED=1`, the same test as acpmux's launch_policy.rs). + public static func agentLaunchDisabled(_ environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + let launch = environment["ACPMUX_AGENT_LAUNCH"]?.trimmingCharacters(in: .whitespaces).lowercased() + let disabled = environment["CMUX_AGENTS_DISABLED"]?.trimmingCharacters(in: .whitespaces) + return launch == "deny" || disabled == "1" + } + public var logPath: String { home.appendingPathComponent("daemon.log").path } /// Resolves the environment of `build` (nil: no build, the shared home), @@ -90,6 +104,9 @@ public nonisolated struct AcpmuxEnvironment: Sendable, Equatable { var childEnvironment = ["ACPMUX_HOME": home.path, "ACPMUX_SOCKET": socket, "ACPMUX_LOCAL_ROUTER": "1", "ACPMUX_SECRET_TOOLS": "1"] childEnvironment.merge(launchEnvironment) { _, new in new } + for key in agentLaunchKeys { + if let value = environment[key], !value.isEmpty { childEnvironment[key] = value } + } if let cli = bundledBinDirectory?.appendingPathComponent("cmux").path, isExecutable(cli) { childEnvironment["CMUX_BUNDLED_CLI_PATH"] = cli } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeBrainHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeBrainHost.swift index b4be5b990965..1aca1f99179c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeBrainHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeBrainHost.swift @@ -46,6 +46,8 @@ public nonisolated struct HomeBrainHost: Sendable { bundledBinDirectory: URL? = Bundle.main.resourceURL?.appendingPathComponent("bin", isDirectory: true), bundledChiefAllowed: Bool = HomeBrainHost.bundledChiefAllowed(bundleID: Bundle.main.bundleIdentifier, isDebugBuild: DevTools.isDebugBuild)) -> Bool { + // The build-session kill switch: no Chief host, so no brain starts (cx-9ettq). + if AcpmuxEnvironment.agentLaunchDisabled(environment) { return false } let override = environment["CMUX_NEXT_MUX_HOST"].flatMap { $0.isEmpty ? nil : $0 } let bundled = bundledChiefAllowed ? bundledBinDirectory?.appendingPathComponent(bundledChiefName).path : nil return [override, bundled].compactMap { $0 }.contains { FileManager.default.isExecutableFile(atPath: $0) } @@ -56,6 +58,7 @@ public nonisolated struct HomeBrainHost: Sendable { bundledBinDirectory: URL? = Bundle.main.resourceURL?.appendingPathComponent("bin", isDirectory: true), bundledChiefAllowed: Bool = HomeBrainHost.bundledChiefAllowed(bundleID: Bundle.main.bundleIdentifier, isDebugBuild: DevTools.isDebugBuild)) -> HomeBrainHost? { + if AcpmuxEnvironment.agentLaunchDisabled(environment) { return nil } let override = environment["CMUX_NEXT_MUX_HOST"].flatMap { $0.isEmpty ? nil : $0 } let bundled = bundledChiefAllowed ? bundledBinDirectory?.appendingPathComponent(bundledChiefName).path : nil guard let path = [override, bundled].compactMap({ $0 }).first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else { diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index 0a23cc980e50..ea7effe5a912 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -350,6 +350,8 @@ impl ChildAgent { // it can drive its own session and siblings through the CLI. session: Option<(&str, &str)>, ) -> Result> { + // The build-session kill switch: no agent process at all (cx-9ettq). + crate::launch_policy::refuse_if_disabled()?; let codex = (translator.is_none() && command_line.is_none() && crate::codex_app_server::is_profile_launch(&profile.argv)) diff --git a/cmux-tui/crates/acpmux/src/agent_host/link.rs b/cmux-tui/crates/acpmux/src/agent_host/link.rs index d15f3dbd7456..d810633f069d 100644 --- a/cmux-tui/crates/acpmux/src/agent_host/link.rs +++ b/cmux-tui/crates/acpmux/src/agent_host/link.rs @@ -36,6 +36,8 @@ pub async fn spawn_within( spec: &SpawnSpec, budget: std::time::Duration, ) -> Result { + // The build-session kill switch: no host and no harness (cx-9ettq). + crate::launch_policy::refuse_if_disabled()?; let mut cmd = tokio::process::Command::new(&launcher.exe); cmd.args(&launcher.prefix) .arg(HOST_ARG) diff --git a/cmux-tui/crates/acpmux/src/harness_health/auto.rs b/cmux-tui/crates/acpmux/src/harness_health/auto.rs index 9874257e2d1e..2d3613ce67a5 100644 --- a/cmux-tui/crates/acpmux/src/harness_health/auto.rs +++ b/cmux-tui/crates/acpmux/src/harness_health/auto.rs @@ -104,6 +104,10 @@ impl HealthService { /// (one at a time, each command once, and not again for the same versions), /// and returns the ids it ran for. Callers wait for a run already going. pub async fn run_auto_updates(&self) -> Vec { + // A build session with agent launches off changes no installed CLI (cx-9ettq). + if crate::launch_policy::agent_launch_disabled() { + return Vec::new(); + } let _one = self.updating.lock().await; let mut done: BTreeMap, AutoUpdateResult> = BTreeMap::new(); let mut ran = Vec::new(); diff --git a/cmux-tui/crates/acpmux/src/harness_health/mod.rs b/cmux-tui/crates/acpmux/src/harness_health/mod.rs index eea7fd9ac77d..a2bed4003c82 100644 --- a/cmux-tui/crates/acpmux/src/harness_health/mod.rs +++ b/cmux-tui/crates/acpmux/src/harness_health/mod.rs @@ -142,6 +142,11 @@ fn builtin_login(family: &str) -> Option<&'static str> { /// (`claude_auth`) only ever prove a sign-in, so none found stays unknown, /// never a guess from a missing file. fn builtin_auth(s: &Subject) -> Option<(Auth, Option)> { + // No credential read (Keychain, `codex login status`) while agent + // launches are off (cx-9ettq): the sign-in stays unknown. + if crate::launch_policy::agent_launch_disabled() { + return None; + } let program = Path::new(&s.program).file_name()?.to_string_lossy().to_lowercase(); let home_named = |key: &str| !s.unresolved.iter().any(|k| k == key); match s.family.as_str() { diff --git a/cmux-tui/crates/acpmux/src/harness_health/probe.rs b/cmux-tui/crates/acpmux/src/harness_health/probe.rs index 364cdacab417..0ddd1cfb05d3 100644 --- a/cmux-tui/crates/acpmux/src/harness_health/probe.rs +++ b/cmux-tui/crates/acpmux/src/harness_health/probe.rs @@ -30,6 +30,11 @@ pub(super) fn run_for( limit: Duration, family: Option<&str>, ) -> Result<(bool, String), String> { + // A sign-in check (`family` set) reads the user's credentials: none while + // agent launches are off (cx-9ettq). `--version` checks still run. + if family.is_some() && crate::launch_policy::agent_launch_disabled() { + return Err(crate::launch_policy::DISABLED_MESSAGE.to_owned()); + } let (program, args) = argv.split_first().ok_or("no command")?; let mut cmd = Command::new(program); crate::login_env::apply_std_for(&mut cmd, family, Some(env)); diff --git a/cmux-tui/crates/acpmux/src/hub/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index b679938a3e40..9e052eb81d61 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -726,7 +726,9 @@ impl Hub { translator: Option, tap: crate::agent::Tap, ) -> Result, RpcError> { - let internal = |e: anyhow::Error| RpcError::internal(format!("{e:#}")); + let internal = |e: anyhow::Error| { + crate::launch_policy::spawn_error(e, |e| RpcError::internal(format!("{e:#}"))) + }; // From the profile, before its CODEX_PATH picks the binary. let codex_bridge = command_line.is_none() && translator.is_none() diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index b7deb8122f49..18582e82a5ad 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -516,7 +516,9 @@ impl Hub { Some((&session.id, &meta.name)), ) .await - .map_err(|e| RpcError::internal(e.to_string()))? + .map_err(|e| { + crate::launch_policy::spawn_error(e, |e| RpcError::internal(e.to_string())) + })? } } else if self.agent_hosts_enabled() { self.spawn_hosted_child(session, profile, &meta, None, None, tap).await? @@ -532,7 +534,9 @@ impl Hub { Some((&session.id, &meta.name)), ) .await - .map_err(|e| RpcError::internal(e.to_string()))? + .map_err(|e| { + crate::launch_policy::spawn_error(e, |e| RpcError::internal(e.to_string())) + })? }; { // The shutdown marks itself started, then takes each session's @@ -817,6 +821,11 @@ impl Hub { wait: bool, only: Option>, ) { + // A probe starts the agent: none while launches are off (cx-9ettq). + if crate::launch_policy::agent_launch_disabled() { + tracing::info!("agent launch is disabled; model probes skipped"); + return; + } let picked = |n: &str| only.as_ref().is_none_or(|o| o.contains(n)); let agents: Vec<(String, HarnessProfile)> = { let cfg = self.config.read().await; diff --git a/cmux-tui/crates/acpmux/src/hub/live_models.rs b/cmux-tui/crates/acpmux/src/hub/live_models.rs index ffa88ec6e22d..046250acedf3 100644 --- a/cmux-tui/crates/acpmux/src/hub/live_models.rs +++ b/cmux-tui/crates/acpmux/src/hub/live_models.rs @@ -71,6 +71,12 @@ impl Hub { wait: bool, only: Option>, ) { + // A live list starts the CLI with its credentials: none while + // launches are off (cx-9ettq). + if crate::launch_policy::agent_launch_disabled() { + tracing::info!("agent launch is disabled; live model probes skipped"); + return; + } let handles: Vec<_> = { let cfg = self.config.read().await; targets(&cfg.harnesses) diff --git a/cmux-tui/crates/acpmux/src/hub/pool/mod.rs b/cmux-tui/crates/acpmux/src/hub/pool/mod.rs index ba0f1f4c2c3e..6f4882603fc2 100644 --- a/cmux-tui/crates/acpmux/src/hub/pool/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/pool/mod.rs @@ -312,6 +312,11 @@ impl Hub { "a remote-origin connection never uses the session pool", )); } + if crate::launch_policy::agent_launch_disabled() { + return Ok( + json!({"accepted": false, "reason": crate::launch_policy::DISABLED_MESSAGE}), + ); + } if !self.pool_enabled().await { return Ok(json!({"accepted": false, "reason": "the session pool is off"})); } diff --git a/cmux-tui/crates/acpmux/src/hub/views.rs b/cmux-tui/crates/acpmux/src/hub/views.rs index 6e92bf882782..a414ae53afff 100644 --- a/cmux-tui/crates/acpmux/src/hub/views.rs +++ b/cmux-tui/crates/acpmux/src/hub/views.rs @@ -260,6 +260,8 @@ impl Hub { "store": cfg.store, "sessions": sessions.len(), "liveAgents": live, + // `deny`: this daemon starts no agent process (cx-9ettq). + "agentLaunch": crate::launch_policy::status_word(), // Hidden pre-created sessions; never counted above. "pool": self.pool_view_json(), // New agents outlive this daemon (agent hosts): a restart for an diff --git a/cmux-tui/crates/acpmux/src/launch_policy.rs b/cmux-tui/crates/acpmux/src/launch_policy.rs new file mode 100644 index 000000000000..13ae582b9e72 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/launch_policy.rs @@ -0,0 +1,75 @@ +//! The build-session kill switch for agent launches (cx-9ettq). +//! +//! GUI test jobs run tagged apps with the user's real credentials. With +//! `ACPMUX_AGENT_LAUNCH=deny` (or `CMUX_AGENTS_DISABLED=1`) in the daemon's +//! environment, acpmux starts no agent process at all: the spawn points +//! (`ChildAgent::spawn_with`, `agent_host::link::spawn_within`) refuse, model +//! probes and pool prewarms skip, and `session/new` fails with +//! [`rpc_error`], which says why and is not retryable. +//! +//! The variable is read at each check, never cached, so the daemon reads the +//! value it was started with (the app passes its own environment on). + +use crate::rpc::RpcError; + +/// The variable that names the policy; `deny` turns launches off. +pub const LAUNCH_ENV: &str = "ACPMUX_AGENT_LAUNCH"; +/// The app-wide switch; `1` turns launches off too. +pub const DISABLED_ENV: &str = "CMUX_AGENTS_DISABLED"; +/// What a refused launch says, everywhere. +pub const DISABLED_MESSAGE: &str = "agent launch is disabled in this build session"; +/// `data.reason` of the refusal, for clients that branch on it. +pub const DISABLED_REASON: &str = "agent_launch_disabled"; +/// JSON-RPC code of the refusal (implementation-defined server error range). +pub const DISABLED_CODE: i64 = -32010; + +/// A spawn refused by the kill switch; callers map it with [`spawn_error`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentLaunchDisabled; + +impl std::fmt::Display for AgentLaunchDisabled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(DISABLED_MESSAGE) + } +} + +impl std::error::Error for AgentLaunchDisabled {} + +/// Whether this process must not start agents. +pub fn agent_launch_disabled() -> bool { + disabled_by( + std::env::var(LAUNCH_ENV).ok().as_deref(), + std::env::var(DISABLED_ENV).ok().as_deref(), + ) +} + +fn disabled_by(launch: Option<&str>, disabled: Option<&str>) -> bool { + launch.is_some_and(|v| v.trim().eq_ignore_ascii_case("deny")) + || disabled.is_some_and(|v| v.trim() == "1") +} + +/// `Err(AgentLaunchDisabled)` when launches are off; called before any spawn. +pub fn refuse_if_disabled() -> anyhow::Result<()> { + if agent_launch_disabled() { Err(AgentLaunchDisabled.into()) } else { Ok(()) } +} + +/// The ACP error a refused launch returns to the client. +pub fn rpc_error() -> RpcError { + RpcError::new(DISABLED_CODE, DISABLED_MESSAGE) + .with_data(serde_json::json!({"reason": DISABLED_REASON, "retryable": false})) +} + +/// Maps a spawn failure: the kill switch keeps its own error, any other +/// failure goes through `other`. +pub fn spawn_error(e: anyhow::Error, other: impl FnOnce(anyhow::Error) -> RpcError) -> RpcError { + if e.chain().any(|c| c.downcast_ref::().is_some()) { + rpc_error() + } else { + other(e) + } +} + +/// `"deny"` or `"allow"`, for `_acpmux/status`. +pub fn status_word() -> &'static str { + if agent_launch_disabled() { "deny" } else { "allow" } +} diff --git a/cmux-tui/crates/acpmux/src/lib.rs b/cmux-tui/crates/acpmux/src/lib.rs index 3116c2a9c7b9..ebcd82557d23 100644 --- a/cmux-tui/crates/acpmux/src/lib.rs +++ b/cmux-tui/crates/acpmux/src/lib.rs @@ -36,6 +36,7 @@ pub mod harness_config; pub mod harness_health; pub mod harness_limits; pub mod hub; +pub mod launch_policy; pub mod live_models; pub mod local_stream; pub mod login_env; diff --git a/cmux-tui/crates/acpmux/tests/agent_launch_deny.rs b/cmux-tui/crates/acpmux/tests/agent_launch_deny.rs new file mode 100644 index 000000000000..71b3ee1c6a8f --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/agent_launch_deny.rs @@ -0,0 +1,212 @@ +//! The build-session kill switch (cx-9ettq), through the real daemon binary +//! and its unix socket: with `ACPMUX_AGENT_LAUNCH=deny` or +//! `CMUX_AGENTS_DISABLED=1`, `session/new` fails with the launch-disabled +//! error, model probes and prewarms start nothing, and no harness process +//! ever runs (the harness command writes a marker file before anything else, +//! and the daemon's own records show no session, live agent or agent host). +#![cfg(unix)] + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + +struct Rpc { + lines: tokio::io::Lines>, + wr: tokio::net::unix::OwnedWriteHalf, + next: i64, +} + +impl Rpc { + async fn connect(path: &Path) -> Self { + let s = tokio::net::UnixStream::connect(path).await.expect("connect to daemon socket"); + let (rd, wr) = s.into_split(); + Self { lines: tokio::io::BufReader::new(rd).lines(), wr, next: 0 } + } + + /// The whole response: `result` or `error`. + async fn send(&mut self, method: &str, params: Value) -> Value { + self.next += 1; + let id = self.next; + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + self.wr.write_all(format!("{line}\n").as_bytes()).await.unwrap(); + loop { + let line = tokio::time::timeout(Duration::from_secs(90), self.lines.next_line()) + .await + .expect("daemon answered in time") + .unwrap() + .expect("daemon closed the socket"); + let v: Value = serde_json::from_str(&line).unwrap(); + if v.get("id") == Some(&json!(id)) { + return v; + } + } + } + + async fn call(&mut self, method: &str, params: Value) -> Value { + let v = self.send(method, params).await; + assert!(v.get("error").is_none(), "{method} failed: {v}"); + v["result"].clone() + } +} + +fn scratch(tag: &str) -> PathBuf { + // Short: the socket path must stay under the macOS limit. + let dir = std::env::temp_dir().join(format!("amx-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +struct Daemon { + child: std::process::Child, + dir: PathBuf, + socket: PathBuf, + marker: PathBuf, +} + +impl Daemon { + /// The daemon with `switch` set (none: launches allowed), once ready. + /// `memory`: no store on disk, so no agent hosts (they outlive the daemon). + async fn start(tag: &str, switch: Option<(&str, &str)>, memory: bool) -> Self { + let dir = scratch(tag); + let marker = dir.join("harness-started"); + let fake = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); + // Any start of the harness (a session or a model probe) leaves the marker. + let argv = json!([ + "/bin/sh", + "-c", + "echo started >>\"$1\"; shift; exec python3 \"$@\"", + "sh", + marker, + fake + ]); + std::fs::write( + dir.join("config.json"), + json!({"harnesses": {"fake": {"argv": argv}}, "defaultHarness": "fake", "permissionPolicy": "approve-all"}).to_string(), + ) + .unwrap(); + let socket = dir.join("s.sock"); + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")); + cmd.args(["daemon", "run", "--ready-fd", "1", "--log", "warn"]); + if memory { + cmd.arg("--memory"); + } + cmd.env("ACPMUX_HOME", &dir) + .env("ACPMUX_SOCKET", &socket) + .env("ACPMUX_LOGIN_ENV", "0") + // No network fetches from a test daemon. + .env("ACPMUX_REGISTRY_FETCH", "0") + .env("ACPMUX_HEALTH_FETCH", "0") + // Only the fake harness: never the developer's own claude or codex + // from PATH or ~/.acpmux, with their credentials. + .env("ACPMUX_PROBE_HARNESSES", "fake") + .env("HOME", &dir) + .env_remove("ACPMUX_AGENT_LAUNCH") + .env_remove("CMUX_AGENTS_DISABLED"); + if let Some((key, value)) = switch { + cmd.env(key, value); + } + let mut child = cmd + .env_remove("XPC_SERVICE_NAME") + .env_remove("CLAUDECODE") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let ready = tokio::task::spawn_blocking(move || { + for line in BufReader::new(stdout).lines() { + let line = line.unwrap(); + if serde_json::from_str::(&line).is_ok_and(|v| v["ready"] == true) { + return; + } + } + panic!("daemon exited without a readiness line"); + }); + tokio::time::timeout(Duration::from_secs(20), ready).await.unwrap().unwrap(); + Self { child, dir, socket, marker } + } + + async fn stop(mut self) { + unsafe { + libc::kill(self.child.id() as i32, libc::SIGTERM); + } + let stop = Instant::now(); + while self.child.try_wait().unwrap().is_none() { + assert!(stop.elapsed() < Duration::from_secs(8), "daemon still running after SIGTERM"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Starts the daemon with `switch` set and a store on disk (agent hosts on, +/// as in the app), runs every check, and stops it. +async fn denied_daemon_starts_no_agent(tag: &str, switch: (&str, &str)) { + let daemon = Daemon::start(tag, Some(switch), false).await; + let (dir, marker) = (&daemon.dir, &daemon.marker); + let mut rpc = Rpc::connect(&daemon.socket).await; + assert_eq!(rpc.call("_acpmux/status", json!({})).await["agentLaunch"], "deny"); + + // session/new: the launch-disabled error, not retryable. + let reply = rpc + .send( + "session/new", + json!({"cwd": dir, "mcpServers": [], "_meta": {"acpmux": {"name": "denied"}}}), + ) + .await; + let error = &reply["error"]; + assert_eq!(error["message"], "agent launch is disabled in this build session", "{reply}"); + assert_eq!(error["code"], -32010, "{reply}"); + assert_eq!(error["data"]["reason"], "agent_launch_disabled", "{reply}"); + assert_eq!(error["data"]["retryable"], false, "{reply}"); + + // A refreshed model list waits for every probe; a probe would start the harness. + rpc.call("_acpmux/models", json!({"refresh": true})).await; + // A prewarm hint is refused, never queued. + let prewarm = + rpc.call("_acpmux/prewarm", json!({"harness": "fake", "cwd": dir, "wait": true})).await; + assert_eq!(prewarm["accepted"], false, "{prewarm}"); + assert_eq!(prewarm["reason"], "agent launch is disabled in this build session", "{prewarm}"); + + // The daemon's own records: no session, no live agent, no agent host. + let status = rpc.call("_acpmux/status", json!({})).await; + assert_eq!(status["sessions"], 0, "{status}"); + assert_eq!(status["liveAgents"], 0, "{status}"); + let hosts: Vec<_> = std::fs::read_dir(dir.join("hosts")) + .map(|d| d.filter_map(Result::ok).map(|e| e.file_name()).collect()) + .unwrap_or_default(); + assert!(hosts.is_empty(), "agent host records: {hosts:?}"); + // And the harness command never ran at all. + assert!(!marker.exists(), "the harness started: {:?}", std::fs::read_to_string(marker)); + daemon.stop().await; +} + +/// The control: the same daemon and harness without the switch starts the +/// harness (the marker appears), so the checks above can fail. +#[tokio::test] +async fn without_the_switch_the_harness_starts() { + let daemon = Daemon::start("allow", None, true).await; + let mut rpc = Rpc::connect(&daemon.socket).await; + assert_eq!(rpc.call("_acpmux/status", json!({})).await["agentLaunch"], "allow"); + rpc.call( + "session/new", + json!({"cwd": daemon.dir, "mcpServers": [], "_meta": {"acpmux": {"name": "allowed"}}}), + ) + .await; + assert!(daemon.marker.exists(), "the harness never started"); + daemon.stop().await; +} + +#[tokio::test] +async fn agent_launch_deny_starts_no_agent() { + denied_daemon_starts_no_agent("deny", ("ACPMUX_AGENT_LAUNCH", "deny")).await; +} + +#[tokio::test] +async fn cmux_agents_disabled_starts_no_agent() { + denied_daemon_starts_no_agent("off", ("CMUX_AGENTS_DISABLED", "1")).await; +} From 603cbd7117610304082faf3a64e779553ccfb25c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:10:31 -0700 Subject: [PATCH 106/183] cmux-next: holder wire protocol v1 spec (cx-t2sqm.12) plans/cmux-next/holder-protocol-v1.md: the frozen protocol every later daemon speaks to every v1 holder: spawn/bootstrap contract, transport and peer checks, framing, 17 kinds with payloads and error codes, handshake, controller replacement, output log (never blocks the child, drops oldest at the cap, GAP), input/control, exit records with no timer, survival rules (service-manager stop per OS), capabilities, identity record and registry paths per OS, token store, lease-based liveness with no process reads, and the 20 exchanges of the compatibility corpus that lands with the cmux-holder crate (cx-t2sqm.15). A review subagent found 7 P1 gaps (spawn contract, pipes offsets, log rules, exit timer, record/lease order, macOS /tmp cleaner, same-uid security); all are fixed in this text. D6 (macOS peer code identity) is recorded as pending. Co-Authored-By: Claude Opus 5.5 (1M context) --- plans/cmux-next/holder-protocol-v1.md | 249 ++++++++++++++++++++++++++ plans/cmux-next/pty-holder.md | 13 +- 2 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 plans/cmux-next/holder-protocol-v1.md diff --git a/plans/cmux-next/holder-protocol-v1.md b/plans/cmux-next/holder-protocol-v1.md new file mode 100644 index 000000000000..7b7d0f370542 --- /dev/null +++ b/plans/cmux-next/holder-protocol-v1.md @@ -0,0 +1,249 @@ +# Holder wire protocol v1 (frozen) + +Epic cx-t2sqm, bead cx-t2sqm.12. Design: `plans/cmux-next/pty-holder.md` +section 2. Status: spec. The recorded compatibility corpus and its runner +land with the `cmux-holder` crate (cx-t2sqm.15); this file is their source. + +A holder is a small process that owns one PTY (`--pty`) or one agent +harness's pipes (`--pipes`) and an output log. Daemons of every later +version speak this protocol to every v1 holder, forever. A holder is never +updated. + +## 1. Spawn + +The daemon execs `cmux-holder --pty|--pipes --bootstrap-fd 3` with an empty +environment. Fd 3 is a pipe that carries one bootstrap struct (same framing +as section 3, kind 0 BOOTSTRAP, daemon to holder only): +`token raw[32], registry_dir string, socket_dir string, install string, +session string, argv string-list, env string-list ("K=V"), cwd string, +cols u16, rows u16, px_w u16, px_h u16, ring_bytes u32, log_bytes u64, +orphan_grace_secs u32 (0 = none)`. + +The holder then, in this order: +1. calls `setsid()` and leaves the daemon's cgroup / launchd job / Job Object + (a stop of the daemon's unit must not stop it); on Linux calls + `prctl(PR_SET_DUMPABLE, 0)`; +2. takes the exclusive lease (section 8); +3. binds its socket; +4. spawns the child (pty: on a new PTY of the given size, as session leader + with the PTY as controlling terminal; pipes: three pipes, own process + group), with exactly `env` and `cwd`; +5. atomically renames its identity record into place (section 7); +6. writes one byte `0x01` to fd 3 and closes it. If step 4 fails it writes + `0x02`, then `errno i32`, writes no record and exits. + +## 2. Transport + +- One Unix domain stream socket per holder (Windows: one named pipe), path in + the holder's identity record (section 7). Socket mode 0600, directory 0700. +- Before reading a frame the holder checks the peer uid + (`getpeereid` / `SO_PEERCRED`; Windows: the pipe client's user SID) equals + its own. A different uid: close without a reply. +- At most 8 links that have not sent HELLO; each must send HELLO within 5 s + or is closed. A frame other than HELLO before HELLO, or a second HELLO on + one link, closes that link. +- At most one controller at a time (section 5.2); observers (HELLO with the + observer flag) may read WELCOME and close. + +## 3. Framing + +Every frame, both directions: + +| Offset | Size | Field | Notes | +| --- | --- | --- | --- | +| 0 | 4 | magic | ASCII `CMHL` | +| 4 | 2 | kind | u16 little endian (section 4) | +| 6 | 2 | flags | u16 LE; senders write 0 except the bits defined in section 4; receivers ignore unknown bits | +| 8 | 4 | len | u32 LE payload length, at most 1 MiB (1 048 576) | +| 12 | len | payload | kind-specific, little endian, no padding | + +A bad magic or a `len` above 1 MiB closes the link (the stream is lost). An +unknown `kind` from the daemon gets ERROR `UNKNOWN_KIND` and the link stays +up. A daemon ignores an unknown kind from a holder. Strings and byte strings +never exceed the frame; a holder truncates argv/env in WELCOME's record to +fit (the full record stays on disk). + +Strings are `u32 len + UTF-8 bytes`; byte strings are `u32 len + bytes`; +`u64` offsets count output bytes from the start of the holder's life. + +## 4. Kinds + +| Kind | Value | Dir | Payload | +| --- | --- | --- | --- | +| HELLO | 1 | d->h | `min u16, max u16, caps_wanted u64, token raw[32]`; flags bit 0 = observer (never becomes controller) | +| WELCOME | 2 | h->d | `version u16, caps_granted u64, state u8 (0 running, 1 exited), oldest u64, end u64, cols u16, rows u16, identity bytes` (the identity record, section 7) | +| ATTACH | 3 | d->h | `from_offset u64` (above `end`: ERROR `BAD_PAYLOAD`) | +| DATA | 4 | h->d | `offset u64, bytes` (at most 1 MiB - 16); flags bit 0 = stderr (pipes mode) | +| GAP | 5 | h->d | `requested u64, oldest u64` | +| DETACH | 6 | d->h | empty | +| WRITE | 7 | d->h | `bytes` (at most 1 MiB - 4) | +| RESIZE | 8 | d->h | `cols u16, rows u16, px_w u16, px_h u16`; 0 cols or rows: `BAD_PAYLOAD`; pipes mode: `NOT_SUPPORTED` | +| SIGNAL | 9 | d->h | `signal i32, target u8` (0 group, 1 leader) | +| ACK | 10 | d->h | `offset u64` | +| EXIT | 11 | h->d | `status_kind u8` (0 exited, 1 signaled), `code i64` (Unix exit status or signal; Windows u32 exit code), `end_offset u64, at_ms u64` | +| CLOSE | 12 | d->h | `grace_ms u32` | +| PING | 13 | both | `nonce u64` | +| PONG | 14 | both | `nonce u64` | +| ERROR | 15 | h->d | `request_kind u16, code u16, message string` | +| STATS | 16 | both | d->h empty; h->d `cpu_user_us u64, cpu_sys_us u64, max_rss_bytes u64` of the child's group as the kernel reports it to the holder without a scan (getrusage of reaped children, Linux cgroup, Windows job accounting); 0 when unknown | + +Error codes: 1 `UNKNOWN_KIND`, 2 `BAD_TOKEN`, 3 `NO_COMMON_VERSION`, +4 `NOT_CONTROLLER`, 5 `NOT_SUPPORTED`, 6 `BAD_PAYLOAD`, 7 `EXITED`. + +## 5. Session + +### 5.1 Handshake + +1. The daemon connects and sends HELLO first. The holder answers nothing + before HELLO. +2. Token mismatch (constant-time compare): ERROR `BAD_TOKEN`, then close. + (Only ERROR `BAD_TOKEN` and `NO_COMMON_VERSION` close the link; every + other ERROR keeps it.) +3. No common version in `[min, max]` and `[1, 1]`: ERROR + `NO_COMMON_VERSION` with the holder's range in the message, then close. +4. Otherwise WELCOME with `version = 1`, `caps_granted = caps_wanted & + holder_caps`. v1 holders grant 0. Bits are assigned in this file only and + never reused. + +### 5.2 Controller + +- The WELCOMEd non-observer link is the controller. A new valid HELLO on + another link replaces it: the old link gets ERROR `NOT_CONTROLLER` and is + closed; frames still queued on the old link are dropped. A daemon that + restarts therefore always wins. A daemon that receives `NOT_CONTROLLER` + does not reconnect by itself (another daemon owns the holder). +- Only the controller may send WRITE, RESIZE, SIGNAL, ACK and CLOSE. + +### 5.3 Output + +- The holder always reads the PTY (or both pipes) and appends to its log, + with or without a controller. It never blocks the child on the socket or + on ACK. The log is an in-memory ring (`ring_bytes`, default 8 MiB) + spilling to `.log` beside its record (`log_bytes`, default 64 MiB); + at the cap the oldest bytes are dropped and `oldest` moves up. ACK only + allows earlier trimming (bytes below an acked offset may go first). +- ATTACH `from`: if `from < oldest`, GAP `{from, oldest}` then DATA from + `oldest`; else DATA from `from`. Then live DATA in order until DETACH, a + new controller or the end. A controller that falls behind `oldest` while + streaming gets GAP in the stream, then DATA from the new `oldest`. +- pipes mode: stdout and stderr share one offset space in arrival order; + DATA flags bit 0 marks stderr bytes. + +### 5.4 Input and control + +- WRITE bytes go to the PTY master (pty) or the harness stdin (pipes) in + order. A full PTY blocks the holder's writer, not its reader. +- SIGNAL target 0: `killpg(child_pgid, signal)`; 1: `kill(child_pid, + signal)`. The holder never signals any other process. +- CLOSE at t0: SIGHUP to the group (pty) or close stdin (pipes), and + SIGTERM to the group; at t0 + `grace_ms`, SIGKILL to the group. CLOSE + after the child exited acts as the final ACK (section 5.5). + +### 5.5 Exit + +When the child leader is reaped, the holder reads the PTY master until EIO +(pty) or both pipes until EOF or 2 s (pipes); the last byte read is +`end_offset`. It writes `.exit` (atomic, fsync, 0600, JSON: +`{"schema":1,"id":...,"status_kind":...,"code":...,"end_offset":...,"at_ms":...}`) +and sends EXIT to the controller, if any. After every ATTACH to an exited +holder, EXIT follows the last DATA. The holder stays (no timer) until an ACK +of `end_offset` or a CLOSE, then removes its socket, record and log and +exits; with `orphan_grace_secs` set it also leaves after that long with no +controller. The daemon deletes `.exit` after it has recorded the exit. + +### 5.6 Survival rules + +- The holder ignores SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1/2 and SIGPIPE, + except a service-manager stop, which acts like CLOSE with grace 2 s: on + macOS SIGTERM from PID 1 (launchd); on Linux SIGTERM from PID 1 or from + the `systemd --user` manager of the holder's own uid, identified by the + sender pid named in `$MANAGERPID` at spawn (no process read); on Windows + `CTRL_SHUTDOWN_EVENT` / `WM_ENDSESSION`. In a container whose PID 1 is not + a service manager, the daemon spawns with `--no-pid1-stop`. +- No orphan timer in normal installs. A DEV build may pass + `--orphan-grace-secs N`, written in the identity record. +- The holder never reads another process's state and never lists processes. + +## 6. Capabilities + +v1 defines no capability bits. Rules for later bits: +- A daemon must work with `caps_granted = 0` forever. +- A bit adds frames or payload fields only on links that granted it. +- Payload growth: new fields only at the end of a payload, only under a + granted bit; a v1 reader never sees them. + +## 7. Identity record + +`/.json`, written by the holder after it holds its lease and +has bound its socket (atomic rename, 0600, O_NOFOLLOW). JSON, fields only +grow, readers ignore unknown fields. `` is +`~/Library/Application Support/cmux/holders//` on macOS (the daily +`/tmp` cleaner removes files idle for 3 days), `$XDG_STATE_HOME/cmux/holders//` +on Linux. Sockets go to `/tmp/cmux-/holders//` (macOS) or +`$XDG_RUNTIME_DIR/cmux/holders//` (Linux; `/tmp/cmux-/...` +when that path would exceed 103 bytes). + +```json +{ + "schema": 1, + "id": "<32 hex>", + "mode": "pty", + "install": "<12 hex hash of bundle id + tag>", + "session": "", + "cwd": "/path", + "argv": ["/bin/zsh", "-l"], + "started_at_ms": 0, + "protocol": {"min": 1, "max": 1}, + "caps": 0, + "socket": "/tmp/cmux-501/holders//.sock", + "lease": "..live", + "orphan_grace_secs": null, + "holder_build": "" +} +``` + +The owner token is not in this record. The daemon keeps tokens in its own +store: macOS Keychain item `cmux holder tokens `; Linux a 0600 file +`$XDG_STATE_HOME/cmux/holders//tokens` (protects against other +users only, and against same-uid processes only when Yama +`ptrace_scope >= 1`). A holder whose token is lost cannot be controlled +again; the daemon lists it as "running, not reachable" and the user may end +it with SIGKILL from the UI (the only pid use, from the user's action). +`pid` fields are absent on purpose: no daemon finds or signals a holder by +pid. + +Code identity (decision D6, pending): on macOS the holder also checks the +connected peer's audit token (`LOCAL_PEERTOKEN`) against a code requirement +passed at spawn (same Team ID + the cmux daemon's identifier). Ad hoc DEV +builds (`orphan_grace_secs` set) skip it. The holder runs with the hardened +runtime so a same-uid process cannot attach a debugger to it. + +## 8. Liveness and discovery + +- The holder holds an exclusive `flock` on `` (a path relative to the + record's directory) for its life, from before its record exists. A daemon + that gets the lock knows the holder is dead. Only a daemon that holds the + lease deletes the record, socket, log and stale `.live` files. +- Discovery = list `/*.json`, probe each lease, connect to each + live socket, HELLO. Never ps, `proc_listpids`, sysctl, `/proc` walks or pid + probes (`scripts/cmux-next/check-no-process-scan.py`). + +## 9. Compatibility corpus (with cx-t2sqm.15) + +`cmux-tui/crates/cmux-holder/corpus/v1/*.json`, one file per exchange: +`{name, steps:[{dir:"d"|"h", frame_hex}], notes}`. Required exchanges: + +1. handshake ok, caps 0; 2. bad token; 3. no common version (daemon offers +2..3); 4. unknown kind 999 -> ERROR, link kept; 5. attach from 0 then live +data; 6. attach below `oldest` -> GAP + data; 7. write + echo; 8. resize; +9. signal group SIGINT; 10. ack trims, re-attach below acked -> GAP; 11. close +-> EXIT; 12. controller replaced -> NOT_CONTROLLER; 13. pipes mode stderr +flag; 14. RESIZE in pipes mode -> NOT_SUPPORTED; 15. ping/pong; 16. observer HELLO +keeps the controller; 17. STATS; 18. attach to an exited holder -> DATA then +EXIT; 19. bootstrap with a failing exec -> 0x02 + errno, no record; 20. a +link silent past the 5 s HELLO deadline is closed. + +The runner replays each file twice in CI: the current daemon client against +a holder built from the newest commit, and against a holder built from the +oldest supported holder commit (pinned in `corpus/v1/HOLDER_PINS`). A +daemon-side change that breaks any file is refused. diff --git a/plans/cmux-next/pty-holder.md b/plans/cmux-next/pty-holder.md index 527f49f36b79..a347fe08b3fe 100644 --- a/plans/cmux-next/pty-holder.md +++ b/plans/cmux-next/pty-holder.md @@ -103,6 +103,9 @@ and old holders keep running their own copy, pinned by its lock. ### 2.1 Holder wire protocol v1 (frozen) +Full spec (frame layouts, kind values, error codes, corpus): +`plans/cmux-next/holder-protocol-v1.md`. Summary: + Framing: `magic "CMHL" u32 | kind u16 | flags u16 | len u32 | payload`. Little endian. Max payload 1 MiB. Payloads are fixed binary structs plus byte strings; no JSON in the holder. @@ -122,7 +125,7 @@ strings; no JSON in the holder. | EXIT | h->d | wait status, end offset, time; also written to `.exit` | | CLOSE | d->h | end the child: SIGHUP/SIGTERM, then SIGKILL after `grace_ms` | | PING/PONG | both | liveness of the link only | -| ERROR | h->d | code (UNKNOWN_KIND, BAD_TOKEN, ...) for a request; link stays up | +| ERROR | h->d | code (UNKNOWN_KIND, BAD_TOKEN, ...) for a request; link stays up except after BAD_TOKEN / NO_COMMON_VERSION | Rules that keep it frozen: - A holder answers an unknown kind with ERROR UNKNOWN_KIND and keeps the link. @@ -147,9 +150,9 @@ replica). Agent hosts move the same way: new agents start under ## 3. Discovery and restore without scanning - At spawn the holder writes an identity record (atomic, 0600, O_NOFOLLOW): - schema, id, mode, owner install (`:`), session, cwd, argv, + schema, id, mode, install (12-hex hash of bundle id + tag), session, cwd, argv, start time, protocol min/max, capability bits, socket path, lease nonce. - pid fields are informational only and are never used to find or signal. + The record has no pid fields: nothing finds or signals a holder by pid. - Records and sockets live in a per-install runtime directory (0700): `/cmux/holders//`, where the install id is a short hash (12 hex) of BuildIdentity's stable part (bundle id + tag), NOT the @@ -280,3 +283,7 @@ never infers an agent from a process name, argv, foreground group or tree. - D5 (chief): peer checks that walk parent processes (control socket, computer-use helper) become a capability token the app gives its children. +- D6 (pending, security policy): the frozen holder checks the macOS peer's + code identity (same Team ID + daemon identifier, audit token) before + HELLO; ad hoc DEV builds skip it. A frozen holder cannot add this later. + Recommend: freeze it now (holder-protocol-v1.md section 7). From bf4c0ef7a8f6bcf90cb61c2c8f3437969be78304 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 18:14:39 -0700 Subject: [PATCH 107/183] cmux NEXT: no prewarmed WebKit process at launch (a load into it froze ~11 s on some Macs) (#20775) A load that took the page-less web content process from -[WKProcessPool _warmInitialProcess] (cx-z9kz0, #19847) held the main thread ~11 s in registerUserInstalledFonts / sandbox_extension_issue on some Macs; the New Tab spare took it, so DEV capture-slot launches died to the main-thread watchdog. The warmup view stays; first-paint medians 916 vs 906 ms on a healthy Mac. Co-Authored-By: Claude Opus 5.5 --- .../AgentPaneView+Warmup.swift | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Warmup.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Warmup.swift index 4be9d563a926..1ce3daf2c78b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Warmup.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Warmup.swift @@ -7,10 +7,19 @@ import WebKit /// about 65 ms earlier on a cold launch). The empty page goes once an agent /// page has loaded or the launch settled (`LaunchSettle`). /// -/// It also has the pool start one more web content process with no page, -/// set up while the windows build, which the first agent page takes: a -/// page in a new process waited for that process's setup, which needs main -/// thread turns the launch does not give it until about 450 ms (bd cx-z9kz0). +/// It no longer has the pool prewarm a second, page-less web content process +/// (`-[WKProcessPool _warmInitialProcess]`, cx-z9kz0, #19847). On some Macs +/// the first load that takes that process holds the main thread for about +/// 11 s: WebKit issues its font sandbox extensions to the prewarmed process +/// while that process's network setup is stalled. A standalone WKWebView app +/// on one test Mac (macOS 27.0.1) held 10.99 s with the prewarm and 0.005 s +/// without; another held 0.006 s either way. A DEV build's watchdog killed +/// every launch on the first (the New Tab spare took the process); a Release +/// build froze. The +/// prewarm saved about 65 ms before the first agent page went live on a +/// cold launch (cx-z9kz0). To bring it back, call `_warmInitialProcess` on +/// ``processPool`` after the warmup view's load, once WebKit's stall is +/// understood. extension AgentPaneView { private static var warmupView: WKWebView? /// The in-memory data store every agent page shares. WebKit gives each store its own web @@ -26,10 +35,6 @@ extension AgentPaneView { let view = WKWebView(frame: .zero, configuration: configuration) view.loadHTMLString("", baseURL: nil) warmupView = view - // WebKit's prewarmed process (`-[WKProcessPool _warmInitialProcess]`), checked first: macOS - // has no public call for it; without it the first page starts its own process. - let prewarm = NSSelectorFromString("_warmInitialProcess") // crash-allow: performed only when the pool responds to it - if processPool.responds(to: prewarm) { _ = processPool.perform(prewarm) } } public static func finishWarmup() { From 4c568481b46c475111a15df0b3f3b8f19776f558 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 18:15:49 -0700 Subject: [PATCH 108/183] ci: the batch formatter fails on a failed bun install (#20764) * test: a failed bun install in the batch formatter is not a clean format Co-Authored-By: Claude Opus 5.5 * ci: the batch formatter fails on a failed bun install (#20763) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/cmux-next-batch.yml | 3 ++- tests/test_next_batch.py | 28 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cmux-next-batch.yml b/.github/workflows/cmux-next-batch.yml index 369fe532853d..28fae3da9150 100644 --- a/.github/workflows/cmux-next-batch.yml +++ b/.github/workflows/cmux-next-batch.yml @@ -293,7 +293,8 @@ jobs: names=() # Formatting first: the bundles are then built from formatted sources. if [[ " $KINDS " == *" format "* ]]; then - (cd webviews && bun install --frozen-lockfile && bun run check:fix) \ + (cd webviews && bun install --frozen-lockfile) || fail "bun install" + (cd webviews && bun run check:fix) \ || echo "check:fix left issues it cannot fix; keeping what it fixed" names+=("webviews formatting") fi diff --git a/tests/test_next_batch.py b/tests/test_next_batch.py index db0fe6ebefd9..56364542757b 100644 --- a/tests/test_next_batch.py +++ b/tests/test_next_batch.py @@ -909,6 +909,34 @@ def test_regeneration_jobs_return_a_patch_and_never_push(self): self.assertIn("format-patch", script) self.assertFalse(any("secrets." in json.dumps(step) for step in spec["steps"])) + def run_format(self, bun: str) -> dict: + """Run regen-linux's real step script for `kinds: format` with a stub bun.""" + step = next(s for s in self.jobs["regen-linux"]["steps"] if "check:fix" in s.get("run", "")) + with tempfile.TemporaryDirectory() as tmp: + work, bin_dir, temp = Path(tmp, "work"), Path(tmp, "bin"), Path(tmp, "temp") + for path in (work / "webviews", bin_dir, temp): + path.mkdir(parents=True) + (bin_dir / "bun").write_text(f"#!/bin/sh\n{bun}\n") + (bin_dir / "bun").chmod(0o755) + subprocess.run(["git", "init", "-q"], cwd=work, check=True) + env = {**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}", "KINDS": "format", + "RUNNER_TEMP": str(temp), "SERVER_URL": "https://github.com", + "REPOSITORY": "manaflow-ai/cmux", "GITHUB_RUN_ID": "1"} + subprocess.run(["bash", "-c", step["run"]], cwd=work, env=env, check=True, + capture_output=True) + return json.loads((temp / "result/result.json").read_text()) + + def test_a_failed_install_is_not_a_clean_format(self): + # Codex CI audit (#20763): `bun install && bun run check:fix || echo` + # turned a failed install into {"ok": true} with no patch, which the + # controller reads as "nothing to fix". + result = self.run_format('[ "$1" = install ] && exit 1; exit 0') + self.assertFalse(result["ok"], result) + self.assertIn("bun install", result["error"]) + + def test_check_fix_leftovers_still_keep_what_it_fixed(self): + self.assertTrue(self.run_format('[ "$1" = install ] && exit 0; exit 1')["ok"]) + if __name__ == "__main__": unittest.main() From d51174a6916d917baf91c2daaff0b02eef0e2567 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:19:57 -0700 Subject: [PATCH 109/183] resource_attach: chief.profile.adopt is the 54th connection operation Co-Authored-By: Claude Opus 5.5 (1M context) --- .../crates/cmux-tui-core/src/server/tests/resource_attach.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server/tests/resource_attach.rs b/cmux-tui/crates/cmux-tui-core/src/server/tests/resource_attach.rs index a6398dbdae70..7692cd36d823 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/tests/resource_attach.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/tests/resource_attach.rs @@ -175,7 +175,7 @@ fn connection_handler_owns_every_router_connection_operation() { ); connection_operations += usize::from(requires_connection); } - assert_eq!(connection_operations, 53); + assert_eq!(connection_operations, 54); } #[test] From 61da528fe376fdcaca3444df60c4ae012de3f937 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:20:18 -0700 Subject: [PATCH 110/183] cmux-next motion: DebugFrameStrip report dictionary gets an explicit type (Xcode 27 could not type-check the merged literal in time) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sources/CmuxNextApp/Control/DebugFrameStrip.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift index 1b4105d2cab2..8ca9f9e6b718 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift @@ -105,7 +105,7 @@ enum DebugFrameStrip { } WindowFrame.writeStrip(frames.filter { $0.milliseconds >= before.milliseconds }, to: (dir as NSString).appendingPathComponent("strip.png")) let motion = progress.fields(moving: intermediate, tabs: MotionTravelSummary("tabs.travel", after: sentTime)).mapValues(JSONValue.number) - return .object(motion.merging([ + let report: [String: JSONValue] = [ "key": sent, "dir": .string(dir), "frames": .array(rows), "intermediate": .number(Double(intermediate)), "blank": .number(Double(blank)), "frame_count": .number(Double(rows.count)), "content_intermediate": .number(Double(contentIntermediate)), "desync": .number(Double(desync)), @@ -113,7 +113,8 @@ enum DebugFrameStrip { "settled_ms": settledAt.map { .number($0.rounded()) } ?? .null, "first_change_ms": firstChange.map { .number($0.rounded()) } ?? .null, "spans": .array(spans.map { .object(["name": .string($0.name), "ms": .number(($0.start - sentAt).rounded()), "duration_ms": .number($0.milliseconds)]) }), "mean_interval_ms": .number(frames.count > 1 ? (last.milliseconds - first.milliseconds) / Double(frames.count - 1) : 0), - ]) { first, _ in first }) + ] + return .object(report.merging(motion) { first, _ in first }) } /// `gesture: "drag_new"`: drags the focused strip's first tab right in 12 steps, one per frame From 27a21a6623f30f71eac41bf3b71d5d0b73025f31 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:21:46 -0700 Subject: [PATCH 111/183] cmux-tui: compact-registry shares the ensure arm's unreachable (crash ratchet, cx-ossub) --- cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs b/cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs index 7a0edb86de05..405f820653e2 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/lifecycle.rs @@ -236,9 +236,8 @@ pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { } match plan.action { - ServerAction::Ensure { .. } => unreachable!("ensure returns before the lifecycle exchange"), - ServerAction::CompactRegistry { .. } => { - unreachable!("compact-registry returns before the lifecycle exchange") + ServerAction::Ensure { .. } | ServerAction::CompactRegistry { .. } => { + unreachable!("ensure and compact-registry return before the lifecycle exchange") } ServerAction::Status => print_success( json!({ From 17baa59842ea75f2afcbbf081058a272b7e843e6 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 18:22:04 -0700 Subject: [PATCH 112/183] CmuxNext: status set waits for a new terminal to join its workspace (cx-odyw4) (#20750) * test: status set from a new terminal reaches its workspace (cx-odyw4) A new terminal's shell can run cmux status set before the daemon commits its tab; the set must still land and exit 0. A caller terminal the session never shows must exit non-zero with a clear message. Co-Authored-By: Claude Opus 5.5 * fix: status set waits for a new terminal to join its workspace (cx-odyw4) A new tab's shell starts before the daemon commits the tab, so a cmux status set at its first prompt looked up a terminal the daemon did not show yet and failed with: no terminal matches "term_...". The caller lookup now follows the session's event stream on a second connection and looks again after each event, for up to 3 s. A terminal that never joins fails non-zero with a message naming it and how to target a workspace instead. Co-Authored-By: Claude Opus 5.5 * fix: wait only on a miss of the caller's own terminal (cx-odyw4) Review: an unknown --machine also fails with selector.not_found; report it as it is instead of waiting 3 s and blaming the terminal. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cmux-tui/crates/cmux-tui/src/cli/resolve.rs | 109 ++++++++++++++++++- cmux-tui/crates/cmux-tui/src/cli/wire.rs | 3 +- cmux-tui/crates/cmux-tui/tests/cli/status.rs | 64 +++++++++++ 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/cli/resolve.rs b/cmux-tui/crates/cmux-tui/src/cli/resolve.rs index 6c9ffe712956..7f99b9baff53 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/resolve.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/resolve.rs @@ -5,12 +5,14 @@ //! fingerprint. use std::io::{BufReader, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; use cmux_tui_core::platform::transport; use cmux_tui_core::resource::{MAX_MESSAGE_BYTES, PROTOCOL, ResourceOperation, ResponseEnvelope}; use serde_json::{Map, Value, json}; -use super::command::{RequestPlan, Resolve, ZoomStep}; +use super::command::{RequestPlan, Resolve, ZoomStep, random_prefixed}; use super::wire::{print_local_error, print_operation_error, random_request_id, read_envelope}; use super::{GlobalArgs, OutputMode}; @@ -51,11 +53,19 @@ impl Failure { } } +/// The session socket a request's connection reached, so a lookup can open +/// a second connection to the same daemon. +pub(super) struct SocketRoute<'a> { + pub(super) path: &'a Path, + pub(super) derived: bool, +} + /// Applies every pending lookup of `plan` and clears them. pub(super) fn apply( reader: &mut Reader, plan: &mut RequestPlan, caller_route: bool, + socket: &SocketRoute<'_>, ) -> Result<(), Failure> { let steps = std::mem::take(&mut plan.resolve); let params = plan @@ -67,7 +77,7 @@ pub(super) fn apply( match step { Resolve::CallerWorkspace { terminal } => { let workspace = if caller_route { - caller_workspace(reader, &route, &terminal)? + registered_caller_workspace(reader, &route, &terminal, socket)? } else { "current".to_string() }; @@ -187,6 +197,101 @@ fn route(params: &Map) -> Map { .collect() } +/// How long a lookup waits for the caller's terminal to join its workspace. +const CALLER_TERMINAL_WAIT: Duration = Duration::from_secs(3); + +/// [`caller_workspace`], waiting for the caller's terminal to be placed. A +/// new tab's shell runs before the daemon commits the tab, so a command at +/// its first prompt can ask for a terminal the daemon does not show yet. +/// The wait follows the session's event stream on a second connection and +/// looks again after each event, up to [`CALLER_TERMINAL_WAIT`]. +fn registered_caller_workspace( + reader: &mut Reader, + route: &Map, + terminal: &str, + socket: &SocketRoute<'_>, +) -> Result { + match caller_workspace(reader, route, terminal) { + Err(Failure::Resource(error)) if is_missing(&error, terminal) => {} + settled => return settled, + } + let deadline = Instant::now() + CALLER_TERMINAL_WAIT; + let mut events = session_events(socket, route, deadline); + loop { + let changed = events.as_mut().is_some_and(|events| next_event(events, deadline)); + match caller_workspace(reader, route, terminal) { + Err(Failure::Resource(error)) if is_missing(&error, terminal) => { + if !changed { + return Err(unregistered_terminal(terminal)); + } + } + settled => return settled, + } + } +} + +/// The lookup found no placed `terminal`; any other miss (an unknown +/// `--machine`, say) is reported as it is. +fn is_missing(error: &Value, terminal: &str) -> bool { + let details = &error["details"]; + error.get("code").and_then(Value::as_str) == Some("selector.not_found") + && (details["selector"] == terminal || details["terminal"] == terminal) +} + +fn unregistered_terminal(terminal: &str) -> Failure { + Failure::Resource(json!({ + "code": "selector.not_found", + "message": format!( + "the caller's terminal {terminal} did not join a workspace of this cmux session \ + within {}s; name one: cmux workspace …", + CALLER_TERMINAL_WAIT.as_secs() + ), + "details": {"terminal": terminal}, + "retryable": false, + })) +} + +/// A `session.events` stream of the request's session on its own +/// connection, or `None` when the daemon cannot give one before `deadline`. +fn session_events( + socket: &SocketRoute<'_>, + route: &Map, + deadline: Instant, +) -> Option { + let stream = cmux_tui_core::server::connect_session_socket(socket.path, socket.derived).ok()?; + let mut events = BufReader::new(stream); + let remaining = deadline.checked_duration_since(Instant::now())?; + events.get_mut().set_read_timeout(Some(remaining)).ok()?; + let id = random_request_id().ok()?; + let mut params = route.clone(); + params.entry("session").or_insert_with(|| Value::String("current".into())); + params.insert("stream_id".into(), Value::String(random_prefixed("stream").ok()?)); + let request = json!({ + "protocol": PROTOCOL, + "type": "request", + "id": id, + "operation": ResourceOperation::SessionEvents.wire_name(), + "params": params, + }); + send(&mut events, &serde_json::to_vec(&request).ok()?).ok()?; + match read_response(&mut events, &id) { + Ok(Ok(_)) => Some(events), + _ => None, + } +} + +/// Blocks until the stream's next item; `false` at `deadline` or when the +/// stream ends. +fn next_event(events: &mut Reader, deadline: Instant) -> bool { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + if remaining.is_zero() || events.get_mut().set_read_timeout(Some(remaining)).is_err() { + return false; + } + matches!(read_envelope(events, false), Ok(Some(_))) +} + /// The workspace that holds `terminal`: terminal -> tab -> pane -> screen. fn caller_workspace( reader: &mut Reader, diff --git a/cmux-tui/crates/cmux-tui/src/cli/wire.rs b/cmux-tui/crates/cmux-tui/src/cli/wire.rs index ed07001b5bd6..431620abaf18 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/wire.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/wire.rs @@ -97,7 +97,8 @@ pub(super) fn run(global: GlobalArgs, mut plan: RequestPlan) -> i32 { return code; } if !plan.resolve.is_empty() { - if let Err(failure) = super::resolve::apply(&mut reader, &mut plan, caller_route) { + let route = super::resolve::SocketRoute { path: &socket, derived: socket_is_derived }; + if let Err(failure) = super::resolve::apply(&mut reader, &mut plan, caller_route, &route) { // A browser tab's page zoom: the app hosts the page and owns it. #[cfg(unix)] if let super::resolve::Failure::AppAction { action, target } = &failure { diff --git a/cmux-tui/crates/cmux-tui/tests/cli/status.rs b/cmux-tui/crates/cmux-tui/tests/cli/status.rs index f72dfacfae03..8c24694db316 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/status.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/status.rs @@ -373,6 +373,70 @@ fn status_set_refuses_a_forged_owner() { assert_eq!(entries(&server, &workspace), Vec::::new()); } +/// cx-odyw4: a new terminal's shell can run `cmux status set` before the +/// daemon has committed its tab. The set waits for the terminal to join its +/// workspace, exits 0, and the entry is there. +#[test] +fn status_set_from_a_terminal_at_its_first_command_reaches_its_workspace() { + let server = HeadlessServer::start("status-new-terminal"); + state_cli(&server, None, &["workspace", "create", "--name", "delta"]); + let workspace = workspace_id_named(&server, "delta"); + let mut runs = Vec::new(); + for index in 0..5 { + let code = server.dir.join(format!("set-{index}.code")); + let log = server.dir.join(format!("set-{index}.log")); + let script = format!( + "'{bin}' --json status set race-{index} 'Ready {index}' > '{log}' 2>&1; \ + echo $? > '{code}.tmp' && mv '{code}.tmp' '{code}'", + bin = bin(), + log = log.display(), + code = code.display(), + ); + let output = status_output( + &server, + None, + &["workspace", &workspace, "run", "--on-exit", "keep", "--", "/bin/sh", "-c", &script], + ); + assert_success(&output); + let terminal = json_output(&output)["value"]["terminal_id"].as_str().unwrap().to_owned(); + runs.push((index, terminal, code, log)); + } + for (index, terminal, code, log) in runs { + let deadline = Instant::now() + Duration::from_secs(20); + let exit = loop { + if let Ok(text) = fs::read_to_string(&code) { + break text.trim().to_owned(); + } + assert!(Instant::now() < deadline, "status set in {terminal} never finished"); + std::thread::sleep(Duration::from_millis(50)); + }; + let printed = fs::read_to_string(&log).unwrap_or_default(); + assert_eq!(exit, "0", "status set in new terminal {terminal} failed: {printed}"); + let key = format!("race-{index}"); + let set = entry(&server, &workspace, &key) + .unwrap_or_else(|| panic!("{key} from {terminal} is not in its workspace: {printed}")); + assert_eq!(set["text"], format!("Ready {index}"), "{set}"); + } +} + +/// A caller terminal the session never shows fails the set after a bounded +/// wait, non-zero, with a message that names the terminal and the way out. +#[test] +fn status_set_from_an_unknown_terminal_exits_non_zero_after_a_bounded_wait() { + let server = HeadlessServer::start("status-unknown-terminal"); + state_cli(&server, None, &["workspace", "create", "--name", "epsilon"]); + let unknown = format!("term_{}", "0".repeat(32)); + let started = Instant::now(); + let output = status_output(&server, Some(&unknown), &["status", "set", "lost", "Lost"]); + assert!(started.elapsed() < Duration::from_secs(15), "the wait is not bounded"); + assert!(!output.status.success(), "a set from an unknown terminal succeeded"); + let error = json_error(&output); + assert_eq!(error["code"], "selector.not_found", "{error}"); + let message = error["message"].as_str().unwrap_or_default(); + assert!(message.contains(&unknown), "{error}"); + assert!(message.contains("cmux workspace "), "{error}"); +} + /// cx-uf8b (nxdog94 preflight): the root help lists `status`, `status --help` /// and `status set --help` print the status usage (they printed the root /// help), and `status set KEY TEXT`, the `workspace status set` form, sets the From 4c9081bf692913dffacc65ff585660b0b75eec44 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 18:28:53 -0700 Subject: [PATCH 113/183] fix: reopening a closed page takes every close of it; Search Tabs lists it once (#20766) * test: reopening a closed page leaves no older close; Search Tabs lists it once Reopening a page closed several times (History, New Tab, Cmd-Shift-T) consumed only its newest close, so an older one became its Recently Closed row while the page was open again. Search Tabs' closed section listed every close of a page, and Forget deleted only one. The Search Tabs seams (TabSearchFactsBuilder.listed / forgotten) keep the current behavior here; the fix changes them. Co-Authored-By: Claude Opus 5.5 * fix: reopening a closed page takes every close of it; Search Tabs lists it once Reopening a page (one browser tab on an address) through closed.reopen now removes, in the same commit and under the REOPEN lock, every other closed group that is only that page, so no older close becomes its Recently Closed row and Cmd-Shift-T never opens it twice. Multi-tab groups, Close Group tab groups, screens, workspaces and windows are untouched. The page key lives once per side: closed_history_query::closed_page in the daemon (history list, Remove and reopen) and DaemonClosedHistory.pageKey in the app (New Tab Recently Closed, Search Tabs list and Forget). Search Tabs lists a page once and Forget deletes every close of it. Co-Authored-By: Claude Opus 5.5 * fix: a reopen's sweep of older closes never fails the reopen A closed group the sweep cannot read stays, and so does every group when the list cannot be read: the reopen whose tabs already exist commits (review of #20766). Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at b7a410f1a6aa - cargo fmt in Native/OptChat/optchat-chief Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38651353532 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- .../Handlers/DaemonClosedHistory.swift | 34 +++++++++ .../CmuxNextApp/History/HistoryService.swift | 17 +---- .../TabSearch/AppTabSearchSource.swift | 5 +- .../TabSearch/TabSearchFactsBuilder.swift | 18 ++++- .../ClosedBlankItemsTests.swift | 32 +++++---- .../TabSearchClosedOnceTests.swift | 53 ++++++++++++++ .../cmux-tui-core/src/history_ops/sources.rs | 54 +++++--------- .../src/server/page_tabs_tests.rs | 28 ++++++++ .../cmux-tui-core/src/state/closed_history.rs | 11 ++- .../src/state/closed_history_query.rs | 72 +++++++++++++++++++ 10 files changed, 256 insertions(+), 68 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabSearchClosedOnceTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/DaemonClosedHistory.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/DaemonClosedHistory.swift index 33691af44256..ccebe3bdc79f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/DaemonClosedHistory.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/DaemonClosedHistory.swift @@ -35,6 +35,40 @@ enum DaemonClosedHistory { id.hasPrefix("daemon:") ? String(id.dropFirst("daemon:".count)) : nil } + /// The page a closed item on `machine` is only, by machine and address: a closed tab of one + /// browser on a real address, as the daemon keys it (`closed_page`, Leo 2026-10-14). nil for a + /// terminal, chat, page tab, screen, workspace, window, group or close of several tabs: + /// nothing tells one close of those from another (two shells in one folder). + nonisolated static func pageKey(_ item: ClosedItem, machine: String) -> [String]? { + let tabs = item.tabs + guard item.kind == .tab, item.group == nil, tabs.count == 1, let tab = tabs.first, tab.kind == "browser", + !tab.empty, tab.page == nil, let url = tab.url, !url.isEmpty, url != "about:blank" else { return nil } + return [machine, url] + } + + /// `closed` (newest first, each as its item and machine) with a page closed again + /// (``pageKey(_:machine:)``) once, at its newest close: Recently Closed and Search Tabs list a + /// page once. + nonisolated static func newestOnce(_ closed: [T], _ item: (T) -> (item: ClosedItem, machine: String)) -> [T] { + var seen = Set<[String]>() + return closed.filter { value in + let (closedItem, machine) = item(value) + return pageKey(closedItem, machine: machine).map { seen.insert($0).inserted } ?? true + } + } + + /// Every close the row of `target` stands for, among `closed`: for a page + /// (``pageKey(_:machine:)``) every close of it, so removing the row leaves none to take its + /// place; else `target` alone. + nonisolated static func closes(of target: T, among closed: [T], _ item: (T) -> (item: ClosedItem, machine: String)) -> [T] { + let own = item(target) + guard let page = pageKey(own.item, machine: own.machine) else { return [target] } + return closed.filter { value in + let other = item(value) + return pageKey(other.item, machine: other.machine) == page + } + } + /// True when some connected daemon records closed history. static func isServed(in services: AppServices) -> Bool { services.machines.daemons.contains { $0.store.servesStateResources } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/History/HistoryService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/History/HistoryService.swift index 352e4a4dc0d4..ce5d5f279ee6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/History/HistoryService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/History/HistoryService.swift @@ -146,9 +146,10 @@ final class HistoryService { /// Closed tabs, screens and workspaces, as the daemons record them /// (`closed-history-v2`): the only closed history (reopen-closed.md 9). Newest first, a page - /// closed again once (``newestOnce(_:)``). + /// closed again once (``DaemonClosedHistory/newestOnce(_:_:)``). func closedEntries() -> [HistoryEntry] { - Self.newestOnce(DaemonClosedHistory.entries(DaemonClosedHistory.allKinds, in: services).filter { Self.isWorthReopening($0.item) }.map { entry in + let shown = DaemonClosedHistory.entries(DaemonClosedHistory.allKinds, in: services).filter { Self.isWorthReopening($0.item) } + return DaemonClosedHistory.newestOnce(shown) { ($0.item, $0.daemon.machineID) }.map { entry in let item = entry.item, tab = Self.shownTab(item) let kind: CmuxNextHistory.ClosedItem.Kind = switch item.kind { case .tab: tab?.kind == "browser" ? .browserTab : .terminalTab @@ -165,18 +166,6 @@ final class HistoryService { time: Date(timeIntervalSince1970: Double(item.closedAtMs) / 1000), title: title, detail: detail, machineName: local ? nil : entry.daemon.machineID, payload: .closed(closed)) - }) - } - - /// `entries` newest first, a page closed again (the same address on the same machine) once, - /// at its newest close (Leo 2026-10-14), as the daemon's history list shows it. A terminal, - /// chat, screen or workspace carries nothing that tells one close from another (two shells - /// in one folder): each close stays its own row. - static func newestOnce(_ entries: [HistoryEntry]) -> [HistoryEntry] { - var seen = Set<[String]>() - return entries.sorted { $0.time > $1.time }.filter { entry in - guard case .closed(let item) = entry.payload, item.kind == .browserTab, let url = item.url else { return true } - return seen.insert([item.machine, url]).inserted } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/AppTabSearchSource.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/AppTabSearchSource.swift index 4aca4aafad71..11821f581195 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/AppTabSearchSource.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/AppTabSearchSource.swift @@ -44,7 +44,10 @@ final class AppTabSearchSource: TabSearchSource { func forgetClosedTab(id: String) { guard let entry = DaemonClosedHistory.entry(DaemonClosedHistory.daemonID(fromHistoryID: id) ?? id, in: services) else { return } - DaemonClosedHistory.delete(entry, services: services) + let closed = DaemonClosedHistory.entries([.tab], in: services) + for close in TabSearchFactsBuilder.forgotten(entry, among: closed, { ($0.item, $0.daemon.machineID) }) { + DaemonClosedHistory.delete(close, services: services) + } } /// An open browser tab's favicon on this Mac: its live page's, else its record's (never an diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/TabSearchFactsBuilder.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/TabSearchFactsBuilder.swift index 8314a634a0c3..25174388c0cb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/TabSearchFactsBuilder.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/TabSearchFactsBuilder.swift @@ -1,4 +1,5 @@ import CmuxNextControl +import CmuxNextDaemon import Foundation /// Builds the control snapshot's Search Tabs facts from the App's owners: @@ -43,8 +44,7 @@ enum TabSearchFactsBuilder { private static func closed(_ services: AppServices) -> [ControlClosedTab] { // Read inside the publisher's tracking: a close republishes. _ = services.closedTabs?.changes.revision - // Untouched New Tab pages are left out, as in Recently Closed (HistoryService.isWorthReopening). - return DaemonClosedHistory.entries([.tab], in: services).filter { HistoryService.isWorthReopening($0.item) }.map { entry in + return listed(DaemonClosedHistory.entries([.tab], in: services)) { ($0.item, $0.daemon.machineID) }.map { entry in let item = entry.item, tab = HistoryService.shownTab(item), daemon = entry.daemon let workspaceTitle = item.workspaceID.flatMap { daemon.store.workspace(resourceID: $0)?.displayName } let local = daemon.machineID == MachineRegistry.localID @@ -60,4 +60,18 @@ enum TabSearchFactsBuilder { closedAt: Date(timeIntervalSince1970: Double(item.closedAtMs) / 1000), isAvailable: daemon.connection != nil) } } + + /// The closed tab items Search Tabs lists, of `closed` (newest first, each as its item and + /// machine), as Recently Closed lists them: untouched New Tab pages left out + /// (HistoryService.isWorthReopening), a page closed again once, at its newest close + /// (``DaemonClosedHistory/newestOnce(_:_:)``). + static func listed(_ closed: [T], _ item: (T) -> (item: ClosedItem, machine: String)) -> [T] { + DaemonClosedHistory.newestOnce(closed.filter { HistoryService.isWorthReopening(item($0).item) }, item) + } + + /// The closed items Forget deletes for the row of `target`, among `closed`: for a page every + /// close of it, so no older one takes its row (``DaemonClosedHistory/closes(of:among:_:)``). + static func forgotten(_ target: T, among closed: [T], _ item: (T) -> (item: ClosedItem, machine: String)) -> [T] { + DaemonClosedHistory.closes(of: target, among: closed, item) + } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClosedBlankItemsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClosedBlankItemsTests.swift index 3f0591f4563e..d227a8600bea 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClosedBlankItemsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClosedBlankItemsTests.swift @@ -59,20 +59,26 @@ struct ClosedBlankItemsTests { } /// One row per page (Leo 2026-10-14): a page closed again shows once, at its newest close, - /// newest first. Terminals and chats carry no identity beyond a name or folder, so each close - /// of them stays its own row. - @Test func aPageClosedAgainShowsOnceAtItsNewestClose() { - func entry(_ id: String, _ kind: CmuxNextHistory.ClosedItem.Kind, at seconds: Double, url: String? = nil, cwd: String? = nil) -> HistoryEntry { - let item = CmuxNextHistory.ClosedItem(id: id, kind: kind, title: url ?? cwd ?? "", machine: "local", cwd: cwd, url: url) - return HistoryEntry(id: id, kind: .closed, time: Date(timeIntervalSince1970: seconds), title: item.title, payload: .closed(item)) + /// newest first, by the key the daemon's history list uses (`DaemonClosedHistory.pageKey`). + /// Terminals and chats carry no identity beyond a name or folder, so each close of them stays + /// its own row. + @Test func aPageClosedAgainShowsOnceAtItsNewestClose() throws { + let example = """ + {"kind":"browser","name":null,"cwd":null,"url":"https://example.com/","browser_profile_id":null,"pinned":false} + """ + let cmux = example.replacingOccurrences(of: "example.com", with: "cmux.com") + func entry(_ id: String, _ tab: String) throws -> CmuxNextDaemon.ClosedItem { + var item = try Self.closed("tab", tab) + item.id = id + return item } - let shown = HistoryService.newestOnce([ - entry("example-1", .browserTab, at: 1, url: "https://example.com/"), - entry("shell-1", .terminalTab, at: 2, cwd: "/Users/you"), - entry("example-2", .browserTab, at: 3, url: "https://example.com/"), - entry("shell-2", .terminalTab, at: 4, cwd: "/Users/you"), - entry("cmux", .browserTab, at: 5, url: "https://cmux.com/"), - ]).map { $0.id } + let shown = try DaemonClosedHistory.newestOnce([ + entry("cmux", cmux), + entry("shell-2", Self.terminal), + entry("example-2", example), + entry("shell-1", Self.terminal), + entry("example-1", example), + ]) { ($0, "local") }.map(\.id) #expect(shown == ["cmux", "shell-2", "example-2", "shell-1"]) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabSearchClosedOnceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabSearchClosedOnceTests.swift new file mode 100644 index 000000000000..df4a472ffd65 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabSearchClosedOnceTests.swift @@ -0,0 +1,53 @@ +@testable import CmuxNextApp +import CmuxNextDaemon +import Foundation +import Testing + +/// Search Tabs' closed section lists a page closed again once, at its newest close, as Recently +/// Closed does, and Forget on that row deletes every close of the page, so no older one takes its +/// place. A terminal carries nothing that tells one close from another: each close stays its +/// own row, and Forget deletes only it. A page on another machine is another page. +@MainActor +struct TabSearchClosedOnceTests { + private typealias Closed = (item: CmuxNextDaemon.ClosedItem, machine: String) + + private static func browser(_ url: String) -> String { + """ + {"kind":"browser","name":null,"cwd":null,"url":"\(url)","browser_profile_id":null,"pinned":false} + """ + } + + private static let terminal = """ + {"kind":"terminal","name":null,"cwd":"/Users/you","url":null,"browser_profile_id":null,"pinned":false} + """ + + /// One closed tab item as `closed.list` lists it, closed at `at`. + private static func closed(_ id: String, at: Int, _ tab: String, machine: String = "local") throws -> Closed { + let json = """ + {"id":"\(id)","kind":"tab","name":null,"workspace_id":null,"pane_id":null,"index":0,"closed_at_ms":\(at), + "screens":[{"name":null,"tabs":[\(tab)]}],"window":null,"member_count":1,"members":[]} + """ + return (try JSONDecoder().decode(CmuxNextDaemon.ClosedItem.self, from: Data(json.utf8)), machine) + } + + @Test func aPageClosedAgainIsListedOnceAndForgottenWhole() throws { + let newestFirst = try [ + Self.closed("cmux", at: 6, Self.browser("https://cmux.com/")), + Self.closed("example-3", at: 5, Self.browser("https://example.com/")), + Self.closed("shell-2", at: 4, Self.terminal), + Self.closed("example-other-mac", at: 3, Self.browser("https://example.com/"), machine: "studio"), + Self.closed("example-2", at: 2, Self.browser("https://example.com/")), + Self.closed("shell-1", at: 1, Self.terminal), + Self.closed("example-1", at: 0, Self.browser("https://example.com/")), + ] + let listed = TabSearchFactsBuilder.listed(newestFirst) { $0 } + #expect(listed.map(\.item.id) == ["cmux", "example-3", "shell-2", "example-other-mac", "shell-1"]) + + let forgotten = { (id: String) in + let target = try #require(newestFirst.first { $0.item.id == id }) + return TabSearchFactsBuilder.forgotten(target, among: newestFirst) { $0 }.map(\.item.id).sorted() + } + #expect(try forgotten("example-3") == ["example-1", "example-2", "example-3"]) + #expect(try forgotten("shell-2") == ["shell-2"]) + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/history_ops/sources.rs b/cmux-tui/crates/cmux-tui-core/src/history_ops/sources.rs index b1f106c05463..e1a0d0bdb049 100644 --- a/cmux-tui/crates/cmux-tui-core/src/history_ops/sources.rs +++ b/cmux-tui/crates/cmux-tui-core/src/history_ops/sources.rs @@ -6,39 +6,37 @@ use cmux_history::{ClosedKind, HistoryEntry, HistoryKind}; use serde_json::Value; use super::trail::TrailEntry; +use crate::state::closed_history_query::{closed_page, closed_tabs}; /// The closed items worth reopening as entries, newest first, a page closed again once, at -/// its newest close (Leo 2026-10-14: Recently Closed is high signal). +/// its newest close (Leo 2026-10-14: Recently Closed is high signal; `closed_page`). pub(super) fn closed_entries(items: &[Value]) -> Vec { - let mut entries = items.iter().filter_map(closed_entry).collect::>(); - entries.sort_by_key(|entry| std::cmp::Reverse(entry.at_ms)); + let mut entries = items + .iter() + .filter_map(|item| Some((closed_entry(item)?, closed_page(item)))) + .collect::>(); + entries.sort_by_key(|(entry, _)| std::cmp::Reverse(entry.at_ms)); let mut seen = std::collections::HashSet::new(); - entries.retain(|entry| page_key(entry).is_none_or(|page| seen.insert(page.to_owned()))); - entries + entries.retain(|(_, page)| page.is_none_or(|page| seen.insert(page))); + entries.into_iter().map(|(entry, _)| entry).collect() } /// The closed groups the row `id` (`closed:daemon:`) stands for: its own, and for a /// page every other close of it, so removing the row leaves no older close to take its place. pub(super) fn closed_ids_of(items: &[Value], id: &str) -> Vec { - let entries = items.iter().filter_map(closed_entry).collect::>(); - let page = entries.iter().find(|entry| entry.id == id).and_then(page_key); + let row = |item: &Value| { + let closed = item.get("id").and_then(Value::as_str)?; + Some(format!("{}{closed}", super::closed::PREFIX)) + }; + let page = items.iter().find(|item| row(item).as_deref() == Some(id)).and_then(closed_page); match page { - Some(page) => entries - .iter() - .filter(|entry| page_key(entry) == Some(page)) - .map(|entry| entry.id.clone()) - .collect(), + Some(page) => { + items.iter().filter(|item| closed_page(item) == Some(page)).filter_map(row).collect() + } None => vec![id.to_owned()], } } -/// What a closed row is shown once by: a browser tab's address. A terminal, chat, screen, -/// workspace or window carries nothing that tells one close of it from another (two shells in -/// one folder), so each close stays its own row. -fn page_key(entry: &HistoryEntry) -> Option<&str> { - (entry.closed_kind == Some(ClosedKind::BrowserTab)).then_some(entry.url.as_deref()).flatten() -} - /// One closed-history item (`closed.list` shape) as an entry /// `closed:daemon:`. An item that reopens as nothing (only tabs /// `closed.list` marks `empty`: untouched New Tab pages, blank browsers) is @@ -90,24 +88,6 @@ fn closed_entry(item: &Value) -> Option { Some(entry) } -/// Every tab the item would recreate: every member's, else the top level's -/// (a v1 item), as the Swift `ClosedItem.tabs`. -fn closed_tabs(item: &Value) -> Vec<&Value> { - fn tabs_of(screens: Option<&Value>) -> Vec<&Value> { - screens - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|screen| screen.get("tabs").and_then(Value::as_array)) - .flatten() - .collect() - } - match item.get("members").and_then(Value::as_array).filter(|members| !members.is_empty()) { - Some(members) => members.iter().flat_map(|member| tabs_of(member.get("screens"))).collect(), - None => tabs_of(item.get("screens")), - } -} - /// One trail entry as a location row (`location:`). The daemon /// cannot know whether the app reaches another machine, so every location /// reads available; the app greys offline ones itself. `current`: the newest diff --git a/cmux-tui/crates/cmux-tui-core/src/server/page_tabs_tests.rs b/cmux-tui/crates/cmux-tui-core/src/server/page_tabs_tests.rs index c899dc148def..6056da3c6481 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/page_tabs_tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/page_tabs_tests.rs @@ -274,6 +274,34 @@ fn history_closed_rows_are_only_things_worth_reopening() { mux.shutdown(); } +/// Reopening a page closed several times takes every close of it out of Recently Closed: the +/// page is open again, so no older close becomes its row and Cmd-Shift-T never opens it twice. +#[test] +fn history_closed_reopening_a_page_leaves_no_older_close_of_it() { + let mux = test_mux("history-closed-reopen-once"); + let (_, pane) = terminal_pane(&mux); + let browser = |url: &str| json!({"cmd":"new-frontend-browser-tab","pane":pane,"url":url,"engine":"webkit"}); + for url in ["https://example.com/", "https://cmux.com/", "https://example.com/"] { + let created = run(&mux, browser(url)).unwrap(); + run(&mux, json!({"cmd":"close-surface","surface":surface_of(&created)})).unwrap(); + } + let closed = |mux: &Arc| { + let listed = resource(mux, "history.entries.list", json!({"kinds":["closed"]}), None); + listed["entries"].as_array().unwrap().clone() + }; + let shown = |entries: &[Value]| -> Vec { + entries.iter().map(|entry| entry["detail"].as_str().unwrap_or("-").to_owned()).collect() + }; + let entries = closed(&mux); + assert_eq!(shown(&entries), ["https://example.com/", "https://cmux.com/"], "{entries:?}"); + + let example = entries[0]["id"].as_str().unwrap().strip_prefix("closed:daemon:").unwrap(); + resource(&mux, "closed.reopen", json!({"closed": example}), Some("reopen-example")); + let entries = closed(&mux); + assert_eq!(shown(&entries), ["https://cmux.com/"], "{entries:?}"); + mux.shutdown(); +} + /// A page id is 1 to 64 lowercase letters, digits or '-', '_', '.'; a page /// and another source together are refused. #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs index 5bd203de92cd..ab0f7321fd25 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs @@ -17,7 +17,8 @@ use crate::mux::tab_groups::pane_by_public_id; use crate::mux::tab_strip::StripRequest; use crate::mux::*; use crate::state::closed_history_query::{ - closed_record, keep_members, newest_for_window, remove_closed, + closed_page, closed_record, keep_members, newest_for_window, public_item, remove_closed, + remove_other_closes, }; use crate::state::commit::{StateEffects, state_not_found}; use crate::state::conversation_tabs::ConversationTabTarget; @@ -140,8 +141,15 @@ fn commit_reopen( let tab_group = ["tab_group", "screen_group"] .iter() .any(|field| record.get(*field).is_some_and(Value::is_object)); + // A page reopened is open again: every other close of it leaves with this one, so none + // takes its Recently Closed row or reopens it a second time (Leo 2026-10-14). + let page = if tab_group { None } else { closed_page(&public_item(&record)).map(str::to_owned) }; + let mut others = Vec::new(); let change = if keep.is_empty() { remove_closed(transaction, closed_id)?; + if let Some(page) = &page { + others = remove_other_closes(transaction, page)?; + } state_delete("closed", closed_id) } else { state_upsert("closed", closed_id, keep_members(transaction, closed_id, record, keep)?) @@ -160,6 +168,7 @@ fn commit_reopen( }) .context("reopened item has no workspace")?; let mut changes = vec![change]; + changes.extend(others); let room_restored = match &room { Some(room) => { let local = crate::state::values::local_registry_id(transaction)?; diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_history_query.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_history_query.rs index 30957477eb28..b2990e9431ec 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state/closed_history_query.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_history_query.rs @@ -141,6 +141,78 @@ pub(crate) fn public_item(record: &Value) -> Value { item } +/// Every tab a public item (`closed.list` shape) would recreate: every member's, else the top +/// level's (a v1 item), as the Swift `ClosedItem.tabs`. +pub(crate) fn closed_tabs(item: &Value) -> Vec<&Value> { + fn tabs_of(screens: Option<&Value>) -> Vec<&Value> { + screens + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|screen| screen.get("tabs").and_then(Value::as_array)) + .flatten() + .collect() + } + match item.get("members").and_then(Value::as_array).filter(|members| !members.is_empty()) { + Some(members) => members.iter().flat_map(|member| tabs_of(member.get("screens"))).collect(), + None => tabs_of(item.get("screens")), + } +} + +/// The page a public item (`closed.list` shape) is only, by its address: a closed tab of one +/// browser on a real address. Recently Closed shows a page once however often it was closed, +/// removing or reopening that row takes every close of it, and the Swift +/// `DaemonClosedHistory.pageKey` keys a page the same way (Leo 2026-10-14). A terminal, chat, +/// page tab, screen, workspace, window, group or close of several tabs is no page: nothing tells +/// one close of it from another (two shells in one folder), so each close stays its own. +pub(crate) fn closed_page(item: &Value) -> Option<&str> { + if item.get("kind").and_then(Value::as_str) != Some("tab") + || !item.get("group").is_none_or(Value::is_null) + { + return None; + } + let tabs = closed_tabs(item); + let &[tab] = tabs.as_slice() else { return None }; + let empty = tab.get("empty").and_then(Value::as_bool) == Some(true); + if tab.get("kind").and_then(Value::as_str) != Some("browser") + || empty + || !tab.get("page").is_none_or(Value::is_null) + { + return None; + } + tab.get("url").and_then(Value::as_str).filter(|url| !url.is_empty() && *url != "about:blank") +} + +/// Removes, in a reopen's commit, every other group that is only the page `page` +/// ([`closed_page`]; a closed tab group of that one page forms its group again, so it stays): +/// the page is open again, so no older close of it takes its Recently Closed row or reopens it +/// a second time. Answers the public changes. Best effort: a group it cannot read stays (and +/// so does every group, when the list cannot be read), so the reopen whose tabs already exist +/// still commits. +pub(crate) fn remove_other_closes( + transaction: &Transaction<'_>, + page: &str, +) -> anyhow::Result> { + let mut changes = Vec::new(); + let Ok(items) = closed_items(transaction) else { return Ok(changes) }; + for item in items { + let Some(id) = item.get("id").and_then(Value::as_str) else { continue }; + if closed_page(&item) != Some(page) { + continue; + } + let Ok(record) = closed_record(transaction, id) else { continue }; + let grouped = record.is_some_and(|record| { + ["tab_group", "screen_group"] + .iter() + .any(|key| record.get(*key).is_some_and(Value::is_object)) + }); + if !grouped && remove_closed(transaction, id)? { + changes.push(crate::state::store::state_delete("closed", id)); + } + } + Ok(changes) +} + /// The newest [`DEFAULT_LIST_LIMIT`] groups of every window. pub(crate) fn closed_items(connection: &Connection) -> anyhow::Result> { closed_items_in(connection, None, DEFAULT_LIST_LIMIT) From 51a713ada995726524b997e4e4dccd8ccb9b28eb Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 18:35:27 -0700 Subject: [PATCH 114/183] refactor(cmux-tui): move the Command router and connection-frame handling out of server.rs (#20771) * refactor(cmux-tui): move the Command router into server/command_dispatch.rs Pure move of handle_command_with_cancellation (868 lines) out of server.rs (cx-6so.3, godfiles.md Rust phase 1). Re-imported in server.rs, so every caller keeps its path. Co-Authored-By: Claude Opus 5.5 * refactor(cmux-tui): move connection-frame handling into server/connection_frame.rs Pure move of handle_connection_frame, handle_request(_with_cancellation), the VT-state command reply, and the auth and pairing helpers (276 lines) out of server.rs (cx-6so.3). Re-imported in server.rs, so every caller keeps its path; the one used only here stays private. Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): the spec inventory reads the Command router from server/command_dispatch.rs The checker's command profiles read handle_command_with_cancellation's arms from server.rs; the router moved (cx-6so.3). Its fixtures split the same way. Also rustfmt's import order in server.rs. Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at e2a3d3ee491a - cargo fmt in Native/OptChat/optchat-chief Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38654019843 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- cmux-tui/crates/cmux-tui-core/src/server.rs | 1153 +---------------- .../src/server/command_dispatch.rs | 873 +++++++++++++ .../src/server/connection_frame.rs | 287 ++++ cmux-tui/scripts/check-spec-inventory.py | 3 +- cmux-tui/scripts/test_check_spec_inventory.py | 14 +- scripts/cmux-next/godfile-baseline.tsv | 2 +- 6 files changed, 1184 insertions(+), 1148 deletions(-) create mode 100644 cmux-tui/crates/cmux-tui-core/src/server/command_dispatch.rs create mode 100644 cmux-tui/crates/cmux-tui-core/src/server/connection_frame.rs diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 5ef64846c92c..3690fd0f6251 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -2585,282 +2585,13 @@ use resource_connection::{ handle_resource_connection_message, resource_stream_end, send_resource_stream_item, }; -/// One frame of a connection. `transport` is the connection's own value -/// (not a registry lookup), so a remote-entry connection always takes the -/// remote path, also after its registry record is gone. -fn handle_connection_frame( - mux: &Arc, - client: u64, - transport: ClientTransport, - message: &str, - writer: &MessageWriter, - scheduler: &Arc, -) -> bool { - // Keep an idle shutdown requester connected until owner cleanup closes - // the transport, so lifecycle clients receive authoritative completion. - // A pipelined message after the acknowledgement must not reach parsing or - // dispatch; returning false makes the connection loop close that client. - if mux.daemon_shutdown_requested() || mux.daemon_handoff_committed() { - return false; - } - if matches!(transport, ClientTransport::Remote) || mux.is_remote_client(client) { - return remote_relay::handle_frame(mux, client, message, writer); - } - // Before the acknowledgement the handoff can still fail (for example - // while `end_terminals` awaits every host) and this daemon keeps - // serving. Closing here would drop the requester's pending - // `shutdown-daemon` reply along with its connection, so a message that - // arrives meanwhile (a subscriber's snapshot refresh) is refused - // without being executed and the connection stays open. - if mux.daemon_handoff_in_progress() { - return pending_handoff::reject_message_during_pending_handoff(message, writer); - } - if let Some(request) = crate::resource_router::parse_resource_line(message) { - // A v2 line the catalog parse refuses may name a routed namespace - // operation (page-router-v1): the same JSON parse decides, so every - // spelling of the line gets one answer. - if request.is_err() - && let Some(keep_open) = page_router::try_handle(mux, client, message, writer) - { - return keep_open; - } - return origin_gate::handle_resource_line(mux, client, message, request, writer); - } - if let Some(keep_open) = loopback_forward::try_handle(mux, client, message, writer) { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = browser_runtime::try_handle(mux, client, message, writer) { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = agent_session_attach::try_handle(mux, client, message, writer) - .or_else(|| agent_transfer::try_handle(mux, client, message, writer)) - { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = apps::try_handle(mux, client, message, writer) { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = scripts::try_handle(mux, client, message, writer) { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = fs_wire::try_handle(mux, client, message, writer) { - return keep_open; - } - let request = match serde_json::from_str::(message) { - Ok(request) => request, - Err(error) => return send_bad_request(writer, message, &error), - }; - let mut pending = Some(request); - match scheduler.dispatch(mux.clone(), client, &mut pending, message.len(), writer.clone()) { - Some(keep_open) => keep_open, - None => handle_request(mux, client, pending.take().unwrap(), writer), - } -} - -fn handle_request(mux: &Arc, client: u64, request: Request, writer: &MessageWriter) -> bool { - handle_request_with_cancellation(mux, client, request, writer, None) -} - -fn handle_request_with_cancellation( - mux: &Arc, - client: u64, - request: Request, - writer: &MessageWriter, - cancellation: Option<&ConnectionCancellation>, -) -> bool { - let Request { id, cmd } = request; - if let Command::UrlOpen { terminal_id, url } = cmd { - return url_open::start(mux, client, id, terminal_id, url, writer); - } - if let Command::ChiefInspect(params) = cmd { - return chief_inspect::start(mux, client, id, params, writer); - } - if cloud_conversations::is_network(&cmd) { - return cloud_conversations::start(mux, client, id, cmd, writer); - } - if let Some(target) = cloud_conversations::subscribe_target(mux, &cmd) { - return cloud_conversations::subscribe_then_announce(mux, client, id, cmd, target, writer); - } - if matches!(&cmd, Command::ShutdownDaemon { .. } | Command::ReloadConfig) - && !mux.server_lifecycle_ready() - { - return send_request_error(writer, id, "server lifecycle is not ready"); - } - if let Command::VtState { surface } = &cmd { - return match send_vt_state_command_response(mux, id.clone(), *surface, writer) { - Ok(()) => true, - Err(error) => send_request_error(writer, id, &error.to_string()), - }; - } - - let detach_self = match &cmd { - Command::DetachClient { client: target, by, surface } - if target.whole_client() == Some(client) - && own_view_detach_target(mux, target, *surface).is_none() => - { - Some(detach_actor(mux, client, by.clone())) - } - _ => None, - }; - let shutdown_daemon = matches!(&cmd, Command::ShutdownDaemon { .. }); - let (mut reason, mut retryable, mut details) = (None, None, None); - let response = match handle_command_with_cancellation(mux, client, cmd, writer, cancellation) { - Ok(data) => Response { - id, - ok: true, - data: Some(data), - error: None, - error_code: None, - error_delivery: None, - }, - Err(error) => { - reason = conversations::error_reason(&error) - .or_else(|| cloud_conversations::error_reason(&error)); - retryable = cloud_conversations::error_retryable(&error); - details = renderer_grant::error_details(&error) - .or_else(|| conversations::error_details(&error)); - let error_code = response_error_code(&error); - let error_delivery = - error.downcast_ref::().map(|error| error.delivery); - Response { - id, - ok: false, - data: None, - error: Some(error.to_string()), - error_code, - error_delivery, - } - } - }; - let (response, reason) = remote_relay::redact_response(mux, client, response, reason); - let details = details.filter(|_| !mux.is_remote_client(client)); - let response_ok = response.ok; - let sent = responses::send_response_with_details(writer, response, reason, retryable, details); - // Flush the successful acknowledgement before making the owning loop - // leave, so process teardown cannot race the response writer. - if shutdown_daemon && response_ok { - if sent { - return complete_daemon_shutdown_after_ack(mux, client, writer); - } else { - mux.cancel_daemon_handoff(client); - } - } - if let Some(by) = detach_self - && response_ok - && sent - { - kick_client(mux, client, by); - return false; - } - sent -} - -fn send_vt_state_command_response( - mux: &Mux, - id: Option, - surface: SurfaceId, - writer: &MessageWriter, -) -> anyhow::Result<()> { - // Reserve the entire wire-frame allowance before copying a replay or - // starting its base64 encoder. The writer allocates only for actual - // output and releases unused logical quota before the response is queued. - let mut output = writer.render_service.reserved_control_writer()?; - let surface = get_surface(mux, surface)?; - require_pty(&surface)?; - let (cols, rows, replay) = surface.try_with_terminal(|terminal| { - terminal - .vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) - .map(|replay| (terminal.cols(), terminal.rows(), replay)) - })??; - - write_vt_state_command_json( - &mut output, - id.as_ref(), - cols, - rows, - &replay.self_contained_bytes(), - &replay.kitty_image_aliases, - replay.kitty_state, - )?; - writer.send_serialized_control(output.finish())?; - Ok(()) -} - -fn write_vt_state_command_json( - output: &mut BudgetedJsonWriter, - id: Option<&Value>, - cols: u16, - rows: u16, - replay: &[u8], - kitty_image_aliases: &[ghostty_vt::KittyImageAlias], - kitty_state: KittyReplayState, -) -> std::io::Result<()> { - output.write_all(b"{")?; - if let Some(id) = id { - output.write_all(b"\"id\":")?; - serde_json::to_writer(&mut *output, id).map_err(json_error_to_io)?; - output.write_all(b",")?; - } - write!(output, "\"ok\":true,\"data\":{{\"cols\":{cols},\"rows\":{rows},\"data\":\"")?; - { - let mut encoder = base64::write::EncoderWriter::new( - &mut *output, - &base64::engine::general_purpose::STANDARD, - ); - encoder.write_all(replay)?; - encoder.finish()?; - } - output.write_all(b"\",\"kitty_image_aliases\":")?; - write_kitty_image_aliases_json(output, kitty_image_aliases)?; - output.write_all(b",\"kitty_graphics_state\":")?; - write_kitty_replay_state_json(output, kitty_state)?; - output.write_all(b"}}")?; - Ok(()) -} - -fn auth_token(message: &str) -> Option { - let value: Value = serde_json::from_str(message).ok()?; - let object = value.as_object()?; - if object.len() != 1 { - return None; - } - let auth = object.get("auth")?.as_object()?; - if auth.len() != 1 { - return None; - } - auth.get("token")?.as_str().map(str::to_string) -} - -fn pairing_request(message: &str) -> bool { - let Ok(value) = serde_json::from_str::(message) else { return false }; - let Some(object) = value.as_object() else { return false }; - if object.len() != 1 { - return false; - } - let Some(pair) = object.get("pair").and_then(Value::as_object) else { return false }; - pair.len() == 1 && pair.get("request").and_then(Value::as_bool) == Some(true) -} - -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - let mut difference = a.len() ^ b.len(); - let length = a.len().max(b.len()); - for index in 0..length { - difference |= - usize::from(a.get(index).copied().unwrap_or(0) ^ b.get(index).copied().unwrap_or(0)); - } - difference == 0 -} - -fn zeroize_string(value: &mut str) { - // NUL remains valid UTF-8, so decoded control frames can be cleared in - // place immediately after dispatch. - value.zeroize(); -} +mod connection_frame; +#[cfg(test)] +use connection_frame::write_vt_state_command_json; +use connection_frame::{ + auth_token, constant_time_eq, handle_connection_frame, handle_request, + handle_request_with_cancellation, pairing_request, zeroize_string, +}; fn node_json(node: &Node, active_pane: PaneId) -> Value { match node { @@ -2973,874 +2704,8 @@ fn handle_command( handle_command_with_cancellation(mux, client, cmd, writer, None) } -fn handle_command_with_cancellation( - mux: &Arc, - client: u64, - cmd: Command, - writer: &MessageWriter, - cancellation: Option<&ConnectionCancellation>, -) -> anyhow::Result { - // Who a durable legacy command acts as (P8): read once per command. - let actor = origin_gate::connection_actor(mux, client); - if let Some(remote) = remote_relay::intercept(mux, client, &cmd, writer) { - return remote; - } - match cmd { - Command::SubscribeActivity => cmd_subscribe::subscribe_activity(mux, client, writer), - cmd @ (Command::UrlOpenSubscribe { .. } - | Command::UrlOpenClaim { .. } - | Command::UrlOpenResult { .. }) => url_open::handle(mux, client, cmd, writer), - cmd @ (Command::TerminalClipboardSubscribe { .. } - | Command::TerminalClipboardReply { .. }) => { - clipboard_read::handle(mux, client, cmd, writer) - } - Command::UrlOpen { .. } => { - anyhow::bail!("URL opening requires the asynchronous request path") - } - Command::ChiefInspect(_) => { - anyhow::bail!("chief-inspect requires the asynchronous request path") - } - Command::PasteImage { - surface, - terminal_id, - lease, - upload_id, - op, - mime, - size, - offset, - data, - } => cmd_terminal_io::paste_image( - mux, - client, - surface, - terminal_id, - lease, - upload_id, - op, - mime, - size, - offset, - data, - ), - command @ (Command::SetTerminalCommandHistory(_) - | Command::ListTerminalCommands(_) - | Command::DeleteTerminalCommands(_)) => command_history::handle(mux, client, command), - Command::ServerStats { include } => cmd_server::server_stats(mux, client, include), - Command::BrowserHostProvider => cmd_browser::browser_host_provider(mux, client), - Command::Identify => cmd_server::identify(mux), - Command::ShutdownDaemon { pid, generation, force, end_terminals, keep_layout } => { - cmd_server::shutdown_daemon( - mux, - client, - pid, - generation, - force, - end_terminals, - keep_layout, - ) - } - Command::Ping => cmd_server::ping(), - Command::SetClientInfo { - name, - kind, - capabilities, - user_id, - display_name, - device_kind, - device_name, - device_id, - } => cmd_server::set_client_info( - mux, - client, - name, - kind, - capabilities, - user_id, - display_name, - device_kind, - device_name, - device_id, - ), - Command::ListClients => cmd_server::list_clients(mux, client), - Command::MachineUsage => cmd_server::machine_usage(mux), - Command::MachineListeningTcp => cmd_server::machine_listening_tcp(), - Command::RegisterBrowserProvider { - provider_id, - endpoint, - authentication, - bearer_token, - hosting, - targets, - } => cmd_browser::register_browser_provider( - mux, - client, - cmd_browser::BrowserProviderRequest { - provider_id, - endpoint, - authentication, - bearer_token, - hosting, - targets, - }, - ), - Command::GetBrowserProvider => cmd_browser::get_browser_provider(mux, client), - Command::UnregisterBrowserProvider => cmd_browser::unregister_browser_provider(mux, client), - Command::ListTerminals => cmd_terminals::list_terminals(mux), - Command::TerminalEvents { after_revision } => { - cmd_terminals::terminal_events(mux, after_revision) - } - Command::SetClientSizing { surface, client: target, enabled, exclusive } => { - cmd_sizing::set_client_sizing(mux, client, surface, target, enabled, exclusive) - } - Command::PairingResponse { request, approve } => { - cmd_server::pairing_response(mux, client, request, approve) - } - Command::DetachClient { client: target, by, surface } => { - cmd_attach::detach_client(mux, client, target, by, surface) - } - Command::SetSizePolicy { surface, workspace, policy } => { - cmd_sizing::set_size_policy(mux, client, surface, workspace, policy) - } - Command::SetSizeCounts { surface, client: target, lease, view, participant, counts } => { - cmd_sizing::set_size_counts( - mux, - client, - surface, - target, - lease, - view, - participant, - counts, - ) - } - Command::NoteSizeActivity { surface, view } => { - cmd_sizing::note_size_activity(mux, client, surface, view) - } - Command::ReattachView { surface, counts } => { - cmd_attach::reattach_view(mux, client, surface, counts) - } - Command::GetSizeState { surface } => cmd_sizing::get_size_state(mux, client, surface), - Command::ReloadConfig => cmd_server::reload_config(mux), - Command::SetWindowTitle { title } => cmd_server::set_window_title(mux, title), - Command::ClearWindowTitle => cmd_server::clear_window_title(mux), - Command::ListWorkspaces => cmd_workspaces::list_workspaces(mux), - Command::GetFrontendProjection { frontend, scope, subject_key } => { - cmd_frontend::get_frontend_projection(mux, frontend, scope, subject_key) - } - Command::PutFrontendProjection { - frontend, - scope, - subject_key, - schema_version, - expected_projection_revision, - projection, - mutation, - } => cmd_frontend::put_frontend_projection( - mux, - client, - frontend, - scope, - subject_key, - schema_version, - expected_projection_revision, - projection, - mutation, - ), - Command::JournalFrontendEvent { event } => { - cmd_frontend::journal_frontend_event(mux, client, event) - } - Command::ExportLayout { screen } => cmd_panes::export_layout(mux, screen), - Command::ApplyLayout { workspace, name, layout, cols, rows } => { - cmd_panes::apply_layout(mux, actor, workspace, name, layout, cols, rows) - } - Command::Send { surface, terminal_id, text, bytes, paste } => { - cmd_terminal_io::send(mux, client, writer, surface, terminal_id, text, bytes, paste) - } - Command::ReadScreen { surface } => cmd_terminal_io::read_screen(mux, surface), - Command::ClearHistory { surface, fallback_key } => { - cmd_terminal_io::clear_history(mux, surface, fallback_key) - } - Command::ReadScrollback { surface, start, count } => { - cmd_terminal_io::read_scrollback(mux, surface, start, count) - } - Command::SidebarPlugin { cols, rows, relaunch } => { - cmd_frontend::sidebar_plugin(mux, cols, rows, relaunch) - } - Command::WaitFor { surface, pattern, timeout_ms } => { - cmd_terminal_io::wait_for(mux, cancellation, surface, pattern, timeout_ms) - } - Command::Run { argv, command, cwd, pane, new_workspace, key, name, cols, rows } => { - cmd_terminal_io::run( - mux, - actor, - argv, - command, - cwd, - pane, - new_workspace, - key, - name, - cols, - rows, - ) - } - Command::CreateSurfaceWithReceipt(request) => { - cmd_frontend::create_surface_with_receipt_command(mux, client, request) - } - Command::SendKey { surface, keys } => cmd_terminal_io::send_key(mux, client, surface, keys), - Command::Copy { surface, mode } => cmd_terminal_io::copy(mux, surface, mode), - Command::Ids { kind } => cmd_frontend::ids(mux, kind), - Command::Notify { title, body, level, surface, source } => { - cmd_frontend::notify(mux, actor, title, body, level, surface, source) - } - Command::ListAgents { surface, state } => cmd_frontend::list_agents(mux, surface, state), - Command::ReportAgent { surface, state, source, session } => { - cmd_frontend::report_agent(mux, surface, state, source, session) - } - Command::VtState { .. } => unreachable!("vt-state uses its streaming response path"), - Command::MintTerminalRenderer { surface, ttl_ms } => { - cmd_terminals::mint_terminal_renderer(mux, client, surface, ttl_ms) - } - Command::MintTerminalRendererByTerminal { terminal, ttl_ms } => { - cmd_terminals::mint_terminal_renderer_by_terminal(mux, client, terminal, ttl_ms) - } - Command::ResolveTerminal { terminal_id } => { - cmd_terminals::resolve_terminal(mux, terminal_id) - } - Command::CloseTerminal { terminal_id, terminal_incarnation, mutation } => { - cmd_terminals::close_terminal(mux, client, terminal_id, terminal_incarnation, mutation) - } - Command::SetTerminalIdlePolicy { surface, terminal_id, idle_close_seconds } => { - cmd_terminals::set_terminal_idle_policy(mux, surface, terminal_id, idle_close_seconds) - } - Command::SetTerminalKeep { surface, terminal_id, keep } => { - cmd_terminals::set_terminal_keep(mux, surface, terminal_id, keep) - } - Command::NewTab { pane, cwd, env, cols, rows, keep, terminal_id, shell_args } => { - cmd_tabs::new_tab( - mux, - client, - actor, - pane, - cwd, - env, - cols, - rows, - keep, - terminal_id, - shell_args, - ) - } - Command::NewConversationTab(params) => cmd_tabs::new_conversation_tab(mux, client, params), - Command::BindConversationTabSession(params) => { - cmd_tabs::bind_conversation_tab_session(mux, actor, params) - } - Command::RestartTab(params) => tab_restart::restart(mux, params), - Command::NewFrontendBrowserTab(params) => { - cmd_tabs::new_frontend_browser_tab(mux, client, params) - } - Command::UpdateFrontendBrowserTab(params) => { - cmd_tabs::update_frontend_browser_tab(mux, actor, params) - } - Command::SetFrontendBrowserHistory(params) => frontend_browser_history::set(mux, params), - Command::GetFrontendBrowserHistory(params) => frontend_browser_history::get(mux, params), - Command::NewRemoteTerminalTab(p) => remote_terminal_tabs_wire::create(mux, &actor, p), - Command::UpdateRemoteTerminalTab(p) => remote_terminal_tabs_wire::update(mux, p), - Command::RemoteTerminalSnapshot(p) => remote_terminal_tabs_wire::snapshot(mux, p), - Command::NewBrowserTab { url, pane, cols, rows } => { - cmd_tabs::new_browser_tab(mux, actor, url, pane, cols, rows) - } - Command::GetCellPixels => cmd_sizing::get_cell_pixels(mux), - Command::SetCellPixels { width_px, height_px } => { - cmd_sizing::set_cell_pixels(mux, width_px, height_px) - } - Command::BrowserFramePresented { surface, frame_seq } => { - cmd_browser::browser_frame_presented(mux, client, surface, frame_seq) - } - cmd @ (Command::BrowserMouse { .. } - | Command::BrowserMouseGuarded { .. } - | Command::BrowserWheel { .. } - | Command::BrowserWheelGuarded { .. } - | Command::BrowserKey { .. } - | Command::BrowserKeyPress { .. } - | Command::BrowserInsertText { .. }) => browser_input::handle(mux, client, cmd), - Command::BrowserNavigate { surface, url } => { - cmd_browser::browser_navigate(mux, surface, url) - } - Command::BrowserBack { surface } => cmd_browser::browser_back(mux, surface), - Command::BrowserForward { surface } => cmd_browser::browser_forward(mux, surface), - Command::BrowserReload { surface } => cmd_browser::browser_reload(mux, surface), - Command::BrowserActivate { surface } => cmd_browser::browser_activate(mux, surface), - Command::NewWorkspace { name, cols, rows } => { - cmd_workspaces::new_workspace(mux, actor, name, cols, rows) - } - Command::CreateWorkspace { name, key, mutation } => { - cmd_workspaces::create_workspace(mux, client, name, key, mutation) - } - Command::CreateTerminal { - workspace, - key, - argv, - shell_args, - command, - cwd, - name, - cols, - rows, - terminal_id, - env, - keep, - detached, - mutation, - } => cmd_terminals::create_terminal( - mux, - client, - actor, - workspace, - key, - argv, - shell_args, - command, - cwd, - name, - cols, - rows, - terminal_id, - env, - keep, - detached, - mutation, - ), - Command::NewScreen(params) => cmd_screens::new_screen(mux, client, params), - Command::SetScreenMetadata { screen, color, icon } => { - cmd_screens::set_screen_metadata(mux, actor, screen, color, icon) - } - Command::SetScreenPinned { screen, pinned } => { - cmd_screens::set_screen_pinned(mux, actor, screen, pinned) - } - Command::MoveScreen { screen, index, workspace, new_workspace } => { - cmd_screens::move_screen(mux, actor, screen, index, workspace, new_workspace) - } - Command::CreateScreenGroup { screens, name, color } => { - cmd_screens::create_screen_group(mux, actor, screens, name, color) - } - Command::UpdateScreenGroup { group, name, color, collapsed, icon } => { - cmd_screens::update_screen_group(mux, actor, group, name, color, collapsed, icon) - } - Command::AddScreensToScreenGroup { group, screens, index } => { - cmd_screens::add_screens_to_screen_group(mux, actor, group, screens, index) - } - Command::RemoveScreensFromScreenGroup { screens } => { - cmd_screens::remove_screens_from_screen_group(mux, actor, screens) - } - Command::MoveScreenGroup { group, index, workspace, new_workspace } => { - cmd_screens::move_screen_group(mux, actor, group, index, workspace, new_workspace) - } - Command::UngroupScreenGroup { group } => { - cmd_screens::ungroup_screen_group(mux, actor, group) - } - Command::CloseScreenGroup { group, end_terminals } => { - cmd_screens::close_screen_group(mux, actor, group, end_terminals) - } - Command::ListSavedScreenGroups => cmd_screens::list_saved_screen_groups(mux), - Command::SaveScreenGroup { group } => cmd_screens::save_screen_group(mux, actor, group), - Command::UnsaveScreenGroup { group } => cmd_screens::unsave_screen_group(mux, group), - Command::DeleteSavedScreenGroup { saved } => { - cmd_screens::delete_saved_screen_group(mux, saved) - } - Command::ReopenSavedScreenGroup { saved, workspace } => { - cmd_screens::reopen_saved_screen_group(mux, actor, saved, workspace) - } - command @ Command::NewPane { .. } => cmd_panes::new_pane(mux, client, actor, command), - Command::NewPaneRight(params) => cmd_panes::new_pane_right(mux, client, params), - Command::Split(params) => cmd_panes::split(mux, client, params), - Command::SetRatio { pane, dir, ratio } => { - cmd_panes::set_ratio(mux, actor, pane, dir, ratio) - } - Command::SetSplitRatio { split, ratio, transaction } => { - cmd_panes::set_split_ratio(mux, client, actor, split, ratio, transaction) - } - Command::SetViewportPaneWidth { pane, width, transaction } => { - cmd_panes::set_viewport_pane_width(mux, client, actor, pane, width, transaction) - } - Command::SetColumnDock { pane, dock, edge, mode, role, permanent, transaction } => { - cmd_panes::set_column_dock( - mux, - client, - actor, - pane, - dock, - edge, - mode, - role, - permanent, - transaction, - ) - } - Command::UndoLayout { pane, revision, confirm_close } => { - cmd_panes::undo_layout(mux, actor, pane, revision, confirm_close) - } - Command::PaneNeighbor { pane, dir } => cmd_panes::pane_neighbor(mux, pane, dir), - Command::FocusDirection { pane, dir } => cmd_panes::focus_direction(mux, actor, pane, dir), - Command::SwapPane { pane, dir, target } => { - cmd_panes::swap_pane(mux, actor, pane, dir, target) - } - Command::ZoomPane { pane, mode } => cmd_panes::zoom_pane(mux, actor, pane, mode), - Command::ProcessInfo { surface } => cmd_terminals::process_info(mux, surface), - Command::TerminalResources { surfaces } => cmd_terminals::terminal_resources(mux, surfaces), - Command::MoveTerminal { terminal_id, workspace_key, terminal_incarnation, mutation } => { - cmd_terminals::move_terminal( - mux, - client, - terminal_id, - workspace_key, - terminal_incarnation, - mutation, - ) - } - Command::MoveTabToWorkspace { surface, workspace, transaction } => { - cmd_tabs::move_tab_to_workspace(mux, actor, surface, workspace, transaction) - } - Command::MoveTabToSplit { - surface, - pane, - edge, - ratio, - respawn, - keep_focus, - transaction, - } => cmd_tabs::move_tab_to_split( - mux, - client, - surface, - pane, - edge, - ratio, - respawn, - keep_focus, - transaction, - ), - Command::MoveTabToColumn(params) => cmd_tabs::move_tab_to_column(mux, client, params), - Command::NewRow(params) => cmd_panes::new_row(mux, client, params), - Command::SetRowHeights(params) => cmd_panes::set_row_heights(mux, client, params), - Command::MoveTabToNewWorkspace { surface, group, index, name, transaction } => { - cmd_tabs::move_tab_to_new_workspace( - mux, - actor, - surface, - group, - index, - name, - transaction, - ) - } - Command::MoveTab { surface, pane, index, transaction } => { - cmd_tabs::move_tab(mux, actor, surface, pane, index, transaction) - } - Command::ListTabGroups => cmd_tabs::list_tab_groups(mux), - Command::CreateTabGroup { surfaces, name, color, group, transaction } => { - cmd_tabs::create_tab_group(mux, actor, surfaces, name, color, group, transaction) - } - Command::UpdateTabGroup { group, name, color, collapsed, icon } => { - cmd_tabs::update_tab_group(mux, actor, group, name, color, collapsed, icon) - } - Command::AddTabsToTabGroup { group, surfaces, transaction } => { - cmd_tabs::add_tabs_to_tab_group(mux, actor, group, surfaces, transaction) - } - Command::RemoveTabsFromTabGroup { surfaces, transaction } => { - cmd_tabs::remove_tabs_from_tab_group(mux, actor, surfaces, transaction) - } - Command::MoveTabGroup { group, pane, index, transaction } => { - cmd_tabs::move_tab_group(mux, actor, group, pane, index, transaction) - } - Command::MoveTabGroupToSplit { group, pane, edge, ratio, transaction } => { - cmd_tabs::move_tab_group_to_split(mux, actor, group, pane, edge, ratio, transaction) - } - Command::MoveTabGroupToColumn { group, pane, screen, after_column, width, transaction } => { - cmd_tabs::move_tab_group_to_column( - mux, - actor, - group, - pane, - screen, - after_column, - width, - transaction, - ) - } - Command::MoveTabGroupToNewWorkspace { group, workspace_group, index, transaction } => { - cmd_tabs::move_tab_group_to_new_workspace( - mux, - actor, - group, - workspace_group, - index, - transaction, - ) - } - Command::UngroupTabGroup { group } => cmd_tabs::ungroup_tab_group(mux, actor, group), - Command::CloseTabGroup { group, end_terminals } => { - cmd_tabs::close_tab_group(mux, actor, group, end_terminals) - } - Command::ListSavedTabGroups => cmd_tabs::list_saved_tab_groups(mux), - Command::SaveTabGroup { group } => cmd_tabs::save_tab_group(mux, actor, group), - Command::UnsaveTabGroup { group } => cmd_tabs::unsave_tab_group(mux, actor, group), - Command::DeleteSavedTabGroup { saved } => { - cmd_tabs::delete_saved_tab_group(mux, actor, saved) - } - Command::ReopenSavedTabGroup { saved, pane, transaction } => { - cmd_tabs::reopen_saved_tab_group(mux, actor, saved, pane, transaction) - } - Command::AckTabNotifications { surface } => cmd_tabs::ack_tab_notifications(mux, surface), - Command::ListNotifications { limit } => cmd_frontend::list_notifications(mux, limit), - Command::SetTabPinned { surface, pinned } => { - cmd_tabs::set_tab_pinned(mux, actor, surface, pinned) - } - Command::MoveWorkspace { workspace, key, index, mutation } => { - cmd_workspaces::move_workspace(mux, client, workspace, key, index, mutation) - } - Command::SetWorkspaceMetadata { - workspace, - key, - color, - icon, - title, - pinned, - marked_unread, - mutation, - } => cmd_workspaces::set_workspace_metadata( - mux, - client, - workspace, - key, - color, - icon, - title, - pinned, - marked_unread, - mutation, - ), - Command::ListPersonal => cmd_profiles::list_personal(mux), - Command::CreateBrowserProfile(params) => browser_profiles::create(mux, params), - Command::UpdateBrowserProfile(params) => browser_profiles::update(mux, params), - Command::MoveBrowserProfile(params) => browser_profiles::move_to(mux, params), - Command::DeleteBrowserProfile(params) => browser_profiles::delete(mux, client, params), - Command::ListBookmarks(params) => bookmarks::list(mux, params), - Command::CreateBookmark(params) => bookmarks::create(mux, &actor, params), - Command::UpdateBookmark(params) => bookmarks::update(mux, &actor, params), - Command::MoveBookmark(params) => bookmarks::move_to(mux, &actor, params), - Command::DeleteBookmark(params) => bookmarks::delete(mux, &actor, params), - Command::ImportBookmarks(params) => bookmarks::import(mux, &actor, params), - cmd @ (Command::FeedLocalList(_) - | Command::FeedLocalRead(_) - | Command::FeedLocalHandoffBegin(_) - | Command::FeedLocalHandoffAbort(_) - | Command::FeedLocalHandoffDone(_)) => feed_local::dispatch(mux, &actor, cmd), - Command::PutBlob(params) => icon_assets::put(mux, params), - Command::GetBlob(params) => icon_assets::get(mux, params), - Command::ConversationList => conversations::list(mux, client), - Command::ConversationCreate(params) => conversations::create(mux, client, params), - Command::ConversationSnapshot(params) => conversations::snapshot(mux, client, params), - Command::ConversationHistory(params) => conversations::history(mux, client, params), - Command::ConversationThread(params) => conversations::thread(mux, client, params), - Command::ConversationLinkResolve(params) => { - conversations::link_resolve(mux, client, params) - } - Command::ConversationInboxOp(params) => conversations::inbox_op(mux, client, params), - Command::ConversationSearch(params) => conversations::search(mux, client, params), - Command::HistorySearch(params) => history_search::search(mux, client, params), - Command::ConversationOp(params) => conversations::op(mux, client, params), - Command::ConversationTyping(params) => conversations::typing(mux, client, params), - Command::ConversationDraft(params) => conversations::draft(mux, client, params), - Command::ConversationBind(params) => conversations::bind(mux, client, params), - Command::ConversationAgentToken(params) => conversations::agent_token(mux, client, params), - Command::CloudSessionSet(params) => cloud_conversations::session_set(mux, client, params), - Command::CloudSessionClear => cloud_conversations::session_clear(mux, client), - Command::CloudSessionStatus => cloud_conversations::session_status(mux, client), - Command::PageCredential(params) => page_credential::page_credential(mux, client, params), - Command::WsListen(params) => page_listener::ws_listen(mux, client, params), - Command::CloudInboxList(params) => cloud_conversations::inbox_list(mux, client, params), - Command::CloudConversationSnapshot(params) => { - cloud_conversations::snapshot(mux, client, params) - } - Command::CloudConversationHistory(params) => { - cloud_conversations::history(mux, client, params) - } - Command::CloudConversationThread(params) => { - cloud_conversations::thread(mux, client, params) - } - Command::CloudConversationRuns(params) => cloud_conversations::runs(mux, client, params), - Command::CloudConversationRun(params) => cloud_conversations::run(mux, client, params), - Command::CloudParticipantAvatarGet(params) => { - cloud_conversations::participant_avatar(mux, client, params) - } - Command::CloudConversationOp(params) => cloud_conversations::op(mux, client, params), - Command::CloudInboxOp(params) => cloud_conversations::inbox_op(mux, client, params), - Command::CloudInboxSubscribe => cloud_conversations::subscribe(mux, client, None), - Command::CloudMuxSubscribe(_) => cloud_conversations::mux_subscribe(mux, client), - Command::CloudMuxUnsubscribe(_) => cloud_conversations::mux_unsubscribe(mux, client), - Command::CloudMuxAck(params) => cloud_conversations::mux_ack(mux, client, params), - Command::CloudInboxUnsubscribe => cloud_conversations::unsubscribe(mux, client, None), - Command::CloudConversationSubscribe(params) => { - cloud_conversations::subscribe(mux, client, Some(params)) - } - Command::CloudConversationUnsubscribe(params) => { - cloud_conversations::unsubscribe(mux, client, Some(params)) - } - Command::ConversationAttachmentUpload(p) => conversation_attachments::put(mux, client, p), - Command::ConversationAttachmentRead(p) => conversation_attachments::read(mux, client, p), - Command::ConversationAttachmentLink(p) => conversation_attachments::link(mux, client, p), - Command::CloudAttachmentUpload(p) => cloud_attachments::put(mux, client, p), - Command::CloudAttachmentRead(p) => cloud_attachments::read(mux, client, p), - Command::CloudAttachmentLink(p) => cloud_attachments::link(mux, client, p), - Command::ConversationRunAppend(p) => conversation_runs::append(mux, client, p), - Command::ConversationRuns(p) => conversation_runs::runs(mux, client, p), - Command::ConversationRun(p) => conversation_runs::run(mux, client, p), - Command::ConversationImport(params) => conversations::import(mux, client, params), - Command::CreateProfile { - name, - profile, - color, - icon, - theme, - index, - browser_profile_id, - default_session_id, - defaults, - follows, - } => cmd_profiles::create_profile( - mux, - name, - profile, - color, - icon, - theme, - index, - browser_profile_id, - default_session_id, - defaults, - follows, - ), - Command::UpdateProfile { - profile, - name, - color, - icon, - theme, - browser_profile_id, - default_session_id, - defaults, - } => cmd_profiles::update_profile( - mux, - profile, - name, - color, - icon, - theme, - browser_profile_id, - default_session_id, - defaults, - ), - Command::MoveProfile { profile, index } => cmd_profiles::move_profile(mux, profile, index), - Command::DeleteProfile { profile, move_to } => { - cmd_profiles::delete_profile(mux, client, profile, move_to) - } - Command::SetProfileFollows { profile, session_ids } => { - cmd_profiles::set_profile_follows(mux, profile, session_ids) - } - Command::PinWorkspace { session_id, workspace_key, profile } => { - cmd_workspaces::pin_workspace(mux, session_id, workspace_key, profile) - } - Command::UnpinWorkspace { session_id, workspace_key } => { - cmd_workspaces::unpin_workspace(mux, session_id, workspace_key) - } - Command::PutSession { - session_id, - machine_name, - session_name, - transport, - capabilities, - follow_with, - } => cmd_profiles::put_session( - mux, - session_id, - machine_name, - session_name, - transport, - capabilities, - follow_with, - ), - Command::ForgetSession { session_id, force } => { - cmd_profiles::forget_session(mux, session_id, force) - } - Command::SetSessionIcon(params) => personal::set_session_icon(mux, params), - Command::ImportSessionOrganization { session_id, groups, workspaces } => { - cmd_profiles::import_session_organization(mux, session_id, groups, workspaces) - } - Command::CreatePersonalGroup { name, group, profile, color, collapsed, index } => { - cmd_profiles::create_personal_group(mux, name, group, profile, color, collapsed, index) - } - Command::UpdatePersonalGroup { group, name, color, collapsed, profile } => { - cmd_profiles::update_personal_group(mux, group, name, color, collapsed, profile) - } - Command::DeletePersonalGroup { group } => { - cmd_profiles::delete_personal_group(mux, client, group) - } - Command::MovePersonalGroup { group, index } => { - cmd_profiles::move_personal_group(mux, group, index) - } - command @ Command::SetPersonalWorkspace { .. } => { - cmd_profiles::set_personal_workspace(mux, command) - } - Command::SetPersonalTerminal { session_id, terminal_key, theme } => { - cmd_profiles::set_personal_terminal(mux, session_id, terminal_key, theme) - } - Command::ListWorkspaceGroups => cmd_workspaces::list_workspace_groups(mux), - Command::CreateWorkspaceGroup { name, group, color, collapsed, index } => { - cmd_workspaces::create_workspace_group(mux, name, group, color, collapsed, index) - } - Command::UpdateWorkspaceGroup { group, name, color, collapsed } => { - cmd_workspaces::update_workspace_group(mux, group, name, color, collapsed) - } - Command::DeleteWorkspaceGroup { group } => { - cmd_workspaces::delete_workspace_group(mux, group) - } - Command::MoveWorkspaceGroup { group, index } => { - cmd_workspaces::move_workspace_group(mux, group, index) - } - Command::MoveWorkspaceToGroup { workspace, key, group, index, mutation } => { - cmd_workspaces::move_workspace_to_group( - mux, client, workspace, key, group, index, mutation, - ) - } - Command::SetDefaultColors { - fg, - bg, - cursor, - selection_bg, - selection_fg, - cursor_style, - cursor_blink, - palette, - complete, - } => cmd_terminal_io::set_default_colors( - mux, - fg, - bg, - cursor, - selection_bg, - selection_fg, - cursor_style, - cursor_blink, - palette, - complete, - ), - Command::CloseSurface { surface } => cmd_tabs::close_surface(mux, actor, surface), - Command::CloseTabs { surfaces, end_terminals, transaction, reason, mutation } => { - cmd_tabs::close_tabs( - mux, - client, - surfaces, - end_terminals, - transaction, - reason, - mutation, - ) - } - Command::ClosePane { pane, end_terminals } => { - cmd_panes::close_pane(mux, actor, pane, end_terminals) - } - Command::CloseScreen { screen, end_terminals } => { - cmd_screens::close_screen(mux, actor, screen, end_terminals) - } - Command::CloseWorkspace { workspace, key, end_terminals, reason, if_empty, mutation } => { - let ending = cmd_workspaces::Ending { terminals: end_terminals, reason, if_empty }; - cmd_workspaces::close_workspace(mux, client, workspace, key, ending, mutation) - } - Command::MarkWorkspacesProviderManaged { authority } => { - cmd_workspaces::mark_workspaces_provider_managed(mux, authority) - } - Command::CloseProviderManagedWorkspace { workspace, key, authority } => { - cmd_workspaces::close_provider_managed_workspace(mux, actor, workspace, key, authority) - } - Command::RenamePane { pane, name } => cmd_panes::rename_pane(mux, actor, pane, name), - Command::RenameSurface { surface, name } => { - cmd_tabs::rename_surface(mux, actor, surface, name) - } - Command::RenameScreen { screen, name } => { - cmd_screens::rename_screen(mux, actor, screen, name) - } - Command::RenameWorkspace { workspace, key, name, mutation } => { - cmd_workspaces::rename_workspace(mux, client, workspace, key, name, mutation) - } - Command::RenameProviderManagedWorkspace { workspace, key, name, authority } => { - cmd_workspaces::rename_provider_managed_workspace( - mux, actor, workspace, key, name, authority, - ) - } - Command::ResizeSurface { surface, cols, rows } => { - cmd_sizing::resize_surface(mux, client, surface, cols, rows) - } - Command::ResizeAttachedView { surface, lease, view, identity, cols, rows } => { - cmd_sizing::resize_attached_view( - mux, client, surface, lease, view, identity, cols, rows, - ) - } - Command::ReleaseSurfaceSize { surface } => { - cmd_sizing::release_surface_size(mux, client, surface) - } - Command::ReleaseAttachedViewSize { surface, lease, view } => { - cmd_sizing::release_attached_view_size(mux, client, surface, lease, view) - } - Command::DetachAttachedView { surface, lease, view } => { - cmd_attach::detach_attached_view(mux, client, surface, lease, view) - } - Command::FocusPane { pane } => cmd_panes::focus_pane(mux, actor, pane), - Command::SelectTab { pane, index, delta } => { - cmd_tabs::select_tab(mux, actor, pane, index, delta) - } - Command::SelectScreen { index, delta } => { - cmd_screens::select_screen(mux, actor, index, delta) - } - Command::SelectWorkspace { index, delta } => { - cmd_workspaces::select_workspace(mux, actor, index, delta) - } - Command::ReportFocus { client_id, pane, tab } => { - cmd_server::report_focus(mux, client_id, pane, tab) - } - Command::ClientFocus { client_id } => cmd_server::client_focus(mux, client_id), - Command::SnapshotRequest(params) => cmd_terminal_io::snapshot_request(mux, client, params), - Command::TerminalHistory(params) => cmd_terminals::terminal_history(mux, params), - Command::TerminalReadRange(params) => cmd_terminals::terminal_read_range(mux, params), - Command::ScrollSurface { surface, delta } => { - cmd_terminal_io::scroll_surface(mux, surface, delta) - } - Command::Subscribe { tree_events, surface } => { - cmd_subscribe::subscribe_command(mux, client, writer, tree_events, surface) - } - Command::AttachSurface { - surface: surface_id, - mode, - cols, - rows, - expected_generation, - expected_terminal_id, - snapshot, - } => cmd_attach::attach_surface( - mux, - client, - writer, - surface_id, - mode, - cols, - rows, - expected_generation, - expected_terminal_id, - snapshot, - ), - } -} +mod command_dispatch; +use command_dispatch::handle_command_with_cancellation; /// The `list-workspaces` reply: the tree plus the registry identity it /// belongs to. The launch snapshot stores the same value. diff --git a/cmux-tui/crates/cmux-tui-core/src/server/command_dispatch.rs b/cmux-tui/crates/cmux-tui-core/src/server/command_dispatch.rs new file mode 100644 index 000000000000..b43bab121660 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/server/command_dispatch.rs @@ -0,0 +1,873 @@ +//! The `Command` router: one arm per command, each calling its family's handler +//! (`cmd_*` modules). Moved out of server.rs unchanged (cx-6so.3). + +use super::*; + +pub(super) fn handle_command_with_cancellation( + mux: &Arc, + client: u64, + cmd: Command, + writer: &MessageWriter, + cancellation: Option<&ConnectionCancellation>, +) -> anyhow::Result { + // Who a durable legacy command acts as (P8): read once per command. + let actor = origin_gate::connection_actor(mux, client); + if let Some(remote) = remote_relay::intercept(mux, client, &cmd, writer) { + return remote; + } + match cmd { + Command::SubscribeActivity => cmd_subscribe::subscribe_activity(mux, client, writer), + cmd @ (Command::UrlOpenSubscribe { .. } + | Command::UrlOpenClaim { .. } + | Command::UrlOpenResult { .. }) => url_open::handle(mux, client, cmd, writer), + cmd @ (Command::TerminalClipboardSubscribe { .. } + | Command::TerminalClipboardReply { .. }) => { + clipboard_read::handle(mux, client, cmd, writer) + } + Command::UrlOpen { .. } => { + anyhow::bail!("URL opening requires the asynchronous request path") + } + Command::ChiefInspect(_) => { + anyhow::bail!("chief-inspect requires the asynchronous request path") + } + Command::PasteImage { + surface, + terminal_id, + lease, + upload_id, + op, + mime, + size, + offset, + data, + } => cmd_terminal_io::paste_image( + mux, + client, + surface, + terminal_id, + lease, + upload_id, + op, + mime, + size, + offset, + data, + ), + command @ (Command::SetTerminalCommandHistory(_) + | Command::ListTerminalCommands(_) + | Command::DeleteTerminalCommands(_)) => command_history::handle(mux, client, command), + Command::ServerStats { include } => cmd_server::server_stats(mux, client, include), + Command::BrowserHostProvider => cmd_browser::browser_host_provider(mux, client), + Command::Identify => cmd_server::identify(mux), + Command::ShutdownDaemon { pid, generation, force, end_terminals, keep_layout } => { + cmd_server::shutdown_daemon( + mux, + client, + pid, + generation, + force, + end_terminals, + keep_layout, + ) + } + Command::Ping => cmd_server::ping(), + Command::SetClientInfo { + name, + kind, + capabilities, + user_id, + display_name, + device_kind, + device_name, + device_id, + } => cmd_server::set_client_info( + mux, + client, + name, + kind, + capabilities, + user_id, + display_name, + device_kind, + device_name, + device_id, + ), + Command::ListClients => cmd_server::list_clients(mux, client), + Command::MachineUsage => cmd_server::machine_usage(mux), + Command::MachineListeningTcp => cmd_server::machine_listening_tcp(), + Command::RegisterBrowserProvider { + provider_id, + endpoint, + authentication, + bearer_token, + hosting, + targets, + } => cmd_browser::register_browser_provider( + mux, + client, + cmd_browser::BrowserProviderRequest { + provider_id, + endpoint, + authentication, + bearer_token, + hosting, + targets, + }, + ), + Command::GetBrowserProvider => cmd_browser::get_browser_provider(mux, client), + Command::UnregisterBrowserProvider => cmd_browser::unregister_browser_provider(mux, client), + Command::ListTerminals => cmd_terminals::list_terminals(mux), + Command::TerminalEvents { after_revision } => { + cmd_terminals::terminal_events(mux, after_revision) + } + Command::SetClientSizing { surface, client: target, enabled, exclusive } => { + cmd_sizing::set_client_sizing(mux, client, surface, target, enabled, exclusive) + } + Command::PairingResponse { request, approve } => { + cmd_server::pairing_response(mux, client, request, approve) + } + Command::DetachClient { client: target, by, surface } => { + cmd_attach::detach_client(mux, client, target, by, surface) + } + Command::SetSizePolicy { surface, workspace, policy } => { + cmd_sizing::set_size_policy(mux, client, surface, workspace, policy) + } + Command::SetSizeCounts { surface, client: target, lease, view, participant, counts } => { + cmd_sizing::set_size_counts( + mux, + client, + surface, + target, + lease, + view, + participant, + counts, + ) + } + Command::NoteSizeActivity { surface, view } => { + cmd_sizing::note_size_activity(mux, client, surface, view) + } + Command::ReattachView { surface, counts } => { + cmd_attach::reattach_view(mux, client, surface, counts) + } + Command::GetSizeState { surface } => cmd_sizing::get_size_state(mux, client, surface), + Command::ReloadConfig => cmd_server::reload_config(mux), + Command::SetWindowTitle { title } => cmd_server::set_window_title(mux, title), + Command::ClearWindowTitle => cmd_server::clear_window_title(mux), + Command::ListWorkspaces => cmd_workspaces::list_workspaces(mux), + Command::GetFrontendProjection { frontend, scope, subject_key } => { + cmd_frontend::get_frontend_projection(mux, frontend, scope, subject_key) + } + Command::PutFrontendProjection { + frontend, + scope, + subject_key, + schema_version, + expected_projection_revision, + projection, + mutation, + } => cmd_frontend::put_frontend_projection( + mux, + client, + frontend, + scope, + subject_key, + schema_version, + expected_projection_revision, + projection, + mutation, + ), + Command::JournalFrontendEvent { event } => { + cmd_frontend::journal_frontend_event(mux, client, event) + } + Command::ExportLayout { screen } => cmd_panes::export_layout(mux, screen), + Command::ApplyLayout { workspace, name, layout, cols, rows } => { + cmd_panes::apply_layout(mux, actor, workspace, name, layout, cols, rows) + } + Command::Send { surface, terminal_id, text, bytes, paste } => { + cmd_terminal_io::send(mux, client, writer, surface, terminal_id, text, bytes, paste) + } + Command::ReadScreen { surface } => cmd_terminal_io::read_screen(mux, surface), + Command::ClearHistory { surface, fallback_key } => { + cmd_terminal_io::clear_history(mux, surface, fallback_key) + } + Command::ReadScrollback { surface, start, count } => { + cmd_terminal_io::read_scrollback(mux, surface, start, count) + } + Command::SidebarPlugin { cols, rows, relaunch } => { + cmd_frontend::sidebar_plugin(mux, cols, rows, relaunch) + } + Command::WaitFor { surface, pattern, timeout_ms } => { + cmd_terminal_io::wait_for(mux, cancellation, surface, pattern, timeout_ms) + } + Command::Run { argv, command, cwd, pane, new_workspace, key, name, cols, rows } => { + cmd_terminal_io::run( + mux, + actor, + argv, + command, + cwd, + pane, + new_workspace, + key, + name, + cols, + rows, + ) + } + Command::CreateSurfaceWithReceipt(request) => { + cmd_frontend::create_surface_with_receipt_command(mux, client, request) + } + Command::SendKey { surface, keys } => cmd_terminal_io::send_key(mux, client, surface, keys), + Command::Copy { surface, mode } => cmd_terminal_io::copy(mux, surface, mode), + Command::Ids { kind } => cmd_frontend::ids(mux, kind), + Command::Notify { title, body, level, surface, source } => { + cmd_frontend::notify(mux, actor, title, body, level, surface, source) + } + Command::ListAgents { surface, state } => cmd_frontend::list_agents(mux, surface, state), + Command::ReportAgent { surface, state, source, session } => { + cmd_frontend::report_agent(mux, surface, state, source, session) + } + Command::VtState { .. } => unreachable!("vt-state uses its streaming response path"), + Command::MintTerminalRenderer { surface, ttl_ms } => { + cmd_terminals::mint_terminal_renderer(mux, client, surface, ttl_ms) + } + Command::MintTerminalRendererByTerminal { terminal, ttl_ms } => { + cmd_terminals::mint_terminal_renderer_by_terminal(mux, client, terminal, ttl_ms) + } + Command::ResolveTerminal { terminal_id } => { + cmd_terminals::resolve_terminal(mux, terminal_id) + } + Command::CloseTerminal { terminal_id, terminal_incarnation, mutation } => { + cmd_terminals::close_terminal(mux, client, terminal_id, terminal_incarnation, mutation) + } + Command::SetTerminalIdlePolicy { surface, terminal_id, idle_close_seconds } => { + cmd_terminals::set_terminal_idle_policy(mux, surface, terminal_id, idle_close_seconds) + } + Command::SetTerminalKeep { surface, terminal_id, keep } => { + cmd_terminals::set_terminal_keep(mux, surface, terminal_id, keep) + } + Command::NewTab { pane, cwd, env, cols, rows, keep, terminal_id, shell_args } => { + cmd_tabs::new_tab( + mux, + client, + actor, + pane, + cwd, + env, + cols, + rows, + keep, + terminal_id, + shell_args, + ) + } + Command::NewConversationTab(params) => cmd_tabs::new_conversation_tab(mux, client, params), + Command::BindConversationTabSession(params) => { + cmd_tabs::bind_conversation_tab_session(mux, actor, params) + } + Command::RestartTab(params) => tab_restart::restart(mux, params), + Command::NewFrontendBrowserTab(params) => { + cmd_tabs::new_frontend_browser_tab(mux, client, params) + } + Command::UpdateFrontendBrowserTab(params) => { + cmd_tabs::update_frontend_browser_tab(mux, actor, params) + } + Command::SetFrontendBrowserHistory(params) => frontend_browser_history::set(mux, params), + Command::GetFrontendBrowserHistory(params) => frontend_browser_history::get(mux, params), + Command::NewRemoteTerminalTab(p) => remote_terminal_tabs_wire::create(mux, &actor, p), + Command::UpdateRemoteTerminalTab(p) => remote_terminal_tabs_wire::update(mux, p), + Command::RemoteTerminalSnapshot(p) => remote_terminal_tabs_wire::snapshot(mux, p), + Command::NewBrowserTab { url, pane, cols, rows } => { + cmd_tabs::new_browser_tab(mux, actor, url, pane, cols, rows) + } + Command::GetCellPixels => cmd_sizing::get_cell_pixels(mux), + Command::SetCellPixels { width_px, height_px } => { + cmd_sizing::set_cell_pixels(mux, width_px, height_px) + } + Command::BrowserFramePresented { surface, frame_seq } => { + cmd_browser::browser_frame_presented(mux, client, surface, frame_seq) + } + cmd @ (Command::BrowserMouse { .. } + | Command::BrowserMouseGuarded { .. } + | Command::BrowserWheel { .. } + | Command::BrowserWheelGuarded { .. } + | Command::BrowserKey { .. } + | Command::BrowserKeyPress { .. } + | Command::BrowserInsertText { .. }) => browser_input::handle(mux, client, cmd), + Command::BrowserNavigate { surface, url } => { + cmd_browser::browser_navigate(mux, surface, url) + } + Command::BrowserBack { surface } => cmd_browser::browser_back(mux, surface), + Command::BrowserForward { surface } => cmd_browser::browser_forward(mux, surface), + Command::BrowserReload { surface } => cmd_browser::browser_reload(mux, surface), + Command::BrowserActivate { surface } => cmd_browser::browser_activate(mux, surface), + Command::NewWorkspace { name, cols, rows } => { + cmd_workspaces::new_workspace(mux, actor, name, cols, rows) + } + Command::CreateWorkspace { name, key, mutation } => { + cmd_workspaces::create_workspace(mux, client, name, key, mutation) + } + Command::CreateTerminal { + workspace, + key, + argv, + shell_args, + command, + cwd, + name, + cols, + rows, + terminal_id, + env, + keep, + detached, + mutation, + } => cmd_terminals::create_terminal( + mux, + client, + actor, + workspace, + key, + argv, + shell_args, + command, + cwd, + name, + cols, + rows, + terminal_id, + env, + keep, + detached, + mutation, + ), + Command::NewScreen(params) => cmd_screens::new_screen(mux, client, params), + Command::SetScreenMetadata { screen, color, icon } => { + cmd_screens::set_screen_metadata(mux, actor, screen, color, icon) + } + Command::SetScreenPinned { screen, pinned } => { + cmd_screens::set_screen_pinned(mux, actor, screen, pinned) + } + Command::MoveScreen { screen, index, workspace, new_workspace } => { + cmd_screens::move_screen(mux, actor, screen, index, workspace, new_workspace) + } + Command::CreateScreenGroup { screens, name, color } => { + cmd_screens::create_screen_group(mux, actor, screens, name, color) + } + Command::UpdateScreenGroup { group, name, color, collapsed, icon } => { + cmd_screens::update_screen_group(mux, actor, group, name, color, collapsed, icon) + } + Command::AddScreensToScreenGroup { group, screens, index } => { + cmd_screens::add_screens_to_screen_group(mux, actor, group, screens, index) + } + Command::RemoveScreensFromScreenGroup { screens } => { + cmd_screens::remove_screens_from_screen_group(mux, actor, screens) + } + Command::MoveScreenGroup { group, index, workspace, new_workspace } => { + cmd_screens::move_screen_group(mux, actor, group, index, workspace, new_workspace) + } + Command::UngroupScreenGroup { group } => { + cmd_screens::ungroup_screen_group(mux, actor, group) + } + Command::CloseScreenGroup { group, end_terminals } => { + cmd_screens::close_screen_group(mux, actor, group, end_terminals) + } + Command::ListSavedScreenGroups => cmd_screens::list_saved_screen_groups(mux), + Command::SaveScreenGroup { group } => cmd_screens::save_screen_group(mux, actor, group), + Command::UnsaveScreenGroup { group } => cmd_screens::unsave_screen_group(mux, group), + Command::DeleteSavedScreenGroup { saved } => { + cmd_screens::delete_saved_screen_group(mux, saved) + } + Command::ReopenSavedScreenGroup { saved, workspace } => { + cmd_screens::reopen_saved_screen_group(mux, actor, saved, workspace) + } + command @ Command::NewPane { .. } => cmd_panes::new_pane(mux, client, actor, command), + Command::NewPaneRight(params) => cmd_panes::new_pane_right(mux, client, params), + Command::Split(params) => cmd_panes::split(mux, client, params), + Command::SetRatio { pane, dir, ratio } => { + cmd_panes::set_ratio(mux, actor, pane, dir, ratio) + } + Command::SetSplitRatio { split, ratio, transaction } => { + cmd_panes::set_split_ratio(mux, client, actor, split, ratio, transaction) + } + Command::SetViewportPaneWidth { pane, width, transaction } => { + cmd_panes::set_viewport_pane_width(mux, client, actor, pane, width, transaction) + } + Command::SetColumnDock { pane, dock, edge, mode, role, permanent, transaction } => { + cmd_panes::set_column_dock( + mux, + client, + actor, + pane, + dock, + edge, + mode, + role, + permanent, + transaction, + ) + } + Command::UndoLayout { pane, revision, confirm_close } => { + cmd_panes::undo_layout(mux, actor, pane, revision, confirm_close) + } + Command::PaneNeighbor { pane, dir } => cmd_panes::pane_neighbor(mux, pane, dir), + Command::FocusDirection { pane, dir } => cmd_panes::focus_direction(mux, actor, pane, dir), + Command::SwapPane { pane, dir, target } => { + cmd_panes::swap_pane(mux, actor, pane, dir, target) + } + Command::ZoomPane { pane, mode } => cmd_panes::zoom_pane(mux, actor, pane, mode), + Command::ProcessInfo { surface } => cmd_terminals::process_info(mux, surface), + Command::TerminalResources { surfaces } => cmd_terminals::terminal_resources(mux, surfaces), + Command::MoveTerminal { terminal_id, workspace_key, terminal_incarnation, mutation } => { + cmd_terminals::move_terminal( + mux, + client, + terminal_id, + workspace_key, + terminal_incarnation, + mutation, + ) + } + Command::MoveTabToWorkspace { surface, workspace, transaction } => { + cmd_tabs::move_tab_to_workspace(mux, actor, surface, workspace, transaction) + } + Command::MoveTabToSplit { + surface, + pane, + edge, + ratio, + respawn, + keep_focus, + transaction, + } => cmd_tabs::move_tab_to_split( + mux, + client, + surface, + pane, + edge, + ratio, + respawn, + keep_focus, + transaction, + ), + Command::MoveTabToColumn(params) => cmd_tabs::move_tab_to_column(mux, client, params), + Command::NewRow(params) => cmd_panes::new_row(mux, client, params), + Command::SetRowHeights(params) => cmd_panes::set_row_heights(mux, client, params), + Command::MoveTabToNewWorkspace { surface, group, index, name, transaction } => { + cmd_tabs::move_tab_to_new_workspace( + mux, + actor, + surface, + group, + index, + name, + transaction, + ) + } + Command::MoveTab { surface, pane, index, transaction } => { + cmd_tabs::move_tab(mux, actor, surface, pane, index, transaction) + } + Command::ListTabGroups => cmd_tabs::list_tab_groups(mux), + Command::CreateTabGroup { surfaces, name, color, group, transaction } => { + cmd_tabs::create_tab_group(mux, actor, surfaces, name, color, group, transaction) + } + Command::UpdateTabGroup { group, name, color, collapsed, icon } => { + cmd_tabs::update_tab_group(mux, actor, group, name, color, collapsed, icon) + } + Command::AddTabsToTabGroup { group, surfaces, transaction } => { + cmd_tabs::add_tabs_to_tab_group(mux, actor, group, surfaces, transaction) + } + Command::RemoveTabsFromTabGroup { surfaces, transaction } => { + cmd_tabs::remove_tabs_from_tab_group(mux, actor, surfaces, transaction) + } + Command::MoveTabGroup { group, pane, index, transaction } => { + cmd_tabs::move_tab_group(mux, actor, group, pane, index, transaction) + } + Command::MoveTabGroupToSplit { group, pane, edge, ratio, transaction } => { + cmd_tabs::move_tab_group_to_split(mux, actor, group, pane, edge, ratio, transaction) + } + Command::MoveTabGroupToColumn { group, pane, screen, after_column, width, transaction } => { + cmd_tabs::move_tab_group_to_column( + mux, + actor, + group, + pane, + screen, + after_column, + width, + transaction, + ) + } + Command::MoveTabGroupToNewWorkspace { group, workspace_group, index, transaction } => { + cmd_tabs::move_tab_group_to_new_workspace( + mux, + actor, + group, + workspace_group, + index, + transaction, + ) + } + Command::UngroupTabGroup { group } => cmd_tabs::ungroup_tab_group(mux, actor, group), + Command::CloseTabGroup { group, end_terminals } => { + cmd_tabs::close_tab_group(mux, actor, group, end_terminals) + } + Command::ListSavedTabGroups => cmd_tabs::list_saved_tab_groups(mux), + Command::SaveTabGroup { group } => cmd_tabs::save_tab_group(mux, actor, group), + Command::UnsaveTabGroup { group } => cmd_tabs::unsave_tab_group(mux, actor, group), + Command::DeleteSavedTabGroup { saved } => { + cmd_tabs::delete_saved_tab_group(mux, actor, saved) + } + Command::ReopenSavedTabGroup { saved, pane, transaction } => { + cmd_tabs::reopen_saved_tab_group(mux, actor, saved, pane, transaction) + } + Command::AckTabNotifications { surface } => cmd_tabs::ack_tab_notifications(mux, surface), + Command::ListNotifications { limit } => cmd_frontend::list_notifications(mux, limit), + Command::SetTabPinned { surface, pinned } => { + cmd_tabs::set_tab_pinned(mux, actor, surface, pinned) + } + Command::MoveWorkspace { workspace, key, index, mutation } => { + cmd_workspaces::move_workspace(mux, client, workspace, key, index, mutation) + } + Command::SetWorkspaceMetadata { + workspace, + key, + color, + icon, + title, + pinned, + marked_unread, + mutation, + } => cmd_workspaces::set_workspace_metadata( + mux, + client, + workspace, + key, + color, + icon, + title, + pinned, + marked_unread, + mutation, + ), + Command::ListPersonal => cmd_profiles::list_personal(mux), + Command::CreateBrowserProfile(params) => browser_profiles::create(mux, params), + Command::UpdateBrowserProfile(params) => browser_profiles::update(mux, params), + Command::MoveBrowserProfile(params) => browser_profiles::move_to(mux, params), + Command::DeleteBrowserProfile(params) => browser_profiles::delete(mux, client, params), + Command::ListBookmarks(params) => bookmarks::list(mux, params), + Command::CreateBookmark(params) => bookmarks::create(mux, &actor, params), + Command::UpdateBookmark(params) => bookmarks::update(mux, &actor, params), + Command::MoveBookmark(params) => bookmarks::move_to(mux, &actor, params), + Command::DeleteBookmark(params) => bookmarks::delete(mux, &actor, params), + Command::ImportBookmarks(params) => bookmarks::import(mux, &actor, params), + cmd @ (Command::FeedLocalList(_) + | Command::FeedLocalRead(_) + | Command::FeedLocalHandoffBegin(_) + | Command::FeedLocalHandoffAbort(_) + | Command::FeedLocalHandoffDone(_)) => feed_local::dispatch(mux, &actor, cmd), + Command::PutBlob(params) => icon_assets::put(mux, params), + Command::GetBlob(params) => icon_assets::get(mux, params), + Command::ConversationList => conversations::list(mux, client), + Command::ConversationCreate(params) => conversations::create(mux, client, params), + Command::ConversationSnapshot(params) => conversations::snapshot(mux, client, params), + Command::ConversationHistory(params) => conversations::history(mux, client, params), + Command::ConversationThread(params) => conversations::thread(mux, client, params), + Command::ConversationLinkResolve(params) => { + conversations::link_resolve(mux, client, params) + } + Command::ConversationInboxOp(params) => conversations::inbox_op(mux, client, params), + Command::ConversationSearch(params) => conversations::search(mux, client, params), + Command::HistorySearch(params) => history_search::search(mux, client, params), + Command::ConversationOp(params) => conversations::op(mux, client, params), + Command::ConversationTyping(params) => conversations::typing(mux, client, params), + Command::ConversationDraft(params) => conversations::draft(mux, client, params), + Command::ConversationBind(params) => conversations::bind(mux, client, params), + Command::ConversationAgentToken(params) => conversations::agent_token(mux, client, params), + Command::CloudSessionSet(params) => cloud_conversations::session_set(mux, client, params), + Command::CloudSessionClear => cloud_conversations::session_clear(mux, client), + Command::CloudSessionStatus => cloud_conversations::session_status(mux, client), + Command::PageCredential(params) => page_credential::page_credential(mux, client, params), + Command::WsListen(params) => page_listener::ws_listen(mux, client, params), + Command::CloudInboxList(params) => cloud_conversations::inbox_list(mux, client, params), + Command::CloudConversationSnapshot(params) => { + cloud_conversations::snapshot(mux, client, params) + } + Command::CloudConversationHistory(params) => { + cloud_conversations::history(mux, client, params) + } + Command::CloudConversationThread(params) => { + cloud_conversations::thread(mux, client, params) + } + Command::CloudConversationRuns(params) => cloud_conversations::runs(mux, client, params), + Command::CloudConversationRun(params) => cloud_conversations::run(mux, client, params), + Command::CloudParticipantAvatarGet(params) => { + cloud_conversations::participant_avatar(mux, client, params) + } + Command::CloudConversationOp(params) => cloud_conversations::op(mux, client, params), + Command::CloudInboxOp(params) => cloud_conversations::inbox_op(mux, client, params), + Command::CloudInboxSubscribe => cloud_conversations::subscribe(mux, client, None), + Command::CloudMuxSubscribe(_) => cloud_conversations::mux_subscribe(mux, client), + Command::CloudMuxUnsubscribe(_) => cloud_conversations::mux_unsubscribe(mux, client), + Command::CloudMuxAck(params) => cloud_conversations::mux_ack(mux, client, params), + Command::CloudInboxUnsubscribe => cloud_conversations::unsubscribe(mux, client, None), + Command::CloudConversationSubscribe(params) => { + cloud_conversations::subscribe(mux, client, Some(params)) + } + Command::CloudConversationUnsubscribe(params) => { + cloud_conversations::unsubscribe(mux, client, Some(params)) + } + Command::ConversationAttachmentUpload(p) => conversation_attachments::put(mux, client, p), + Command::ConversationAttachmentRead(p) => conversation_attachments::read(mux, client, p), + Command::ConversationAttachmentLink(p) => conversation_attachments::link(mux, client, p), + Command::CloudAttachmentUpload(p) => cloud_attachments::put(mux, client, p), + Command::CloudAttachmentRead(p) => cloud_attachments::read(mux, client, p), + Command::CloudAttachmentLink(p) => cloud_attachments::link(mux, client, p), + Command::ConversationRunAppend(p) => conversation_runs::append(mux, client, p), + Command::ConversationRuns(p) => conversation_runs::runs(mux, client, p), + Command::ConversationRun(p) => conversation_runs::run(mux, client, p), + Command::ConversationImport(params) => conversations::import(mux, client, params), + Command::CreateProfile { + name, + profile, + color, + icon, + theme, + index, + browser_profile_id, + default_session_id, + defaults, + follows, + } => cmd_profiles::create_profile( + mux, + name, + profile, + color, + icon, + theme, + index, + browser_profile_id, + default_session_id, + defaults, + follows, + ), + Command::UpdateProfile { + profile, + name, + color, + icon, + theme, + browser_profile_id, + default_session_id, + defaults, + } => cmd_profiles::update_profile( + mux, + profile, + name, + color, + icon, + theme, + browser_profile_id, + default_session_id, + defaults, + ), + Command::MoveProfile { profile, index } => cmd_profiles::move_profile(mux, profile, index), + Command::DeleteProfile { profile, move_to } => { + cmd_profiles::delete_profile(mux, client, profile, move_to) + } + Command::SetProfileFollows { profile, session_ids } => { + cmd_profiles::set_profile_follows(mux, profile, session_ids) + } + Command::PinWorkspace { session_id, workspace_key, profile } => { + cmd_workspaces::pin_workspace(mux, session_id, workspace_key, profile) + } + Command::UnpinWorkspace { session_id, workspace_key } => { + cmd_workspaces::unpin_workspace(mux, session_id, workspace_key) + } + Command::PutSession { + session_id, + machine_name, + session_name, + transport, + capabilities, + follow_with, + } => cmd_profiles::put_session( + mux, + session_id, + machine_name, + session_name, + transport, + capabilities, + follow_with, + ), + Command::ForgetSession { session_id, force } => { + cmd_profiles::forget_session(mux, session_id, force) + } + Command::SetSessionIcon(params) => personal::set_session_icon(mux, params), + Command::ImportSessionOrganization { session_id, groups, workspaces } => { + cmd_profiles::import_session_organization(mux, session_id, groups, workspaces) + } + Command::CreatePersonalGroup { name, group, profile, color, collapsed, index } => { + cmd_profiles::create_personal_group(mux, name, group, profile, color, collapsed, index) + } + Command::UpdatePersonalGroup { group, name, color, collapsed, profile } => { + cmd_profiles::update_personal_group(mux, group, name, color, collapsed, profile) + } + Command::DeletePersonalGroup { group } => { + cmd_profiles::delete_personal_group(mux, client, group) + } + Command::MovePersonalGroup { group, index } => { + cmd_profiles::move_personal_group(mux, group, index) + } + command @ Command::SetPersonalWorkspace { .. } => { + cmd_profiles::set_personal_workspace(mux, command) + } + Command::SetPersonalTerminal { session_id, terminal_key, theme } => { + cmd_profiles::set_personal_terminal(mux, session_id, terminal_key, theme) + } + Command::ListWorkspaceGroups => cmd_workspaces::list_workspace_groups(mux), + Command::CreateWorkspaceGroup { name, group, color, collapsed, index } => { + cmd_workspaces::create_workspace_group(mux, name, group, color, collapsed, index) + } + Command::UpdateWorkspaceGroup { group, name, color, collapsed } => { + cmd_workspaces::update_workspace_group(mux, group, name, color, collapsed) + } + Command::DeleteWorkspaceGroup { group } => { + cmd_workspaces::delete_workspace_group(mux, group) + } + Command::MoveWorkspaceGroup { group, index } => { + cmd_workspaces::move_workspace_group(mux, group, index) + } + Command::MoveWorkspaceToGroup { workspace, key, group, index, mutation } => { + cmd_workspaces::move_workspace_to_group( + mux, client, workspace, key, group, index, mutation, + ) + } + Command::SetDefaultColors { + fg, + bg, + cursor, + selection_bg, + selection_fg, + cursor_style, + cursor_blink, + palette, + complete, + } => cmd_terminal_io::set_default_colors( + mux, + fg, + bg, + cursor, + selection_bg, + selection_fg, + cursor_style, + cursor_blink, + palette, + complete, + ), + Command::CloseSurface { surface } => cmd_tabs::close_surface(mux, actor, surface), + Command::CloseTabs { surfaces, end_terminals, transaction, reason, mutation } => { + cmd_tabs::close_tabs( + mux, + client, + surfaces, + end_terminals, + transaction, + reason, + mutation, + ) + } + Command::ClosePane { pane, end_terminals } => { + cmd_panes::close_pane(mux, actor, pane, end_terminals) + } + Command::CloseScreen { screen, end_terminals } => { + cmd_screens::close_screen(mux, actor, screen, end_terminals) + } + Command::CloseWorkspace { workspace, key, end_terminals, reason, if_empty, mutation } => { + let ending = cmd_workspaces::Ending { terminals: end_terminals, reason, if_empty }; + cmd_workspaces::close_workspace(mux, client, workspace, key, ending, mutation) + } + Command::MarkWorkspacesProviderManaged { authority } => { + cmd_workspaces::mark_workspaces_provider_managed(mux, authority) + } + Command::CloseProviderManagedWorkspace { workspace, key, authority } => { + cmd_workspaces::close_provider_managed_workspace(mux, actor, workspace, key, authority) + } + Command::RenamePane { pane, name } => cmd_panes::rename_pane(mux, actor, pane, name), + Command::RenameSurface { surface, name } => { + cmd_tabs::rename_surface(mux, actor, surface, name) + } + Command::RenameScreen { screen, name } => { + cmd_screens::rename_screen(mux, actor, screen, name) + } + Command::RenameWorkspace { workspace, key, name, mutation } => { + cmd_workspaces::rename_workspace(mux, client, workspace, key, name, mutation) + } + Command::RenameProviderManagedWorkspace { workspace, key, name, authority } => { + cmd_workspaces::rename_provider_managed_workspace( + mux, actor, workspace, key, name, authority, + ) + } + Command::ResizeSurface { surface, cols, rows } => { + cmd_sizing::resize_surface(mux, client, surface, cols, rows) + } + Command::ResizeAttachedView { surface, lease, view, identity, cols, rows } => { + cmd_sizing::resize_attached_view( + mux, client, surface, lease, view, identity, cols, rows, + ) + } + Command::ReleaseSurfaceSize { surface } => { + cmd_sizing::release_surface_size(mux, client, surface) + } + Command::ReleaseAttachedViewSize { surface, lease, view } => { + cmd_sizing::release_attached_view_size(mux, client, surface, lease, view) + } + Command::DetachAttachedView { surface, lease, view } => { + cmd_attach::detach_attached_view(mux, client, surface, lease, view) + } + Command::FocusPane { pane } => cmd_panes::focus_pane(mux, actor, pane), + Command::SelectTab { pane, index, delta } => { + cmd_tabs::select_tab(mux, actor, pane, index, delta) + } + Command::SelectScreen { index, delta } => { + cmd_screens::select_screen(mux, actor, index, delta) + } + Command::SelectWorkspace { index, delta } => { + cmd_workspaces::select_workspace(mux, actor, index, delta) + } + Command::ReportFocus { client_id, pane, tab } => { + cmd_server::report_focus(mux, client_id, pane, tab) + } + Command::ClientFocus { client_id } => cmd_server::client_focus(mux, client_id), + Command::SnapshotRequest(params) => cmd_terminal_io::snapshot_request(mux, client, params), + Command::TerminalHistory(params) => cmd_terminals::terminal_history(mux, params), + Command::TerminalReadRange(params) => cmd_terminals::terminal_read_range(mux, params), + Command::ScrollSurface { surface, delta } => { + cmd_terminal_io::scroll_surface(mux, surface, delta) + } + Command::Subscribe { tree_events, surface } => { + cmd_subscribe::subscribe_command(mux, client, writer, tree_events, surface) + } + Command::AttachSurface { + surface: surface_id, + mode, + cols, + rows, + expected_generation, + expected_terminal_id, + snapshot, + } => cmd_attach::attach_surface( + mux, + client, + writer, + surface_id, + mode, + cols, + rows, + expected_generation, + expected_terminal_id, + snapshot, + ), + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/server/connection_frame.rs b/cmux-tui/crates/cmux-tui-core/src/server/connection_frame.rs new file mode 100644 index 000000000000..e1c22ada802b --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/server/connection_frame.rs @@ -0,0 +1,287 @@ +//! One frame of a connection: authentication and pairing frames, a request's +//! dispatch with its cancellation, and the VT-state command reply. Moved out of +//! server.rs unchanged (cx-6so.3). + +use super::*; + +/// One frame of a connection. `transport` is the connection's own value +/// (not a registry lookup), so a remote-entry connection always takes the +/// remote path, also after its registry record is gone. +pub(super) fn handle_connection_frame( + mux: &Arc, + client: u64, + transport: ClientTransport, + message: &str, + writer: &MessageWriter, + scheduler: &Arc, +) -> bool { + // Keep an idle shutdown requester connected until owner cleanup closes + // the transport, so lifecycle clients receive authoritative completion. + // A pipelined message after the acknowledgement must not reach parsing or + // dispatch; returning false makes the connection loop close that client. + if mux.daemon_shutdown_requested() || mux.daemon_handoff_committed() { + return false; + } + if matches!(transport, ClientTransport::Remote) || mux.is_remote_client(client) { + return remote_relay::handle_frame(mux, client, message, writer); + } + // Before the acknowledgement the handoff can still fail (for example + // while `end_terminals` awaits every host) and this daemon keeps + // serving. Closing here would drop the requester's pending + // `shutdown-daemon` reply along with its connection, so a message that + // arrives meanwhile (a subscriber's snapshot refresh) is refused + // without being executed and the connection stays open. + if mux.daemon_handoff_in_progress() { + return pending_handoff::reject_message_during_pending_handoff(message, writer); + } + if let Some(request) = crate::resource_router::parse_resource_line(message) { + // A v2 line the catalog parse refuses may name a routed namespace + // operation (page-router-v1): the same JSON parse decides, so every + // spelling of the line gets one answer. + if request.is_err() + && let Some(keep_open) = page_router::try_handle(mux, client, message, writer) + { + return keep_open; + } + return origin_gate::handle_resource_line(mux, client, message, request, writer); + } + if let Some(keep_open) = loopback_forward::try_handle(mux, client, message, writer) { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = browser_runtime::try_handle(mux, client, message, writer) { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = agent_session_attach::try_handle(mux, client, message, writer) + .or_else(|| agent_transfer::try_handle(mux, client, message, writer)) + { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = apps::try_handle(mux, client, message, writer) { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = scripts::try_handle(mux, client, message, writer) { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = fs_wire::try_handle(mux, client, message, writer) { + return keep_open; + } + let request = match serde_json::from_str::(message) { + Ok(request) => request, + Err(error) => return send_bad_request(writer, message, &error), + }; + let mut pending = Some(request); + match scheduler.dispatch(mux.clone(), client, &mut pending, message.len(), writer.clone()) { + Some(keep_open) => keep_open, + None => handle_request(mux, client, pending.take().unwrap(), writer), + } +} + +pub(super) fn handle_request( + mux: &Arc, + client: u64, + request: Request, + writer: &MessageWriter, +) -> bool { + handle_request_with_cancellation(mux, client, request, writer, None) +} + +pub(super) fn handle_request_with_cancellation( + mux: &Arc, + client: u64, + request: Request, + writer: &MessageWriter, + cancellation: Option<&ConnectionCancellation>, +) -> bool { + let Request { id, cmd } = request; + if let Command::UrlOpen { terminal_id, url } = cmd { + return url_open::start(mux, client, id, terminal_id, url, writer); + } + if let Command::ChiefInspect(params) = cmd { + return chief_inspect::start(mux, client, id, params, writer); + } + if cloud_conversations::is_network(&cmd) { + return cloud_conversations::start(mux, client, id, cmd, writer); + } + if let Some(target) = cloud_conversations::subscribe_target(mux, &cmd) { + return cloud_conversations::subscribe_then_announce(mux, client, id, cmd, target, writer); + } + if matches!(&cmd, Command::ShutdownDaemon { .. } | Command::ReloadConfig) + && !mux.server_lifecycle_ready() + { + return send_request_error(writer, id, "server lifecycle is not ready"); + } + if let Command::VtState { surface } = &cmd { + return match send_vt_state_command_response(mux, id.clone(), *surface, writer) { + Ok(()) => true, + Err(error) => send_request_error(writer, id, &error.to_string()), + }; + } + + let detach_self = match &cmd { + Command::DetachClient { client: target, by, surface } + if target.whole_client() == Some(client) + && own_view_detach_target(mux, target, *surface).is_none() => + { + Some(detach_actor(mux, client, by.clone())) + } + _ => None, + }; + let shutdown_daemon = matches!(&cmd, Command::ShutdownDaemon { .. }); + let (mut reason, mut retryable, mut details) = (None, None, None); + let response = match handle_command_with_cancellation(mux, client, cmd, writer, cancellation) { + Ok(data) => Response { + id, + ok: true, + data: Some(data), + error: None, + error_code: None, + error_delivery: None, + }, + Err(error) => { + reason = conversations::error_reason(&error) + .or_else(|| cloud_conversations::error_reason(&error)); + retryable = cloud_conversations::error_retryable(&error); + details = renderer_grant::error_details(&error) + .or_else(|| conversations::error_details(&error)); + let error_code = response_error_code(&error); + let error_delivery = + error.downcast_ref::().map(|error| error.delivery); + Response { + id, + ok: false, + data: None, + error: Some(error.to_string()), + error_code, + error_delivery, + } + } + }; + let (response, reason) = remote_relay::redact_response(mux, client, response, reason); + let details = details.filter(|_| !mux.is_remote_client(client)); + let response_ok = response.ok; + let sent = responses::send_response_with_details(writer, response, reason, retryable, details); + // Flush the successful acknowledgement before making the owning loop + // leave, so process teardown cannot race the response writer. + if shutdown_daemon && response_ok { + if sent { + return complete_daemon_shutdown_after_ack(mux, client, writer); + } else { + mux.cancel_daemon_handoff(client); + } + } + if let Some(by) = detach_self + && response_ok + && sent + { + kick_client(mux, client, by); + return false; + } + sent +} + +fn send_vt_state_command_response( + mux: &Mux, + id: Option, + surface: SurfaceId, + writer: &MessageWriter, +) -> anyhow::Result<()> { + // Reserve the entire wire-frame allowance before copying a replay or + // starting its base64 encoder. The writer allocates only for actual + // output and releases unused logical quota before the response is queued. + let mut output = writer.render_service.reserved_control_writer()?; + let surface = get_surface(mux, surface)?; + require_pty(&surface)?; + let (cols, rows, replay) = surface.try_with_terminal(|terminal| { + terminal + .vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) + .map(|replay| (terminal.cols(), terminal.rows(), replay)) + })??; + + write_vt_state_command_json( + &mut output, + id.as_ref(), + cols, + rows, + &replay.self_contained_bytes(), + &replay.kitty_image_aliases, + replay.kitty_state, + )?; + writer.send_serialized_control(output.finish())?; + Ok(()) +} + +pub(super) fn write_vt_state_command_json( + output: &mut BudgetedJsonWriter, + id: Option<&Value>, + cols: u16, + rows: u16, + replay: &[u8], + kitty_image_aliases: &[ghostty_vt::KittyImageAlias], + kitty_state: KittyReplayState, +) -> std::io::Result<()> { + output.write_all(b"{")?; + if let Some(id) = id { + output.write_all(b"\"id\":")?; + serde_json::to_writer(&mut *output, id).map_err(json_error_to_io)?; + output.write_all(b",")?; + } + write!(output, "\"ok\":true,\"data\":{{\"cols\":{cols},\"rows\":{rows},\"data\":\"")?; + { + let mut encoder = base64::write::EncoderWriter::new( + &mut *output, + &base64::engine::general_purpose::STANDARD, + ); + encoder.write_all(replay)?; + encoder.finish()?; + } + output.write_all(b"\",\"kitty_image_aliases\":")?; + write_kitty_image_aliases_json(output, kitty_image_aliases)?; + output.write_all(b",\"kitty_graphics_state\":")?; + write_kitty_replay_state_json(output, kitty_state)?; + output.write_all(b"}}")?; + Ok(()) +} + +pub(super) fn auth_token(message: &str) -> Option { + let value: Value = serde_json::from_str(message).ok()?; + let object = value.as_object()?; + if object.len() != 1 { + return None; + } + let auth = object.get("auth")?.as_object()?; + if auth.len() != 1 { + return None; + } + auth.get("token")?.as_str().map(str::to_string) +} + +pub(super) fn pairing_request(message: &str) -> bool { + let Ok(value) = serde_json::from_str::(message) else { return false }; + let Some(object) = value.as_object() else { return false }; + if object.len() != 1 { + return false; + } + let Some(pair) = object.get("pair").and_then(Value::as_object) else { return false }; + pair.len() == 1 && pair.get("request").and_then(Value::as_bool) == Some(true) +} + +pub(super) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + let mut difference = a.len() ^ b.len(); + let length = a.len().max(b.len()); + for index in 0..length { + difference |= + usize::from(a.get(index).copied().unwrap_or(0) ^ b.get(index).copied().unwrap_or(0)); + } + difference == 0 +} + +pub(super) fn zeroize_string(value: &mut str) { + // NUL remains valid UTF-8, so decoded control frames can be cleared in + // place immediately after dispatch. + value.zeroize(); +} diff --git a/cmux-tui/scripts/check-spec-inventory.py b/cmux-tui/scripts/check-spec-inventory.py index e8221a2a9b8f..ef79c98e210f 100644 --- a/cmux-tui/scripts/check-spec-inventory.py +++ b/cmux-tui/scripts/check-spec-inventory.py @@ -253,7 +253,8 @@ def guarded_command_profiles(source: str) -> dict[str, str]: def command_profiles() -> dict[str, set[str]]: - source = (TUI / "crates/cmux-tui-core/src/server.rs").read_text() + # The Command router lives in server/command_dispatch.rs (cx-6so.3). + source = (TUI / "crates/cmux-tui-core/src/server/command_dispatch.rs").read_text() runtime_commands = command_names() sdk_commands = load_sdk_catalog().get("commands") if not isinstance(sdk_commands, dict): diff --git a/cmux-tui/scripts/test_check_spec_inventory.py b/cmux-tui/scripts/test_check_spec_inventory.py index 2f49d7322080..327703a6ac3f 100644 --- a/cmux-tui/scripts/test_check_spec_inventory.py +++ b/cmux-tui/scripts/test_check_spec_inventory.py @@ -166,7 +166,12 @@ def test_command_profiles_combine_sdk_metadata_and_rust_guards(self) -> None: Ping, ShutdownDaemon { pid: u32 }, } - +""" + ) + # The router lives in server/command_dispatch.rs. + (server.parent / "server").mkdir() + (server.parent / "server/command_dispatch.rs").write_text( + """\ fn handle_command_with_cancellation(cmd: Command) { match cmd { Command::Ping => {} @@ -212,7 +217,12 @@ def test_command_profile_guard_drift_is_rejected(self) -> None: enum Command { ShutdownDaemon { pid: u32 }, } - +""" + ) + # The router lives in server/command_dispatch.rs. + (server.parent / "server").mkdir() + (server.parent / "server/command_dispatch.rs").write_text( + """\ fn handle_command_with_cancellation(cmd: Command) { match cmd { Command::ShutdownDaemon { .. } => { diff --git a/scripts/cmux-next/godfile-baseline.tsv b/scripts/cmux-next/godfile-baseline.tsv index c180c01ac190..6e54a0054dae 100644 --- a/scripts/cmux-next/godfile-baseline.tsv +++ b/scripts/cmux-next/godfile-baseline.tsv @@ -51,7 +51,7 @@ rust-file cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs 1025 44 rust-file cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs 2728 82 rust-file cmux-tui/crates/cmux-tui-core/src/resource_router/topology.rs 1930 46 rust-file cmux-tui/crates/cmux-tui-core/src/resource_selector.rs 1139 33 -rust-file cmux-tui/crates/cmux-tui-core/src/server.rs 3842 47 +rust-file cmux-tui/crates/cmux-tui-core/src/server.rs 2778 30 rust-file cmux-tui/crates/cmux-tui-core/src/server/loopback_forward.rs 1091 46 rust-file cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs 6002 226 rust-file cmux-tui/crates/cmux-tui-core/src/workspace_registry/effect_store.rs 1410 25 From be93becd1f4cc93a72216efa61b7f9592b90c1d6 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 18:35:40 -0700 Subject: [PATCH 115/183] cmux-next composer: one gap and grouping across the footer row (#20785) * test(cmux-next composer): the controls row keeps one gap per group (red) The composer's controls row renders in Chromium at 760, 600, 480 and 420 px, light and dark, with a running turn and a typed prompt so Interrupt, the mic and Send show. It measures what each control draws and expects one gap inside a group, one wider gap between groups, the same insets inside every chip, and the + glyph and Send's disc the same distance from the bar's sides. Co-Authored-By: Claude Opus 5.5 * cmux-next composer: one gap and grouping across the footer row The controls row spaced every hit box 8 px apart, so what a person saw between two controls ran from 13 to 23 px depending on each control's padding and glyph. The row now has one gap scale in tokens: every control draws its text or icon 6 px in from its edge, controls in a group sit 4 px apart (16 px between what they draw) and groups 12 px apart (24 px). The groups are [+] [model, effort] [permission mode, Plan] and the trailing [Interrupt, mic, Send]. + and the mic draw Send's 16 px glyph, Interrupt and Plan take the chips' inset, and the + glyph and Send's disc sit 12 px from the card's sides. Co-Authored-By: Claude Opus 5.5 * test(cmux-next composer): measure the row in the light and the dark theme The gallery's colorScheme parameter keeps the default dark theme; the light case now loads Apple System Colors Light. Co-Authored-By: Claude Opus 5.5 * cmux-next composer: the trailing cluster takes the free space in RTL too The actions cluster's margin-left: auto left the row's free space past it under dir=rtl; margin-inline-start puts it between the chips and the cluster in either direction. The rhythm test sorts the row's controls by position and says that icons count as their boxes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../agent-session/acpmux/composerLocation.css | 63 +++- webviews/test/composer-row-layout.test.ts | 19 +- webviews/test/composer-row-rhythm.test.ts | 274 ++++++++++++++++++ 3 files changed, 337 insertions(+), 19 deletions(-) create mode 100644 webviews/test/composer-row-rhythm.test.ts diff --git a/webviews/src/agent-session/acpmux/composerLocation.css b/webviews/src/agent-session/acpmux/composerLocation.css index 373d0d5ca355..bd9f45408038 100644 --- a/webviews/src/agent-session/acpmux/composerLocation.css +++ b/webviews/src/agent-session/acpmux/composerLocation.css @@ -130,10 +130,26 @@ .acpmux-location-choose { border: 0; background: none; color: inherit; font: inherit; } .acpmux-location-choose:hover, .acpmux-location-choose:focus-visible { background: var(--acpmux-menu-hover); outline: none; } +/* The controls row's rhythm (Leo 2026-10-14: "one consistent gap, group related controls, and + right-align the trailing cluster"). Its groups: [+] [model · effort] [permission mode · Plan] + ……… [Interrupt · mic · Send]. Every control draws its text or icon --acpmux-row-inset in from its + own edge (where its hover wash ends), so what a person sees between two controls is + inset + gap + inset: --acpmux-row-gap inside a group, --acpmux-row-group-gap between groups, one + gap scale and no per-control spacing. Send's disc is drawn to its edge, so it keeps its inset + outside it. The + glyph and Send's disc sit --acpmux-row-edge from the card's sides (the + leading padding gives + its inset back). + test/composer-row-rhythm.test.ts measures it. */ .acpmux-composer-bar { + --acpmux-row-inset: 6px; + --acpmux-row-gap: 4px; + --acpmux-row-group-gap: 12px; + --acpmux-row-icon-gap: 4px; + --acpmux-row-edge: 12px; min-height: 52px; height: auto; - padding: 6px 12px; + gap: var(--acpmux-row-group-gap); + padding-block: 6px; + padding-inline: calc(var(--acpmux-row-edge) - var(--acpmux-row-inset)) var(--acpmux-row-edge); } .acpmux-mode .acpmux-mode-text { display: none; } .acpmux-mode .acpmux-picker-button { @@ -254,12 +270,38 @@ .acpmux-location-readonly { padding-inline: 4px; } } -/* The top row is a compact control rail. Keep the selected value visible so the - model, effort, and access choices can be scanned without opening a menu. Every control is the - bar's regular 28 px and 8 px from the next, the bar's own gap, so + · model · effort · access - read as one evenly spaced group (hq#1957 item 15 compact row). */ +/* The top row is a compact control rail. Keep the selected value visible so the model, effort, + and access choices can be scanned without opening a menu. Every control is the bar's regular + 28 px; the chips are one group apart from + and from each other's groups (the rhythm above): + the permission mode, and Plan beside it, open the chips' second group. */ .acpmux-composer-bar .acpmux-chips { - gap: 8px; + gap: var(--acpmux-row-gap); +} +/* Padding on the chip's wrapper, not a margin: the hover wash stays the button's, and the row's + fit (composerRowFit.ts) counts the wrapper's whole box. */ +.acpmux-composer-bar .acpmux-chips > .acpmux-mode:not(:first-child) { + padding-inline-start: calc(var(--acpmux-row-group-gap) - var(--acpmux-row-gap)); +} +/* The trailing cluster takes the row's free space on its leading side, in either direction. */ +.acpmux-composer-bar .acpmux-composer-actions { + gap: var(--acpmux-row-gap); + margin-left: 0; + margin-inline-start: auto; +} +/* Send's inset sits inside the actions cluster's box, which the row's fit measures whole. */ +.acpmux-composer-bar .acpmux-send { margin-inline-start: var(--acpmux-row-inset); } +/* + and the mic draw a 16 px glyph, as Send does, so a 28 px button keeps the row's inset. */ +.acpmux-composer-bar .acpmux-composer-plus .acpmux-picker-button .acpmux-icon, +.acpmux-composer-bar .acpmux-mic .acpmux-icon { + width: 16px; + height: 16px; +} +/* Interrupt (or Steer) and Plan: the chips' inset and radius. */ +.acpmux-composer-bar .acpmux-steer, +.acpmux-composer-bar .acpmux-plan { + gap: var(--acpmux-row-icon-gap); + padding-inline: var(--acpmux-row-inset); + border-radius: 7px; } .acpmux-composer-bar .acpmux-mode-text { display: inline; @@ -277,10 +319,15 @@ .acpmux-composer-bar .acpmux-effort .acpmux-picker-button, .acpmux-composer-bar .acpmux-access .acpmux-picker-button { height: 28px; - padding-inline: 6px 4px; border-radius: 7px; color: var(--agent-muted); } +/* Every chip: its text and icons the row's inset from both edges, an icon or chevron the same + distance from the text. */ +.acpmux-composer-bar .acpmux-chips .acpmux-picker-button { + gap: var(--acpmux-row-icon-gap); + padding-inline: var(--acpmux-row-inset); +} /* Hover is the shared quiet wash (ui/ui.css --ui-hover, which the agent pane does not load): the control's own ink at 6% over the card, tight to the control, a step further on press; no opaque block. */ @@ -353,7 +400,7 @@ /* Keep the complete control rail usable in a very narrow pane: preserve every action and only tighten their rhythm. */ @media (max-width: 220px) { - .acpmux-composer-bar { gap: 6px; padding-inline: 8px; } + .acpmux-composer-bar { --acpmux-row-group-gap: 8px; --acpmux-row-edge: 8px; } .acpmux-composer-bar .acpmux-composer-plus .acpmux-picker-button { width: 24px; } } /* The composer is one material surface; the location strip on its top edge shares its fill and diff --git a/webviews/test/composer-row-layout.test.ts b/webviews/test/composer-row-layout.test.ts index edb942bd0d28..5f4003f11768 100644 --- a/webviews/test/composer-row-layout.test.ts +++ b/webviews/test/composer-row-layout.test.ts @@ -5,7 +5,7 @@ // - the controls are two clusters: + model effort access on the left, mic and Send (or Stop) // on the right; the row's free space sits only between the two; // - the row is compact and calm (hq#1957 item 15 compact row): the left cluster's controls sit -// 8 to 12 px apart, evenly (within 1 px); no context ring or spinner in the row; one lock at +// 4 to 12 px apart (composer-row-rhythm.test.ts: one gap per group); no ring or spinner; one lock at // most; no granted-permission status chip; Stop has Send's footprint and a square glyph of 9 // to 12 px, centred in a solid text-colored disc with a base-colored square (Q4-B: the faint // disc read as a disabled Send); @@ -243,16 +243,13 @@ await requireBrowserLane("composer-row-layout.test.ts", async () => { test(`${variant} at ${width} px: a compact, calm row`, async () => { const measured = await row(variant, width); const problems: string[] = []; - // Even gaps in the left cluster: each 8 to 12 px, none more than 1 px off another. - const gaps = measured.left.slice(1).map((control, index) => ({ - between: `${measured.left[index]!.name}-${control.name}`, - gap: control.rect.left - measured.left[index]!.rect.right, - })); - for (const { between, gap } of gaps) - if (gap < 7.5 || gap > 12.5) problems.push(`${between} ${gap.toFixed(1)} px apart`); - const sizes = gaps.map(({ gap }) => gap); - if (sizes.length > 1 && Math.max(...sizes) - Math.min(...sizes) > 1) - problems.push(`uneven gaps ${gaps.map(({ between, gap }) => `${between} ${gap.toFixed(1)}`).join(", ")}`); + // The left cluster's spacing (one gap inside a group, one between groups) is + // composer-row-rhythm.test.ts's; here no two of its controls touch or sit far apart. + for (const [index, control] of measured.left.slice(1).entries()) { + const gap = control.rect.left - measured.left[index]!.rect.right; + if (gap < 3.5 || gap > 12.5) + problems.push(`${measured.left[index]!.name}-${control.name} ${gap.toFixed(1)} px apart`); + } if (measured.spinners.length > 0) problems.push(`a ring or spinner in the row: ${measured.spinners.join(", ")}`); if (measured.locks > 1) problems.push(`${measured.locks} locks in the row`); diff --git a/webviews/test/composer-row-rhythm.test.ts b/webviews/test/composer-row-rhythm.test.ts new file mode 100644 index 000000000000..a96ba423c4d3 --- /dev/null +++ b/webviews/test/composer-row-rhythm.test.ts @@ -0,0 +1,274 @@ +// The composer's controls row keeps one rhythm (Leo 2026-10-14: "the footer controls are spaced +// unevenly, with big random gaps between 'Claude Opus', 'Medium', 'Normal' and 'Trusted this +// chat's…'. Use one consistent gap, group related controls, and right-align the trailing cluster"). +// +// The row is four groups: [+] [model · effort] [permission mode · Plan] ……… [Interrupt · mic · Send]. +// What a person sees between two controls is the space between what each draws (its text, its +// icons' boxes, or Send's disc), not between their transparent hit boxes, so this measures that. +// An icon counts as its box, not its strokes: icons share one grid, so equal box gaps read as equal. +// The effort chip and the mic may be absent (a short row, or effort folded into the model chip): +// - inside a group, every control is the same distance from the next (within 1 px); +// - between the leading groups, every gap is the same, and larger than the in-group gap; +// - inside the chips (model, effort, permission mode, Interrupt), the text sits the same distance +// from the chip's leading and trailing edges, and an icon or chevron the same distance from the +// text, in every chip; +// - the trailing cluster ends at the bar's content edge, so Send lines up with the row's padding. +// +// It renders the real composer (the gallery's `agent-pane.composer-row` entry, a running Claude chat +// with text typed so Interrupt shows beside the mic and Send) in Chromium at four pane widths. +// Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +type Span = { left: number; right: number }; +type Control = { + name: string; + /** The control's hit box. */ + box: Span; + /** What it draws, leading to trailing: text runs and icons (Send: its disc). */ + parts: { kind: "text" | "icon" | "disc"; left: number; right: number }[]; +}; +type Row = { bar: Span; content: Span; controls: Control[] }; + +await requireBrowserLane("composer-row-rhythm.test.ts", async () => { + /** Runs in the page: each visible control of the row and what it draws. */ + function measure(): Row { + const bar = document.querySelector(".acpmux-composer-bar")!; + const barRect = bar.getBoundingClientRect(); + const style = getComputedStyle(bar); + const named: [string, string][] = [ + ["plus", ".acpmux-composer-plus .acpmux-picker-button"], + ["model", ".acpmux-model .acpmux-picker-button"], + ["effort", ".acpmux-effort .acpmux-picker-button"], + ["mode", ".acpmux-access-trigger"], + ["plan", ".acpmux-plan"], + ["steer", ".acpmux-steer"], + ["mic", ".acpmux-mic"], + ["send", ".acpmux-send"], + ]; + const shown = (element: Element) => { + for (let node: Element | null = element; node && node !== bar; node = node.parentElement) { + const css = getComputedStyle(node); + if (css.display === "none" || css.visibility !== "visible" || css.opacity === "0") return false; + } + return element.getBoundingClientRect().width > 0; + }; + const controls = named.flatMap(([name, selector]): Control[] => { + const element = bar.querySelector(selector); + if (!element || !shown(element)) return []; + const rect = element.getBoundingClientRect(); + const parts: Control["parts"] = []; + const disc = getComputedStyle(element).backgroundColor; + if (name === "send" && disc !== "rgba(0, 0, 0, 0)" && disc !== "transparent") + parts.push({ kind: "disc", left: rect.left, right: rect.right }); + else { + const walk = (node: Node) => { + if (node instanceof SVGSVGElement) { + if (shown(node)) { + const box = node.getBoundingClientRect(); + parts.push({ kind: "icon", left: box.left, right: box.right }); + } + return; + } + if (node.nodeType === Node.TEXT_NODE) { + if (!node.textContent?.trim() || !node.parentElement || !shown(node.parentElement)) return; + const range = document.createRange(); + range.selectNodeContents(node); + const box = range.getBoundingClientRect(); + // A label cut by its box (an ellipsis) draws only inside that box. + let left = box.left; + let right = box.right; + for ( + let clip: Element | null = node.parentElement; + clip && clip !== element.parentElement; + clip = clip.parentElement + ) + if (getComputedStyle(clip).overflowX !== "visible") { + const bounds = clip.getBoundingClientRect(); + left = Math.max(left, bounds.left); + right = Math.min(right, bounds.right); + } + if (right - left > 0) parts.push({ kind: "text", left, right }); + return; + } + if (node instanceof HTMLElement) { + // A label drawn by a pseudo-element (content: attr(...)) has no text node of its own. + const before = getComputedStyle(node, "::before").content; + if (before && before !== "none" && before !== "normal" && before !== '""' && shown(node)) { + const box = node.getBoundingClientRect(); + if (box.width > 0) parts.push({ kind: "text", left: box.left, right: box.right }); + } + } + for (const child of node.childNodes) walk(child); + }; + walk(element); + } + parts.sort((a, b) => a.left - b.left); + return [{ name, box: { left: rect.left, right: rect.right }, parts }]; + }); + controls.sort((a, b) => a.box.left - b.box.left); + return { + bar: { left: barRect.left, right: barRect.right }, + content: { + left: barRect.left + parseFloat(style.paddingLeft) + parseFloat(style.borderLeftWidth), + right: barRect.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth), + }, + controls, + }; + } + + setDefaultTimeout(180_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("composer-row-rhythm: skipping (run `bunx playwright install chromium`)"); + describe.skip("composer row rhythm", () => test("chromium", () => {})); + return; + } + const webviews = path.resolve(import.meta.dir, ".."); + const { createServer } = await import("vite"); + let vite: Awaited> | undefined; + let browser: Browser | undefined; + const stageURL = (variant: string, scheme: string) => + `http://127.0.0.1:${(vite!.httpServer!.address() as { port: number }).port}/gallery/frame.html?entry=agent-pane.composer-row&variant=${variant}&theme=${encodeURIComponent(scheme === "light" ? "Apple System Colors Light" : "Apple System Colors")}`; + + beforeAll(async () => { + vite = await createServer({ + configFile: path.join(webviews, "vite.config.gallery-dev.ts"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + browser = await chromium.launch({ headless: true }); + // Warm the stage once: the first load can run Vite's dependency optimizer and reload mid-compile. + const warm = await browser.newPage({ viewport: { width: 760, height: 300 } }); + await warm.goto(stageURL("queued", "light")); + for (;;) { + try { + await warm.waitForFunction(() => document.documentElement.dataset.galleryReady, undefined, { timeout: 0 }); + break; + } catch (error) { + if (!/context was destroyed|navigat/i.test(String(error))) throw error; + } + } + await warm.close(); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + /** A running Claude chat with a prompt typed, so Interrupt, the mic and Send all show. */ + const row = async (width: number, scheme: string) => { + const page = await browser!.newPage({ viewport: { width, height: 300 } }); + await page.goto(stageURL("queued", scheme)); + await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1"); + await page.locator(".acpmux-effort .acpmux-picker-button").waitFor(); + await page.locator(".acpmux-composer-box [contenteditable='true'], .acpmux-composer-box textarea").first().click(); + await page.keyboard.type("Also cover the 503 path"); + await page.locator(".acpmux-steer").waitFor(); + await page.evaluate(() => document.fonts.ready); + // Let the row's fit pass (composerRowFit.ts) settle after the typed text. + await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))); + const measured = await page.evaluate(measure); + await page.close(); + return measured; + }; + + const GROUPS = [["plus"], ["model", "effort"], ["mode", "plan"], ["steer", "mic", "send"]]; + const group = (name: string) => GROUPS.findIndex((members) => members.includes(name)); + const drawn = (control: Control): Span => ({ + left: control.parts[0]?.left ?? control.box.left, + right: control.parts.at(-1)?.right ?? control.box.right, + }); + + for (const width of [760, 600, 480, 420]) + for (const scheme of ["light", "dark"]) + test(`at ${width} px (${scheme}): one gap in a group, one between groups, Send at the edge`, async () => { + const measured = await row(width, scheme); + const { controls } = measured; + const names = controls.map((control) => control.name); + const inGroup: { between: string; gap: number }[] = []; + const betweenGroups: { between: string; gap: number }[] = []; + for (let index = 1; index < controls.length; index++) { + const before = controls[index - 1]!; + const after = controls[index]!; + // The row's free space sits between the leading groups and the trailing cluster. + if (group(after.name) === 3 && group(before.name) !== 3) continue; + const gap = { between: `${before.name}→${after.name}`, gap: drawn(after).left - drawn(before).right }; + (group(before.name) === group(after.name) ? inGroup : betweenGroups).push(gap); + } + // Inside each chip: the text's inset from the leading and trailing edges, and the space + // between an icon or chevron and the text. + const insides = controls + .filter((control) => ["model", "effort", "mode", "plan", "steer"].includes(control.name)) + .map((control) => { + const { parts, box } = control; + const gaps = parts.slice(1).map((part, index) => part.left - parts[index]!.right); + return { + name: control.name, + lead: parts[0] ? parts[0].left - box.left : 0, + trail: parts.at(-1) ? box.right - parts.at(-1)!.right : 0, + // Leading icon to text, and text to chevron, where the chip draws them. + iconGap: parts[0]?.kind === "icon" && parts[1]?.kind === "text" ? gaps[0] : undefined, + chevronGap: parts.at(-1)?.kind === "icon" && parts.at(-2)?.kind === "text" ? gaps.at(-1) : undefined, + shape: parts.map((part) => part.kind).join("+"), + }; + }); + const send = controls.find((control) => control.name === "send"); + console.log( + JSON.stringify({ + width, + scheme, + names, + barEdges: { + lead: controls[0] ? drawn(controls[0]).left - measured.bar.left : undefined, + trail: send ? measured.bar.right - drawn(send).right : undefined, + padding: [measured.content.left - measured.bar.left, measured.bar.right - measured.content.right], + }, + boxGaps: controls + .slice(1) + .map( + (control, index) => + `${controls[index]!.name}→${control.name} ${(control.box.left - controls[index]!.box.right).toFixed(1)}`, + ), + inGroup: inGroup.map(({ between, gap }) => `${between} ${gap.toFixed(1)}`), + betweenGroups: betweenGroups.map(({ between, gap }) => `${between} ${gap.toFixed(1)}`), + insides, + }), + ); + const problems: string[] = []; + // The effort chip and the mic may leave a short row (or fold into the model chip); the + // rest always shows. + for (const name of ["plus", "model", "mode", "steer", "send"]) + if (!names.includes(name)) problems.push(`${name} is not in the row`); + const spread = (values: number[]) => (values.length > 1 ? Math.max(...values) - Math.min(...values) : 0); + const list = (gaps: { between: string; gap: number }[]) => + gaps.map(({ between, gap }) => `${between} ${gap.toFixed(1)}`).join(", "); + if (spread(inGroup.map(({ gap }) => gap)) > 1) problems.push(`uneven gaps inside groups: ${list(inGroup)}`); + if (spread(betweenGroups.map(({ gap }) => gap)) > 1) + problems.push(`uneven gaps between groups: ${list(betweenGroups)}`); + const tight = Math.max(...inGroup.map(({ gap }) => gap)); + for (const { between, gap } of betweenGroups) + if (gap <= tight + 1) problems.push(`${between} ${gap.toFixed(1)} is not wider than the in-group gap`); + for (const key of ["lead", "trail", "iconGap", "chevronGap"] as const) { + const values = insides.flatMap((inside) => (inside[key] === undefined ? [] : [inside[key]!])); + if (spread(values) > 1) + problems.push( + `chips' ${key} differs: ${insides.map((inside) => `${inside.name} ${inside[key]?.toFixed(1) ?? "-"}`).join(", ")}`, + ); + } + // The + glyph and Send's disc sit the same distance from the bar's sides. + const lead = controls[0] ? drawn(controls[0]).left - measured.bar.left : 0; + const trail = send ? measured.bar.right - drawn(send).right : 0; + if (Math.abs(lead - trail) > 1) + problems.push(`the row's drawn edges differ: ${lead.toFixed(1)} px leading, ${trail.toFixed(1)} px trailing`); + if (send && Math.abs(send.box.right - measured.content.right) > 1) + problems.push( + `Send ends ${(measured.content.right - send.box.right).toFixed(1)} px before the bar's content edge`, + ); + expect(problems).toEqual([]); + }); +}); From d28e48caa888199c4b6cc932be526c08d2873269 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 18:36:24 -0700 Subject: [PATCH 116/183] Agent chat and channels: one hover action per row (#20439) * test: rows show one hover action; the rest are on right-click Leo dogfood: hover buttons everywhere. A prompt keeps Edit (Copy is Copy Message), a call's output copies from its menu, a queued prompt keeps Send now, and a channel message keeps More, with every action on right-click. The hover-actions experiment's all arm keeps them all. Co-Authored-By: Claude Opus 5.5 * feat: rows show one hover action; the rest are on right-click Leo dogfood: hover buttons everywhere. Behind the hover-actions experiment, default one: - a prompt shows Edit alone; Copy is Copy Message on right-click - a call's output drops its corner Copy; Copy Output on right-click - a queued prompt shows Send now alone; Edit and Remove are on its menu and keys (Return, Delete) - a channel message shows More alone, with every action; a right-click on the message opens the same items (both arms) The all arm keeps every hover action for comparison. Sidebar rows are unchanged (Lawrence's sidebar rework). Co-Authored-By: Claude Opus 5.5 * fix: a channel message keeps its body through edits; links keep their menu One MessageActions wrapper whether or not the actions show, so starting or ending an edit never remounts the message body. A right-click on a link keeps the page's Copy Link and Open Link. Co-Authored-By: Claude Opus 5.5 * test: a hover shows no Copy on a prompt (it is on right-click) Co-Authored-By: Claude Opus 5.5 * test: chat column header says where Copy went Co-Authored-By: Claude Opus 5.5 * test: turn footer aligns to the column edge when a prompt has no hover actions #20382 measured the reply's actions against a prompt's last hover button; with one hover action, a plain prompt has none (Copy and Edit are in its context menu), so the column's edge stands in. Co-Authored-By: Claude Opus 5.5 * style: format turn footer test Co-Authored-By: Claude Opus 5.5 * test: turn footer actions end at the column edge, matching a prompt's when it has any Co-Authored-By: Claude Opus 5.5 * style: format the merged message actions Co-Authored-By: Claude Opus 5.5 * fix: hover-trim More card shadow on the elevation scale; keep the footer alignment check The one-arm More card cast a literal blur, which subtle-elevation forbids; it now uses --elevation-2 like the toolbar. The turn footer test runs the all arm so a plain prompt keeps its Copy and the actions still line up with it. Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at d0dc31f0b5ca - vp fmt webviews/src/pages/home-channels/cloudHome.ts Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38649125263 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- .../agent-session/acpmux/ComposerQueue.tsx | 11 +- .../acpmux/conversation/ToolRow.tsx | 18 +- .../acpmux/conversation/UserBubble.tsx | 39 ++-- webviews/src/experiments/registry.ts | 3 +- .../pages/home-channels/MessageActions.tsx | 175 ++++++++++++------ .../src/pages/home-channels/MessageRow.tsx | 48 ++--- webviews/src/pages/home-channels/styles.css | 11 ++ webviews/src/ui/hoverActions.experiment.ts | 22 +++ webviews/test/agent-pane-chat-column.test.ts | 10 +- webviews/test/agent-pane-turn-footer.test.ts | 11 +- webviews/test/edit-resend.test.tsx | 18 +- webviews/test/home-message-actions.test.tsx | 134 ++++++++++++++ webviews/test/queue-row-actions.test.tsx | 80 ++++++++ webviews/test/transcript-output-copy.test.tsx | 22 ++- 14 files changed, 482 insertions(+), 120 deletions(-) create mode 100644 webviews/src/ui/hoverActions.experiment.ts create mode 100644 webviews/test/home-message-actions.test.tsx create mode 100644 webviews/test/queue-row-actions.test.tsx diff --git a/webviews/src/agent-session/acpmux/ComposerQueue.tsx b/webviews/src/agent-session/acpmux/ComposerQueue.tsx index abe38f5915cf..1e88baee95f0 100644 --- a/webviews/src/agent-session/acpmux/ComposerQueue.tsx +++ b/webviews/src/agent-session/acpmux/ComposerQueue.tsx @@ -12,6 +12,8 @@ import { Icon } from "./icons/Icon"; import type { AcpmuxSnapshot, QueueEdit } from "./model"; import type { Translate } from "./i18n"; import { ContextMenu, type ContextMenuItem } from "../../ui/ContextMenu"; +import { experimentArm } from "../../experiments/experiment"; +import { hoverActions } from "../../ui/hoverActions.experiment"; import { rovingTabStopProps } from "../../ui/listRowKeyboard"; type QueuedEntry = AcpmuxSnapshot["queue"][number]; @@ -93,6 +95,7 @@ export function ComposerQueue({ }; const remove = onRemove && ((entry: QueuedEntry) => act(entry.id, () => onRemove(entry.id))); const sendNow = onChange && ((entry: QueuedEntry) => act(entry.id, () => onChange(entry.id, { op: "send_now" }))); + const allActions = experimentArm(hoverActions) === "all"; // A move to the place of the row shown at `index` (acpmux counts rows still leaving too); the // moved row keeps focus once acpmux's new order draws. const move = @@ -208,7 +211,9 @@ export function ComposerQueue({ {entry.prompt} )} - {editing !== entry.id && (sendNow || onChange || remove) && ( + {/* Send now is the row's one hover action; Edit and Remove are on its menu and keys (Leo dogfood + 2026-10-13). The `hover-actions` experiment's "all" arm shows them too. */} + {editing !== entry.id && (sendNow || (allActions && (onChange || remove))) && ( {sendNow && ( )} - {onChange && ( + {onChange && allActions && ( )} - {remove && ( + {remove && allActions && ( )} - + {copies && ( + + )} {through !== undefined && ( )} @@ -246,7 +246,7 @@ function UndoButton({ : state.phase === "failed" ? t("edited.undoFailed") : t("edited.undo")} - + ); } @@ -355,8 +355,8 @@ function DirPart({ dir }: { dir: string }) { const PlusMinus = () => ( span{overflow:hidden;text-overflow:ellipsis} .acpmux-fh-name>span:not(.acpmux-fh-dir){flex:none} .acpmux-fh-dir{min-width:0;color:var(--agent-muted)} @@ -357,28 +357,28 @@ .acpmux-file-header:hover .acpmux-fh-chevron,.acpmux-fh-name:focus-visible .acpmux-fh-chevron,.acpmux-fh-name[aria-expanded=false] .acpmux-fh-chevron{display:block} .acpmux-fh-name[aria-expanded=false] .acpmux-fh-chevron{transform:rotate(-90deg)} .acpmux-file-header[data-viewed] .acpmux-fh-name{color:var(--agent-muted)} -.acpmux-fh-badge{flex:none;font-size:11px;line-height:18px;padding:0 7px;border-radius:4px;background:var(--agent-control,color-mix(in srgb,var(--agent-text) 10%,var(--agent-page-bg)));color:var(--agent-muted)} -.acpmux-file-header .acpmux-diff-counts{gap:5px} -.acpmux-fh-btn{display:inline-flex;align-items:center;justify-content:center;flex:none;width:24px;height:24px;border:0;border-radius:6px;background:none;color:var(--agent-muted);cursor:pointer} +.acpmux-fh-badge{flex:none;font-size:var(--text-detail);line-height:16px;color:var(--agent-muted)} +.acpmux-file-header .acpmux-diff-counts{gap:5px;font-size:var(--text-detail)} +.acpmux-fh-btn{display:inline-flex;align-items:center;justify-content:center;flex:none;width:22px;height:22px;border:0;border-radius:6px;background:none;color:var(--agent-muted);cursor:pointer} .acpmux-fh-btn:hover{background:var(--agent-control-hover,color-mix(in srgb,var(--agent-text) 10%,transparent));color:var(--agent-text)} .acpmux-fh-btn[aria-pressed=true]{color:var(--agent-text)} @media (max-width:620px){.acpmux-diff-tree{display:none}.acpmux-fh-dir{display:none}} -.acpmux-edited{position:relative;margin-bottom:6px;border-radius:9px;overflow:hidden;background:color-mix(in srgb,var(--agent-text) 3%,var(--agent-page-bg))} -.acpmux-edited::after{content:"";position:absolute;inset:0;border-radius:inherit;box-shadow:inset 0 0 0 .5px var(--agent-border-strong,var(--agent-border));pointer-events:none} -.acpmux-edited-head{display:flex;align-items:center;gap:11px;min-height:58px;box-sizing:border-box;padding:9px 12px;background:color-mix(in srgb,var(--agent-text) 5%,var(--agent-page-bg))} +.acpmux-edited{position:relative;margin-bottom:6px;border-radius:8px;overflow:hidden;background:color-mix(in srgb,var(--agent-text) 2%,var(--agent-page-bg))} +.acpmux-edited::after{content:"";position:absolute;inset:0;border-radius:inherit;box-shadow:inset 0 0 0 1px var(--agent-border);pointer-events:none} +.acpmux-edited-head{display:flex;align-items:center;gap:8px;min-height:36px;box-sizing:border-box;padding:4px 6px 4px 12px} .acpmux-edited-head:not(:last-child){box-shadow:inset 0 -1px 0 var(--agent-border)} -.acpmux-edited-icon{flex:none;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:8px;background:var(--agent-page-bg);color:var(--agent-muted)} -.acpmux-edited-title{flex:1;min-width:0;line-height:20px} -.acpmux-edited-title>div:first-child{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.acpmux-edited-title .acpmux-diff-counts{font-size:12px} -.acpmux-edited-outside{margin-left:8px;color:var(--agent-muted);font-size:12px} -.acpmux-review-changes{flex:none;height:28px;padding:0 10px;border:0;border-radius:8px;background:var(--agent-control,color-mix(in srgb,var(--agent-text) 10%,var(--agent-page-bg)));box-shadow:inset 0 0 0 1px var(--agent-border);color:var(--agent-text);font:inherit;cursor:pointer} -.acpmux-review-changes:hover{background:var(--agent-control-hover,color-mix(in srgb,var(--agent-text) 10%,transparent))} -.acpmux-edited-undo{flex:none;display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 8px;border:0;border-radius:8px;background:transparent;color:var(--agent-text);font:inherit;cursor:pointer} -.acpmux-edited-undo:hover:not(:disabled){background:var(--agent-control-hover,color-mix(in srgb,var(--agent-text) 10%,transparent))} +.acpmux-edited-icon{flex:none;display:flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--agent-muted)} +.acpmux-edited-title{flex:1;min-width:0;display:flex;align-items:baseline;gap:8px;line-height:18px;font-size:var(--text-body)} +.acpmux-edited-title>div:first-child{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.acpmux-edited-title .acpmux-diff-counts{font-size:var(--text-detail)} +.acpmux-edited-outside{color:var(--agent-muted);font-size:var(--text-detail)} +.acpmux-review-changes{flex:none;height:22px;padding:0 8px;border:0;border-radius:6px;background:transparent;box-shadow:inset 0 0 0 1px var(--agent-border);color:var(--agent-text);font:inherit;font-size:var(--text-detail);cursor:pointer} +.acpmux-review-changes:hover{background:color-mix(in srgb,var(--agent-text) 6%,transparent)} +.acpmux-edited-undo{flex:none;display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 6px;border:0;border-radius:6px;background:transparent;color:var(--agent-muted);font:inherit;font-size:var(--text-detail);cursor:pointer} +.acpmux-edited-undo:hover:not(:disabled){background:color-mix(in srgb,var(--agent-text) 6%,transparent);color:var(--agent-text)} .acpmux-edited-undo:disabled{color:var(--agent-muted);cursor:default} .acpmux-edited-undo:focus-visible{outline:1.5px solid var(--agent-muted);outline-offset:1px} -.acpmux-edited-file{display:flex;align-items:center;gap:12px;width:100%;height:34px;box-sizing:border-box;padding:0 12px;border:0;background:none;color:var(--agent-text);font:inherit;text-align:left} +.acpmux-edited-file{display:flex;align-items:center;gap:12px;width:100%;height:26px;box-sizing:border-box;padding:0 12px;border:0;background:none;color:var(--agent-text);font:inherit;font-size:var(--text-detail);line-height:16px;text-align:left} button.acpmux-edited-file{cursor:pointer} button.acpmux-edited-file:hover{background:color-mix(in srgb,var(--agent-text) 4%,transparent)} .acpmux-turn-preview{position:relative;margin-bottom:6px;border-radius:9px;overflow:hidden;background:color-mix(in srgb,var(--agent-text) 3%,var(--agent-page-bg))} @@ -390,10 +390,10 @@ button.acpmux-edited-file:hover{background:color-mix(in srgb,var(--agent-text) 4 .acpmux-turn-preview-address:focus-visible{outline:1.5px solid var(--agent-muted);outline-offset:1px;border-radius:3px} .acpmux-turn-preview-frame{position:relative;height:180px;overflow:hidden;cursor:pointer;background:var(--agent-page-bg)} .acpmux-turn-preview-frame iframe{position:absolute;top:0;left:0;width:400%;height:400%;border:0;transform:scale(.25);transform-origin:0 0;pointer-events:none;background:var(--agent-page-bg)} -.acpmux-edited-path{flex:1;min-width:0;display:flex;white-space:nowrap;overflow:hidden} +.acpmux-edited-path{flex:1;min-width:0;display:flex;white-space:nowrap;overflow:hidden;font-family:var(--cv-code-font,var(--cv-mono,ui-monospace,"SF Mono",Menlo,monospace))} .acpmux-edited-dir{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--agent-muted)} .acpmux-edited-base{flex:none} -.acpmux-edited-more{display:flex;align-items:center;gap:8px;width:100%;height:34px;box-sizing:border-box;padding:0 12px;border:0;background:color-mix(in srgb,var(--agent-text) 5%,var(--agent-page-bg));color:var(--agent-muted);font:inherit;cursor:pointer} +.acpmux-edited-more{display:flex;align-items:center;gap:4px;width:100%;height:26px;box-sizing:border-box;padding:0 12px;border:0;background:none;color:var(--agent-muted);font:inherit;font-size:var(--text-detail);cursor:pointer} .acpmux-edited-more:hover{color:var(--agent-text)} .acpmux-permission-panel{display:flex;flex-direction:column;gap:var(--acp-row-gap,8px);flex-shrink:0;min-height:0;max-height:45%;overflow:auto;padding-bottom:0;font-size:var(--acp-font-size,13px);line-height:var(--acp-line-height,1.5)} diff --git a/webviews/src/agent-session/acpmux/turnChanges/turnChanges.css b/webviews/src/agent-session/acpmux/turnChanges/turnChanges.css index b10f75378673..6b87774d48c8 100644 --- a/webviews/src/agent-session/acpmux/turnChanges/turnChanges.css +++ b/webviews/src/agent-session/acpmux/turnChanges/turnChanges.css @@ -1,27 +1,23 @@ /* The edited-files card's Undo confirmation, per-file Undo results and collapse toggle (conversation/EditedFilesCard.tsx). The card itself is styled in styles.css (.acpmux-edited). */ -.acpmux-edited-base{font-weight:600} -.acpmux-edited-toggle{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:8px;background:transparent;color:var(--agent-muted);cursor:pointer} +.acpmux-edited-toggle{flex:none;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:var(--agent-muted);cursor:pointer} .acpmux-edited-toggle:hover{background:var(--agent-control-hover,color-mix(in srgb,var(--agent-text) 10%,transparent));color:var(--agent-text)} .acpmux-edited-toggle:focus-visible,.acpmux-edited-cancel:focus-visible,.acpmux-edited-viewdiff:focus-visible{outline:1.5px solid var(--agent-muted);outline-offset:1px} -.acpmux-edited-undone{flex:none;color:var(--agent-muted);font-size:12px} -.acpmux-edited-confirm{display:flex;align-items:center;gap:8px;padding:9px 12px;box-shadow:inset 0 -1px 0 var(--agent-border)} +.acpmux-edited-undone{flex:none;color:var(--agent-muted);font-size:var(--text-detail)} +.acpmux-edited-confirm{display:flex;align-items:center;gap:8px;padding:6px 6px 6px 12px;box-shadow:inset 0 -1px 0 var(--agent-border)} .acpmux-edited-confirm-text{flex:1;min-width:0;line-height:18px} -.acpmux-edited-confirm-skip{color:var(--agent-muted);font-size:12px} -.acpmux-edited-cancel{flex:none;height:28px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--agent-text);font:inherit;cursor:pointer} +.acpmux-edited-confirm-skip{color:var(--agent-muted);font-size:var(--text-detail)} +.acpmux-edited-cancel{flex:none;height:22px;padding:0 8px;border:0;border-radius:6px;background:transparent;color:var(--agent-text);font:inherit;font-size:var(--text-detail);cursor:pointer} .acpmux-edited-cancel:hover{background:var(--agent-control-hover,color-mix(in srgb,var(--agent-text) 10%,transparent))} .acpmux-edited-undo-confirm{background:color-mix(in srgb,var(--agent-text) 9%,transparent)} -.acpmux-edited-status{display:flex;align-items:center;gap:8px;min-height:24px;padding:0 12px 6px;color:var(--agent-muted);font-size:12px} +.acpmux-edited-status{display:flex;align-items:center;gap:8px;min-height:22px;padding:0 12px 4px;color:var(--agent-muted);font-size:var(--text-detail)} .acpmux-edited-status-changed{color:var(--agent-warning,var(--agent-muted))} .acpmux-edited-viewdiff{height:22px;padding:0 6px;border:0;border-radius:6px;background:transparent;color:var(--agent-text);font:inherit;cursor:pointer;text-decoration:underline} -/* The card as drawn in the target (2026-10-07): a 1px theme border, a dark icon tile with the ± - glyph, the title over the turn's +/- totals, Undo as a text button and View changes - as an outlined one; rows put the directory in the secondary color and cut its middle. */ -.acpmux-edited::after{box-shadow:inset 0 0 0 1px var(--agent-border-strong,var(--agent-border))} -.acpmux-edited-icon{background:var(--agent-page-bg);color:var(--agent-text);box-shadow:inset 0 0 0 1px var(--agent-border)} -.acpmux-edited-sub{display:flex;align-items:center;gap:8px;min-width:0;line-height:18px;font-size:12px} -.acpmux-review-changes{background:transparent;box-shadow:inset 0 0 0 1px var(--agent-border-strong,var(--agent-border))} -.acpmux-review-changes:hover{background:color-mix(in srgb,var(--agent-text) 8%,transparent)} +/* The card at Codex/Claude restraint (cx-ncc.99, 2026-10-14): one 1px theme rim, a 14px glyph with + no tile, the title and the turn's +/- totals on one line, Undo as a text button and View + changes as a hairline one; 26px rows put the path in the chat's monospace, the directory in the + secondary color with its middle cut. Nothing is heavier than regular weight. */ +.acpmux-edited-sub{display:flex;align-items:baseline;gap:8px;flex:none;min-width:0;line-height:16px;font-size:var(--text-detail)} .acpmux-edited-dir{display:flex;min-width:0;overflow:hidden} .acpmux-edited-dir-head{flex:none} .acpmux-edited-dir-tail{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;direction:rtl;text-align:left} diff --git a/webviews/test/agent-pane-edited-calm.test.ts b/webviews/test/agent-pane-edited-calm.test.ts new file mode 100644 index 000000000000..0df2694a9c97 --- /dev/null +++ b/webviews/test/agent-pane-edited-calm.test.ts @@ -0,0 +1,266 @@ +// l10n-allow-file: test fixture prompts, replies and file text, not shipped UI. +// The Edited files card and the turn's diff stay small and calm (cx-ncc.99, Leo: "too big / too +// tonka toys"): nothing heavier than medium weight, file names in regular weight, paths in the +// chat's monospace with counts in tabular figures, compact rows, small radii and hairline rims, +// the card flush with the reply text, and diff lines no taller or larger than the terminal-sized +// code. The page is the pane's dev entry with its browser host, talking JSON-RPC to a stand-in +// acpmux daemon (as agent-pane-edited-side.test.ts). +// +// Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("agent-pane-edited-calm.test.ts", async () => { + setDefaultTimeout(120_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("agent-pane-edited-calm: skipping (run `bunx playwright install chromium`)"); + describe.skip("edited calm", () => test("chromium", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + + const CHAT = "0190a000-0000-7000-8000-0000000000c1"; + const START = Date.UTC(2026, 9, 11, 9, 0, 0); + const update = (update: Record) => ({ + method: "session/update", + params: { sessionId: "agent-1", update }, + }); + const say = (text: string) => update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } }); + type Step = [dir: string, kind: string, msg: Record]; + const edit = (id: string, path: string, oldText: string | null, newText: string): Step[] => [ + [ + "in", + "tool_call", + update({ + sessionUpdate: "tool_call", + toolCallId: id, + title: `Edit ${path}`, + kind: "edit", + status: "in_progress", + }), + ], + [ + "in", + "tool_call_update", + update({ + sessionUpdate: "tool_call_update", + toolCallId: id, + status: "completed", + content: [{ type: "diff", path, oldText, newText }], + }), + ], + ]; + const steps: Step[] = [ + ["mux", "user_message", { text: "Add retry to the uploader" }], + ["mux", "turn_started", {}], + ...edit("e1", "/home/dev/uploader/src/net/upload.ts", "a\nb\n", "a\nretry\nb\nbackoff\n"), + ...edit("e2", "/home/dev/uploader/README.md", "x\ny\n", "x\n"), + ...edit("e3", "/home/dev/uploader/src/net/backoff.ts", null, "export const backoff = 2;\n"), + ["in", "agent_message_chunk", say("Retry added to the uploader.")], + ["mux", "turn_result", { status: "completed" }], + ]; + const events = steps.map(([dir, kind, msg], index) => ({ + sessionId: CHAT, + seq: index + 1, + at: START + index * 1000, + dir, + kind, + msg, + })); + const session = { + sessionId: CHAT, + cwd: "/home/dev/uploader", + title: "Uploader retry", + harness: "claude", + family: "claude", + status: "idle", + turnCount: 1, + updatedAt: START, + }; + + function answer(method: string): unknown { + switch (method) { + case "initialize": + return { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; + case "_acpmux/watch": + return { sessions: [session] }; + case "_acpmux/attach": + return { session, lastSeq: events.length, events }; + case "_acpmux/events": + return { events: [], hasMore: false, lastSeq: events.length }; + default: + return null; + } + } + + let daemon: ReturnType | undefined; + let vite: Awaited> | undefined; + let browser: Browser | undefined; + const url = (session: string) => { + const { port } = vite!.httpServer!.address() as { port: number }; + const endpoint = `ws://127.0.0.1:${daemon!.port}/`; + return `http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${session}`; + }; + beforeAll(async () => { + daemon = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, server) { + return server.upgrade(request, { data: undefined }) ? undefined : new Response("acpmux", { status: 426 }); + }, + websocket: { + message(ws, raw) { + const message = JSON.parse(String(raw)) as { id?: number; method: string; params?: Record }; + if (message.id !== undefined) + ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method) })); + }, + }, + }); + vite = await createServer({ + configFile: path.join(webviews, "vite.config.acpmux-pane.mjs"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + browser = await chromium.launch({ headless: true }); + // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, + // again if it stalls, so the test times only the card. + for (let attempt = 1; ; attempt++) { + const warm = await browser.newPage(); + try { + await warm.goto(url(CHAT)); + await warm.getByText("Retry added to the uploader.").waitFor({ timeout: 25_000 }); + await warm.close(); + break; + } catch (error) { + await warm.close(); + if (attempt === 3) throw error; + } + } + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + await daemon?.stop(true); + }); + + /// Every element's computed look under `root`, pseudo rims included, for the bounds below. + const looks = (page: import("playwright").Page, root: string) => + page.evaluate((query) => { + const host = document.querySelector(query); + if (!host) throw new Error(`no ${query}`); + const px = (value: string) => Math.max(0, ...value.split(/[\s,]+/).map((part) => parseFloat(part) || 0)); + return [host, ...host.querySelectorAll("*")].flatMap((node) => { + const style = getComputedStyle(node); + const after = getComputedStyle(node, "::after"); + const shadows = [style.boxShadow, after.content === "none" ? "none" : after.boxShadow] + .filter((shadow) => shadow !== "none") + // An inset ring's width is its spread (the 4th length). + .map((shadow) => [...shadow.matchAll(/(-?[\d.]+)px/g)].map((m) => parseFloat(m[1]!))[3] ?? 0); + return [ + { + cls: node.getAttribute("class") ?? node.tagName, + text: node.children.length === 0 ? (node.textContent ?? "").trim() : "", + weight: Number(style.fontWeight), + radius: px(style.borderRadius), + border: Math.max( + ...["Top", "Right", "Bottom", "Left"].map((side) => + style.getPropertyValue(`border-${side.toLowerCase()}-style`) === "none" + ? 0 + : parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) || 0, + ), + ), + ring: Math.max(0, ...shadows), + }, + ]; + }); + }, root); + const style = (page: import("playwright").Page, selector: string, ...props: string[]) => + page.evaluate( + ([query, names]) => { + const node = document.querySelector(query as string); + if (!node) throw new Error(`no ${query}`); + const style = getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return { + height: rect.height, + left: rect.left, + ...Object.fromEntries((names as string[]).map((name) => [name, style.getPropertyValue(name)])), + } as Record; + }, + [selector, props] as const, + ); + const calm = (list: Awaited>) => { + const loud = list.filter((one) => one.weight > 500).map((one) => `${one.cls} ${one.text} ${one.weight}`); + const round = list.filter((one) => one.radius > 8).map((one) => `${one.cls} ${one.radius}`); + const thick = list + .filter((one) => one.border > 1 || one.ring > 1) + .map((one) => `${one.cls} ${one.border}/${one.ring}`); + return { loud, round, thick }; + }; + + test("the card: regular weights, mono paths, tabular counts, compact rows, small radii, hairline rims", async () => { + const page = await browser!.newPage({ viewport: { width: 900, height: 760 } }); + await page.goto(url(CHAT)); + await page.getByText("Retry added to the uploader.").waitFor(); + await page.locator(".acpmux-edited-file").first().waitFor(); + expect(calm(await looks(page, ".acpmux-edited"))).toEqual({ loud: [], round: [], thick: [] }); + expect((await style(page, ".acpmux-edited-base", "font-weight"))["font-weight"]).toBe("400"); + expect(String((await style(page, ".acpmux-edited-path", "font-family"))["font-family"])).toMatch(/mono|Menlo/i); + expect( + String( + (await style(page, ".acpmux-edited-file .acpmux-diff-counts", "font-variant-numeric"))["font-variant-numeric"], + ), + ).toContain("tabular-nums"); + expect((await style(page, ".acpmux-edited-file")).height).toBeLessThanOrEqual(28); + expect((await style(page, ".acpmux-edited-head")).height).toBeLessThanOrEqual(40); + expect((await style(page, ".acpmux-review-changes")).height).toBeLessThanOrEqual(24); + // No tile around the head's glyph. + expect( + await page.locator(".acpmux-edited-icon").evaluate((node) => node.getBoundingClientRect().width), + ).toBeLessThanOrEqual(16); + await page.close(); + }); + + test("the card adds no left indent: it starts where the reply's text starts", async () => { + const page = await browser!.newPage({ viewport: { width: 1200, height: 760 } }); + await page.goto(url(CHAT)); + const reply = page.getByText("Retry added to the uploader."); + await reply.waitFor(); + const text = await reply.evaluate((node) => node.getBoundingClientRect().left); + const card = (await style(page, ".acpmux-edited")).left as number; + expect(Math.abs(card - text)).toBeLessThanOrEqual(1); + await page.close(); + }); + + test("the diff: a regular file header, plain-text badges, terminal-sized code lines", async () => { + const page = await browser!.newPage({ viewport: { width: 1500, height: 800 } }); + await page.goto(url(CHAT)); + await page.getByText("Retry added to the uploader.").waitFor(); + await page.locator(".acpmux-edited-head .acpmux-review-changes").click(); + await page.locator(".acpmux-diff-panel .acpmux-file-header").first().waitFor(); + expect(calm(await looks(page, ".acpmux-diff-panel .acpmux-file-header")).loud).toEqual([]); + expect((await style(page, ".acpmux-file-header")).height).toBeLessThanOrEqual(30); + const badge = await style(page, ".acpmux-fh-badge", "background-color", "font-size"); + expect(badge["background-color"]).toBe("rgba(0, 0, 0, 0)"); + // Pierre draws the lines in its shadow root. + const lines = await page.waitForFunction(() => { + for (const host of document.querySelectorAll(".acpmux-diff-panel *")) { + const line = host.shadowRoot?.querySelector("[data-line]"); + if (!line) continue; + const style = getComputedStyle(line); + return { size: parseFloat(style.fontSize), height: line.getBoundingClientRect().height }; + } + return null; + }); + const { size, height } = (await lines.jsonValue())!; + expect(size).toBeLessThanOrEqual(12); + expect(height).toBeLessThanOrEqual(19); + await page.close(); + }); +}); From f4d317fcf694c227f49b4ac7b2034394406287e9 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 18:39:56 -0700 Subject: [PATCH 119/183] cmux-next: keep the previous run's cef.log as cef.previous.log (cx-urd.2) (#20754) * cmux-next: keep the previous run's cef.log as cef.previous.log (cx-urd.2) Chromium starts cef.log afresh at each launch, so the FATAL line of a run that crashed was gone by the time the restart notice could point at it. CEFRuntime now renames cef.log to cef.previous.log just before Chromium starts (one rename(2), replacing the older copy; no log on a first launch). Co-Authored-By: Claude Opus 5.5 * cmux-next: one CEFProfileStorage.prepareForLaunch keeps CEFRuntime under its line cap Co-Authored-By: Claude Opus 5.5 * ci(cmux-next): run CEFProfileStorageLogTests in the PR fast tier (cx-urd.2) The swift test job runs on PRs only with full-ci; the fast tier runs every PR. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../CEF/CEFProfileStorage.swift | 18 +++++++++ .../CmuxNextBrowser/CEF/CEFRuntime.swift | 2 +- .../CEFProfileStorageLogTests.swift | 38 +++++++++++++++++++ scripts/cmux-next/fast-tier.json | 3 +- 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/CEFProfileStorageLogTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFProfileStorage.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFProfileStorage.swift index a7307ce74fe9..65566fe38351 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFProfileStorage.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFProfileStorage.swift @@ -80,4 +80,22 @@ public nonisolated struct CEFProfileStorage: Hashable, Sendable { } public var logFile: URL { root.appending(path: "cef.log") } + + /// The previous run's `cef.log` (plans/cmux-next/crash-elimination.md + /// section 4). Chromium starts `cef.log` afresh at each launch, which + /// would lose the FATAL line of a run that crashed. + public var previousLogFile: URL { root.appending(path: "cef.previous.log") } + + /// Moves `cef.log` to `cef.previous.log`, replacing an older one, before + /// Chromium starts. One rename: false when there was no log to keep. + @discardableResult + public func keepPreviousLog() -> Bool { + rename(logFile.path, previousLogFile.path) == 0 + } + + /// Right before Chromium starts: the root exists and the last run's log is kept. + public func prepareForLaunch() { + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + keepPreviousLog() + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFRuntime.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFRuntime.swift index 35fbddeb42f7..0a1b2c9e498f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFRuntime.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFRuntime.swift @@ -302,7 +302,7 @@ final class CEFRuntime { self.pump = pump pump.start() - try? FileManager.default.createDirectory(at: storage.root, withIntermediateDirectories: true) + storage.prepareForLaunch() let switchSet = CEFSwitches.current( forkAPIVersion: shim.forkAPIVersion(), bundleIdentifier: Bundle.main.bundleIdentifier, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/CEFProfileStorageLogTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/CEFProfileStorageLogTests.swift new file mode 100644 index 000000000000..4c6b37690451 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/CEFProfileStorageLogTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import CmuxNextBrowser + +/// `cef.previous.log` keeps the run before this one's Chromium log, so a +/// crash's FATAL line survives the relaunch (crash-elimination.md section 4). +@Suite struct CEFProfileStorageLogTests { + private func storage() throws -> CEFProfileStorage { + let root = FileManager.default.temporaryDirectory.appending(path: "cef-log-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return CEFProfileStorage(root: root) + } + + @Test func theLogMovesAsidePreviousReplaced() throws { + let storage = try storage() + defer { try? FileManager.default.removeItem(at: storage.root) } + try Data("older run\n".utf8).write(to: storage.previousLogFile) + try Data("FATAL: last run\n".utf8).write(to: storage.logFile) + #expect(storage.keepPreviousLog()) + #expect(!FileManager.default.fileExists(atPath: storage.logFile.path)) + #expect(try String(contentsOf: storage.previousLogFile, encoding: .utf8) == "FATAL: last run\n") + } + + /// A first launch has no log; an earlier run's previous log stays. + @Test func noLogKeepsThePreviousOne() throws { + let storage = try storage() + defer { try? FileManager.default.removeItem(at: storage.root) } + try Data("older run\n".utf8).write(to: storage.previousLogFile) + #expect(!storage.keepPreviousLog()) + #expect(try String(contentsOf: storage.previousLogFile, encoding: .utf8) == "older run\n") + } + + @Test func bothLogsSitInTheRoot() { + let storage = CEFProfileStorage(root: URL(filePath: "/tmp/Chromium")) + #expect(storage.logFile.path == "/tmp/Chromium/cef.log") + #expect(storage.previousLogFile.path == "/tmp/Chromium/cef.previous.log") + } +} diff --git a/scripts/cmux-next/fast-tier.json b/scripts/cmux-next/fast-tier.json index 76d6383b492f..205de127c53b 100644 --- a/scripts/cmux-next/fast-tier.json +++ b/scripts/cmux-next/fast-tier.json @@ -6,6 +6,7 @@ {"name": "home motion tokens", "kind": "swift", "package": "Packages/Shared/CmuxHomeRender", "filter": "MotionTokenTests", "owner": "op-next-newtab", "bead": "cx-29ch2"}, {"name": "home intent mapping", "kind": "swift", "package": "Packages/Shared/CmuxHomeRender", "filter": "IntentMappingTests", "owner": "op-next-newtab", "bead": "cx-29ch2"}, {"name": "matrix entries", "kind": "swift", "package": "Packages/macOS/CmuxNext", "filter": "ModelMatrixTests", "owner": "op-next-split", "bead": "cx-gkyi1"}, - {"name": "pane zoom motion", "kind": "swift", "package": "Packages/macOS/CmuxNext", "filter": "PaneZoomMotionTests", "owner": "op-next-newtab", "bead": "cx-v0v8k"} + {"name": "pane zoom motion", "kind": "swift", "package": "Packages/macOS/CmuxNext", "filter": "PaneZoomMotionTests", "owner": "op-next-newtab", "bead": "cx-v0v8k"}, + {"name": "cef previous log", "kind": "swift", "package": "Packages/macOS/CmuxNext", "filter": "CEFProfileStorageLogTests", "owner": "cx-gallery-feed", "bead": "cx-urd.2"} ] } From f5fb2b6adf33dedbc47b64e29d838e8569a9bc73 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 19:24:46 -0700 Subject: [PATCH 120/183] fix(tabs): repaint only the selected tab when the window changes main (#20779) * test(tabs): activation repaints only the selected tab A cosmic-gray DEV build was killed by the 10 s watchdog assert while didBecomeMain recolored every tab of the strip. Pins that a window-main change repaints the selected tab only. Red on base. Co-Authored-By: Claude Opus 5.5 * fix(tabs): repaint only the selected tab when the window changes main isWindowMain only decides whether the selected tab shows its fill, yet every tab of every strip re-resolved its theme scope, re-tinted its icon and committed its own CATransaction on each activation and deactivation. Activation now repaints one tab per strip, whatever the tab count. Co-Authored-By: Claude Opus 5.5 * chore(tabs): keep TabCell within its line budget; test note Co-Authored-By: Claude Opus 5.5 * test: drive the strip's window-main observer directly, not the global notification center Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../Sources/CmuxNextTabs/TabCell.swift | 4 +- .../TabStripActivationRecolorTests.swift | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripActivationRecolorTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift index 7836b98c6152..13b3d620417d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift @@ -26,8 +26,8 @@ final class TabCell { var isCloseHovered = false { didSet { if oldValue != isCloseHovered { updateColors(animated: true, showing: isCloseHovered) } } } var isClosePressed = false { didSet { if oldValue != isClosePressed { updateColors(animated: false) } } } var isLifted = false { didSet { if oldValue != isLifted { updateLift() } } } - /// The strip's window is main (in front); off, the selected tab has no fill. - var isWindowMain = true { didSet { if oldValue != isWindowMain { updateColors(animated: false) } } } + /// The strip's window is main (in front); off, the selected tab has no fill, so only it repaints. + var isWindowMain = true { didSet { if oldValue != isWindowMain, isSelected { updateColors(animated: false) } } } /// The strip's window is on screen and not fully covered: a busy indicator spins only then /// (cx-qtgj2: a minimized or covered window costs no spinner frames). var isWindowVisible = true { didSet { if oldValue != isWindowVisible, item.isBusy { updateSpinner() } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripActivationRecolorTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripActivationRecolorTests.swift new file mode 100644 index 000000000000..2411c7b257ea --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripActivationRecolorTests.swift @@ -0,0 +1,47 @@ +import AppKit +@testable import CmuxNextTabs +import Testing + +/// A window becoming or leaving main changes one thing in its tab strip: whether the selected +/// tab shows its fill. Every tab of every strip used to re-resolve its theme and recolor itself +/// on each activation (where a DEV build's 10 s watchdog hang was sampled, cosmic-gray +/// 2026-10-14), so activation repaints the selected tab only, however many tabs the strip holds. +@MainActor @Suite(.serialized) struct TabStripActivationRecolorTests { + static let tabs = 300 + + /// Marks every unselected tab's title with a color no theme draws: a tab that repaints + /// replaces it. + static let sentinel = CGColor(srgbRed: 1, green: 0, blue: 1, alpha: 1) + + @Test func activationRepaintsOnlyTheSelectedTab() throws { + let model = TabStripModel(tabs: (0.. Date: Wed, 14 Oct 2026 19:24:54 -0700 Subject: [PATCH 121/183] window edge: flush card as a Debug opt-in (default unchanged) (#20388) * test: count the window frame where the main pane meets it Co-Authored-By: Claude Opus 5.5 * fix(next): one outline where the main pane meets the window edge The main pane card stood a 6 pt gutter inside the window edge, and the chrome shade over that gutter stepped darker than the card, so over wallpaper art a second rounded outline showed inside the window's own. The pane now meets the window edges (gutter 0) and takes the window's own corner radius there; a square (fullscreen) window squares it. The sidebar's row inset stays the one gap beside the sidebar. Co-Authored-By: Claude Opus 5.5 * fix(next): square the main pane under a standard titlebar strip With no gutter, a standard titlebar put the pane's top corners below the strip against the window's straight side, a rounded notch that is not a real edge. The window still rounds its own corners. Co-Authored-By: Claude Opus 5.5 * test: keep the 6 pt gutter by default and test the flush opt-in The gutter-reading suites run under one serialized parent, so a test that sets the Debug Settings gutter to 0 never runs beside one that reads the default. Co-Authored-By: Claude Opus 5.5 * fix(next): keep the 6 pt main pane gutter; flush is a Debug opt-in Leo's call (2026-10-14): today's look stays the default. Setting Debug Settings > Main pane gutter to 0 gives the flush card that takes the window's own corners. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../CmuxNextApp/WindowRootView+MainPane.swift | 3 +- .../Sources/CmuxNextDesign/MainPane.swift | 33 +- .../MainPaneGutterSuites.swift | 21 ++ .../MainPaneOneInsetTests.swift | 158 +++++---- .../MainPaneWindowCornerTests.swift | 69 ++-- .../SidebarSurfaceTests.swift | 320 +++++++++--------- .../WebviewPaneOneEdgeTests.swift | 319 ++++++++++------- 7 files changed, 539 insertions(+), 384 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneGutterSuites.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WindowRootView+MainPane.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WindowRootView+MainPane.swift index dd2b0439799e..5f0779568c37 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WindowRootView+MainPane.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WindowRootView+MainPane.swift @@ -63,7 +63,8 @@ extension WindowRootView { /// Insets the content host by the gutter on every side (beside the /// sidebar, under the titlebar, from the window edges, also with the - /// sidebar hidden or in fullscreen), rounds it, and reshades the chrome. + /// sidebar hidden or in fullscreen; 0, the flush opt-in, meets them), + /// rounds it, and reshades the chrome. // @intent familiar first (Lawrence 2026-10-14): a margin on every side of the main pane, as in // Helium, the sidebar's side too. func applyMainPane() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/MainPane.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/MainPane.swift index 4db5acf44e77..031ee7ebff51 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/MainPane.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/MainPane.swift @@ -3,8 +3,10 @@ import AppKit public import Foundation /// The curved main pane (cx-rkgu, Leo 2026-10-10): the workspace content -/// sits inset in the window chrome as one rounded card, as in Edge, instead -/// of reaching the sidebar and the window edges. Under +/// sits inset in the window chrome as one rounded card, as in Edge. A +/// gutter of 0 (Debug Settings, 2026-10-14) is the flush opt-in: the pane +/// meets the window's edges and takes the window's own corners, so one +/// outline shows there instead of the gutter's second one. Under /// `appearance.sidebarSurface` separate, the chrome around the card /// (sidebar, gutter, titlebar) is shaded a little darker so the curve reads /// on the one surface; by default it shares the card's surface @@ -18,11 +20,11 @@ nonisolated extension ChromeTunables { default: true, code: "ChromeTunables.mainPaneCurved") public static let mainPaneCornerRadius = Tunable.number( "chrome.mainPane.cornerRadius", .sidebar, "Main pane corner radius", - help: "Corner radius of the curved main pane. Unset: the window's corner radius less the gutter (8 in fullscreen).", default: 8, range: 0...24, step: 0.5, unit: .points, + help: "Corner radius of the curved main pane. Unset: the window's corner radius less the gutter (square in fullscreen without a gutter, else 8).", default: 8, range: 0...24, step: 0.5, unit: .points, code: "ChromeTunables.mainPaneCornerRadius") public static let mainPaneGutter = Tunable.number( "chrome.mainPane.gutter", .sidebar, "Main pane gutter", - help: "Space between the curved main pane and the sidebar, the titlebar and the window edges.", + help: "Space between the curved main pane and the sidebar, the titlebar and the window edges. 0 (flush): the pane meets the window edge and takes its corners.", default: 6, range: 0...24, step: 0.5, unit: .points, code: "ChromeTunables.mainPaneGutter") public static let mainPaneShadeLight = Tunable.number( "chrome.mainPane.shadeLight", .sidebar, "Main pane chrome shade (light)", @@ -56,7 +58,9 @@ extension Metrics { floatingPanes && !NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast } /// Inset of the main pane from the sidebar, titlebar and window edges; - /// 0 when the pane is edge to edge. Floating panes: the pane padding + /// 0 when the pane is edge to edge, or the flush opt-in (a Debug + /// Settings gutter of 0), where the pane meets the window edge and one + /// outline shows there. Floating panes: the pane padding /// (unless Debug Settings set the gutter), so the gap at the edges and /// beside the sidebar (gutter plus padding) equals the gap between two /// panes (twice the padding; the layout leaves no divider gap then). @@ -73,14 +77,19 @@ extension Metrics { } /// Corner radius of the main pane in a window whose own corners have /// `windowCornerRadius`: concentric with them (the window radius less - /// the gutter), so the gutter keeps one width around each window corner. - /// A Debug Settings override wins; without window corners (fullscreen) - /// it is `mainPaneCornerRadius`. + /// the gutter). Without a gutter the pane's corners at the window's are + /// the window's own, so the two are one outline; a square window + /// (fullscreen) gets a square pane, rounding only at real edges. Under a + /// standard titlebar strip the pane's top corners sit below it, against + /// the window's straight side, so the pane is square too (the window + /// still rounds its own corners). A Debug + /// Settings override wins; with a gutter in fullscreen it is + /// `mainPaneCornerRadius`. public static func mainPaneCornerRadius(windowCornerRadius: CGFloat) -> CGFloat { - guard mainPaneCurved, !floatingPanes, ChromeTunables.mainPaneCornerRadius.override == nil, windowCornerRadius > 0 else { - return mainPaneCornerRadius - } - return max(0, windowCornerRadius - mainPaneGutter) + guard mainPaneCurved, !floatingPanes, ChromeTunables.mainPaneCornerRadius.override == nil else { return mainPaneCornerRadius } + let gutter = mainPaneGutter + if gutter == 0 { return DesignSettings.shared.titlebar == .standard ? 0 : windowCornerRadius } + return windowCornerRadius > 0 ? max(0, windowCornerRadius - gutter) : mainPaneCornerRadius } /// Black's alpha over the chrome around the main pane: the shade of the /// separate look only under `appearance.sidebarSurface` separate (the diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneGutterSuites.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneGutterSuites.swift new file mode 100644 index 000000000000..d2c64740b54a --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneGutterSuites.swift @@ -0,0 +1,21 @@ +import CmuxNextDesign +import CoreGraphics +import Testing + +/// The suites that read the curved main pane's gutter (`chrome.mainPane.gutter`): the window +/// edge, its corners and the chrome shade. They run one at a time under this parent, so a test +/// that sets the gutter (the Debug Settings flush opt-in, gutter 0) never runs beside one that +/// reads the default. +@MainActor @Suite(.serialized) struct MainPaneGutterSuites { + /// Runs `body` with the main pane gutter at `points` (the Debug Settings override), then + /// removes the override. + static func withGutter(_ points: CGFloat, _ body: () throws -> T) rethrows -> T { + let store = TunableStore.shared + let gutter = ChromeTunables.mainPaneGutter + store.register([gutter.descriptor]) + if !store.isActive { store.activate(file: nil) } + store.set(gutter.key, .number(Double(points))) + defer { store.set(gutter.key, nil) } + return try body() + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneOneInsetTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneOneInsetTests.swift index 37e2a4c1227e..6f6e56c820b9 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneOneInsetTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneOneInsetTests.swift @@ -5,82 +5,102 @@ import CmuxNextDesign import CmuxNextSidebar import Testing -/// Edge containers batch 1 (Leo 2026-10-12, "odd padding all around the window and panes"): the -/// window stacked insets. The curved main pane sat a 6 pt gutter in from the window, every pane -/// sat another 2 pt (plus a 2 pt column gap at the sides) inside it as its own outlined, rounded -/// box, and beside the sidebar the gutter came on top of the sidebar's own 6 pt row inset. Inside -/// the main pane panes are flush and told apart by one line. The gutter itself runs on every side -/// of the main pane, the sidebar's too (Lawrence 2026-10-14: a margin all round, as in Helium). -@MainActor @Suite(.serialized) struct MainPaneOneInsetTests { - func root(sidebarHidden: Bool) -> (NSWindow, WindowRootView) { - _ = NSApplication.shared - let model = SidebarModel() - if sidebarHidden { model.presentation = .hidden } - let root = WindowRootView(sidebar: SidebarContainerView(model: model)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.contentView = root - if sidebarHidden { - root.sidebar.isHidden = true - root.sidebarHidden = true +extension MainPaneGutterSuites { + /// Edge containers batch 1 (Leo 2026-10-12, "odd padding all around the window and panes"): the + /// window stacked insets. The curved main pane sat a 6 pt gutter in from the window, every pane + /// sat another 2 pt (plus a 2 pt column gap at the sides) inside it as its own outlined, rounded + /// box, and beside the sidebar the gutter came on top of the sidebar's own 6 pt row inset. Inside + /// the main pane panes are flush and told apart by one line. The gutter itself runs on every side + /// of the main pane, the sidebar's too (Lawrence 2026-10-14: a margin all round, as in Helium). + /// With the gutter at 0 (the Debug Settings flush opt-in, 2026-10-14) the pane meets the window + /// edges and the sidebar. + @MainActor @Suite(.serialized) struct MainPaneOneInsetTests { + func root(sidebarHidden: Bool) -> (NSWindow, WindowRootView) { + _ = NSApplication.shared + let model = SidebarModel() + if sidebarHidden { model.presentation = .hidden } + let root = WindowRootView(sidebar: SidebarContainerView(model: model)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + window.contentView = root + if sidebarHidden { + root.sidebar.isHidden = true + root.sidebarHidden = true + } + root.needsLayout = true + root.layoutSubtreeIfNeeded() + return (window, root) } - root.needsLayout = true - root.layoutSubtreeIfNeeded() - return (window, root) - } - @Test func panesInsideTheMainPaneAreFlushWithOneLineBetweenThem() throws { - try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") - let style = LayoutStyle().applyingDesignMetrics() - #expect(style.panePadding == 0, "no inset of a pane inside the main pane's gutter") - #expect(!style.showsPaneBorder, "no outlined box per pane") - #expect(style.paneCornerRadius == 0, "no rounded box per pane: the main pane rounds") - #expect(style.stripGap == 0, "no gap at the column strip's edges or between columns") - #expect(style.showsDividerLine, "one line between neighboring panes") - let cell = CGRect(x: 0, y: 0, width: 400, height: 300) - #expect(PaneChromeGeometry.contentRect(forCell: cell, style: style) == cell) - } + @Test func panesInsideTheMainPaneAreFlushWithOneLineBetweenThem() throws { + try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") + let style = LayoutStyle().applyingDesignMetrics() + #expect(style.panePadding == 0, "no inset of a pane inside the main pane's gutter") + #expect(!style.showsPaneBorder, "no outlined box per pane") + #expect(style.paneCornerRadius == 0, "no rounded box per pane: the main pane rounds") + #expect(style.stripGap == 0, "no gap at the column strip's edges or between columns") + #expect(style.showsDividerLine, "one line between neighboring panes") + let cell = CGRect(x: 0, y: 0, width: 400, height: 300) + #expect(PaneChromeGeometry.contentRect(forCell: cell, style: style) == cell) + } - /// Dividers are only the default for a cmux.json that says nothing about pane chrome: a pane - /// corner radius, border color or border width describes a bordered pane, so it keeps borders - /// (dividers would round flush panes and drop the color and width). - @Test func aPaneLookSettingWithoutASeparationKeepsBorders() { - let looks = [PaneChromeOverrides(cornerRadius: 4), PaneChromeOverrides(borderColor: ThemeRGB(hex: 0x336699)), - PaneChromeOverrides(borderWidth: 2)] - for look in looks { - #expect(PaneSeparation.resolve(look, unset: .dividers) == .borders, "\(look)") + /// Dividers are only the default for a cmux.json that says nothing about pane chrome: a pane + /// corner radius, border color or border width describes a bordered pane, so it keeps borders + /// (dividers would round flush panes and drop the color and width). + @Test func aPaneLookSettingWithoutASeparationKeepsBorders() { + let looks = [PaneChromeOverrides(cornerRadius: 4), PaneChromeOverrides(borderColor: ThemeRGB(hex: 0x336699)), + PaneChromeOverrides(borderWidth: 2)] + for look in looks { + #expect(PaneSeparation.resolve(look, unset: .dividers) == .borders, "\(look)") + } + #expect(PaneSeparation.resolve(PaneChromeOverrides(), unset: .dividers) == .dividers) } - #expect(PaneSeparation.resolve(PaneChromeOverrides(), unset: .dividers) == .dividers) - } - /// A column gap the style carries is honored under every separation: the default inside the - /// main pane is no gap, but an `appearance.metrics.columnGap` the user set still applies. - @Test func aColumnGapIsHonoredUnderDividers() { - for separation in [PaneSeparation.dividers, .none] { - var style = LayoutStyle() - style.paneSeparation = separation - style.panePadding = 0 - style.columnGap = 10 - #expect(style.stripGap == 10, "\(separation)") + /// A column gap the style carries is honored under every separation: the default inside the + /// main pane is no gap, but an `appearance.metrics.columnGap` the user set still applies. + @Test func aColumnGapIsHonoredUnderDividers() { + for separation in [PaneSeparation.dividers, .none] { + var style = LayoutStyle() + style.paneSeparation = separation + style.panePadding = 0 + style.columnGap = 10 + #expect(style.stripGap == 10, "\(separation)") + } } - } - @Test func theGutterRunsBesideTheSidebarToo() throws { - let (window, root) = root(sidebarHidden: false) - defer { window.close() } - try #require(Metrics.mainPaneGutter > 0 && root.sidebar.frame.width > 0) - let sidebar = root.sidebar.frame, card = root.contentHost.frame - #expect(card.minX - sidebar.maxX == Metrics.mainPaneGutter, "the card starts \(card.minX - sidebar.maxX) pt after the sidebar") - #expect(window.contentView!.bounds.maxX - card.maxX == Metrics.mainPaneGutter, "the gutter at the window edge") - } + @Test func theGutterRunsBesideTheSidebarToo() throws { + let (window, root) = root(sidebarHidden: false) + defer { window.close() } + try #require(Metrics.mainPaneCurved && root.sidebar.frame.width > 0) + let sidebar = root.sidebar.frame, card = root.contentHost.frame + #expect(Metrics.mainPaneGutter > 0, "the default keeps a gutter on every side") + #expect(card.minX - sidebar.maxX == Metrics.mainPaneGutter, "the card starts \(card.minX - sidebar.maxX) pt after the sidebar") + #expect(window.contentView!.bounds.maxX - card.maxX == Metrics.mainPaneGutter, "the gutter at the window edge") + } - @Test func withTheSidebarHiddenTheGutterIsAtTheWindowEdge() throws { - let (window, root) = root(sidebarHidden: true) - defer { window.close() } - try #require(Metrics.mainPaneGutter > 0) - #expect(root.contentHost.frame.minX == Metrics.mainPaneGutter) + @Test func withTheSidebarHiddenTheGutterIsAtTheWindowEdge() throws { + let (window, root) = root(sidebarHidden: true) + defer { window.close() } + try #require(Metrics.mainPaneCurved) + #expect(Metrics.mainPaneGutter > 0, "the default keeps a gutter at the window edges") + #expect(root.contentHost.frame.minX == Metrics.mainPaneGutter) + } + + /// Flush (gutter 0): the pane meets every window edge, and the sidebar when it shows. + @Test(arguments: [false, true]) + func withNoGutterThePaneMeetsTheWindowEdges(sidebarHidden: Bool) throws { + try MainPaneGutterSuites.withGutter(0) { + let (window, root) = root(sidebarHidden: sidebarHidden) + defer { window.close() } + try #require(Metrics.mainPaneCurved) + let card = root.contentHost.frame, bounds = window.contentView!.bounds + #expect(card.maxX == bounds.maxX && card.maxY == bounds.maxY && card.minY == bounds.minY, + "no gutter at the window edges") + if sidebarHidden { #expect(card.minX == bounds.minX) } else { #expect(card.minX == root.sidebar.frame.maxX) } + } + } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneWindowCornerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneWindowCornerTests.swift index 1f9da885ae16..349958cbb57b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneWindowCornerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneWindowCornerTests.swift @@ -5,33 +5,48 @@ import CmuxNextDesign import CmuxNextSidebar import Testing -/// Edge containers batch 1 (Leo 2026-10-12, "the bottom-edge rounding"): the curved main pane -/// sits the gutter in from the window's edges, and its corners used a fixed 8 pt radius. At the -/// window's bottom corners that curve did not follow the window's own (about 16 pt), so the gutter -/// pinched and widened around each corner. The pane's corners are concentric with the window's: -/// the window radius less the gutter, so the gutter runs at one width all the way round. -@MainActor struct MainPaneWindowCornerTests { - func root() -> (NSWindow, WindowRootView) { - _ = NSApplication.shared - let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.contentView = root - root.needsLayout = true - root.layoutSubtreeIfNeeded() - return (window, root) - } +extension MainPaneGutterSuites { + /// Edge containers batch 1 (Leo 2026-10-12, "the bottom-edge rounding"): the curved main pane + /// sits the gutter in from the window's edges, and its corners used a fixed 8 pt radius. At the + /// window's bottom corners that curve did not follow the window's own (about 16 pt), so the + /// gutter pinched and widened around each corner. The pane's corners are concentric with the + /// window's: the window radius less the gutter. With the gutter at 0 (the Debug Settings flush + /// opt-in) they are the window's own. + @MainActor struct MainPaneWindowCornerTests { + func root() -> (NSWindow, WindowRootView) { + _ = NSApplication.shared + let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + window.contentView = root + root.needsLayout = true + root.layoutSubtreeIfNeeded() + return (window, root) + } + + @Test func theMainPaneCornersAreConcentricWithTheWindowCorners() throws { + let (window, root) = root() + defer { window.close() } + let windowRadius = CEFClipShape.windowCornerRadius(window) + let gutter = Metrics.mainPaneGutter + try #require(Metrics.mainPaneCurved && windowRadius > gutter, "a curved main pane in a window with round corners") + #expect(gutter > 0, "the default keeps a gutter at the window edges") + #expect(root.contentHost.layer?.cornerRadius == windowRadius - gutter, + "the pane's corner is the window's corner less the gutter") + } - @Test func theMainPaneCornersAreConcentricWithTheWindowCorners() throws { - let (window, root) = root() - defer { window.close() } - let windowRadius = CEFClipShape.windowCornerRadius(window) - let gutter = Metrics.mainPaneGutter - try #require(Metrics.mainPaneCurved && windowRadius > gutter, "a curved main pane in a window with round corners") - #expect(root.contentHost.layer?.cornerRadius == windowRadius - gutter, - "the pane's corner is the window's corner less the gutter") + /// Flush (gutter 0): the pane's corners are the window's own, so the two are one outline. + @Test func withNoGutterTheMainPaneCornersAreTheWindowCorners() throws { + try MainPaneGutterSuites.withGutter(0) { + let (window, root) = root() + defer { window.close() } + let windowRadius = CEFClipShape.windowCornerRadius(window) + try #require(Metrics.mainPaneCurved && windowRadius > 0, "a curved main pane in a window with round corners") + #expect(root.contentHost.layer?.cornerRadius == windowRadius, "the pane's corner is the window's own") + } + } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSurfaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSurfaceTests.swift index f239cf3618d6..933029ec57dd 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSurfaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSurfaceTests.swift @@ -6,181 +6,183 @@ import CmuxNextSettings import CmuxNextSidebar import Testing -/// `appearance.sidebarSurface` (Leo 2026-10-12): by default the sidebar draws the main pane's own -/// surface, with no shade of its own while pane outlines part them and a faint step beside the -/// default flush, divided panes; "separate" keeps the old look, where the sidebar and the -/// chrome around the curved main pane are shaded darker than the pane. The setting applies live, -/// from cmux.json through the settings applier to the open window. -@MainActor @Suite(.serialized) struct SidebarSurfaceTests { - /// A window root in a titled, full-size-content window, as the app shows it, sidebar shown. - func openRoot() -> (NSWindow, WindowRootView) { - _ = NSApplication.shared - let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) - let window = NSWindow(contentRect: root.frame, styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.contentView = root - root.needsLayout = true - root.layoutSubtreeIfNeeded() - return (window, root) - } +extension MainPaneGutterSuites { + /// `appearance.sidebarSurface` (Leo 2026-10-12): by default the sidebar draws the main pane's own + /// surface, with no shade of its own while pane outlines part them and a faint step beside the + /// default flush, divided panes; "separate" keeps the old look, where the sidebar and the + /// chrome around the curved main pane are shaded darker than the pane. The setting applies live, + /// from cmux.json through the settings applier to the open window. + @MainActor @Suite(.serialized) struct SidebarSurfaceTests { + /// A window root in a titled, full-size-content window, as the app shows it, sidebar shown. + func openRoot() -> (NSWindow, WindowRootView) { + _ = NSApplication.shared + let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) + let window = NSWindow(contentRect: root.frame, styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + window.contentView = root + root.needsLayout = true + root.layoutSubtreeIfNeeded() + return (window, root) + } - /// Loads `json` as the cmux.json, the way a settings load applies it. - @discardableResult - func load(_ json: JSONValue) -> [SettingsDiagnostic] { - let snapshot = CmuxConfigSnapshot.parse(json, validDensities: SettingsApplier.validDensities, - validMetrics: SettingsApplier.validMetrics) - return SettingsApplier(registry: ActionRegistry.standard()).apply(snapshot) - } + /// Loads `json` as the cmux.json, the way a settings load applies it. + @discardableResult + func load(_ json: JSONValue) -> [SettingsDiagnostic] { + let snapshot = CmuxConfigSnapshot.parse(json, validDensities: SettingsApplier.validDensities, + validMetrics: SettingsApplier.validMetrics) + return SettingsApplier(registry: ActionRegistry.standard()).apply(snapshot) + } - func appearance(_ members: [String: JSONValue]) -> JSONValue { - .object(["appearance": .object(members)]) - } + func appearance(_ members: [String: JSONValue]) -> JSONValue { + .object(["appearance": .object(members)]) + } - /// `appearance` members with outlined panes (`layout.paneSeparation` borders): the pane - /// outlines part the sidebar from the main pane, so the shared surface takes no step. Inside - /// the curved main pane the default separation is dividers, which draws no outline. - func outlined(_ members: [String: JSONValue] = [:]) -> JSONValue { - .object(["appearance": .object(members), "layout": .object(["paneSeparation": .string("borders")])]) - } + /// `appearance` members with outlined panes (`layout.paneSeparation` borders): the pane + /// outlines part the sidebar from the main pane, so the shared surface takes no step. Inside + /// the curved main pane the default separation is dividers, which draws no outline. + func outlined(_ members: [String: JSONValue] = [:]) -> JSONValue { + .object(["appearance": .object(members), "layout": .object(["paneSeparation": .string("borders")])]) + } - /// The color the window shows at `point` (root coordinates) under its chrome: the window's - /// one surface, under the main pane's chrome shade where that shade covers the point. - func background(at point: CGPoint, in root: WindowRootView) -> ThemeRGB { - let surface = root.themeTokens.surfaceBackground.withAlpha(1) - let shade = root.mainPaneShade.layer?.sublayers?.lazy.compactMap { $0 as? CAShapeLayer }.first - guard let shade, let path = shade.path, path.contains(point, using: .evenOdd), - let fill = shade.fillColor.flatMap({ NSColor(cgColor: $0) })?.usingColorSpace(.sRGB) else { return surface } - return ThemeRGB(red: Double(fill.redComponent), green: Double(fill.greenComponent), blue: Double(fill.blueComponent), - alpha: Double(fill.alphaComponent)).composited(over: surface) - } + /// The color the window shows at `point` (root coordinates) under its chrome: the window's + /// one surface, under the main pane's chrome shade where that shade covers the point. + func background(at point: CGPoint, in root: WindowRootView) -> ThemeRGB { + let surface = root.themeTokens.surfaceBackground.withAlpha(1) + let shade = root.mainPaneShade.layer?.sublayers?.lazy.compactMap { $0 as? CAShapeLayer }.first + guard let shade, let path = shade.path, path.contains(point, using: .evenOdd), + let fill = shade.fillColor.flatMap({ NSColor(cgColor: $0) })?.usingColorSpace(.sRGB) else { return surface } + return ThemeRGB(red: Double(fill.redComponent), green: Double(fill.greenComponent), blue: Double(fill.blueComponent), + alpha: Double(fill.alphaComponent)).composited(over: surface) + } - func sidebarBackground(_ root: WindowRootView) -> ThemeRGB { - background(at: CGPoint(x: root.sidebar.frame.midX, y: root.sidebar.frame.midY), in: root) - } + func sidebarBackground(_ root: WindowRootView) -> ThemeRGB { + background(at: CGPoint(x: root.sidebar.frame.midX, y: root.sidebar.frame.midY), in: root) + } - func mainPaneBackground(_ root: WindowRootView) -> ThemeRGB { - background(at: CGPoint(x: root.contentHost.frame.midX, y: root.contentHost.frame.midY), in: root) - } + func mainPaneBackground(_ root: WindowRootView) -> ThemeRGB { + background(at: CGPoint(x: root.contentHost.frame.midX, y: root.contentHost.frame.midY), in: root) + } - /// The sidebar's shaded look before this setting: black at the main pane's chrome shade over - /// the surface. - func separateSidebar(_ root: WindowRootView) -> ThemeRGB { - let alpha = root.themeTokens.isDark ? ChromeTunables.mainPaneShadeDark.value : ChromeTunables.mainPaneShadeLight.value - return ThemeRGB.black.withAlpha(alpha).composited(over: root.themeTokens.surfaceBackground.withAlpha(1)) - } + /// The sidebar's shaded look before this setting: black at the main pane's chrome shade over + /// the surface. + func separateSidebar(_ root: WindowRootView) -> ThemeRGB { + let alpha = root.themeTokens.isDark ? ChromeTunables.mainPaneShadeDark.value : ChromeTunables.mainPaneShadeLight.value + return ThemeRGB.black.withAlpha(alpha).composited(over: root.themeTokens.surfaceBackground.withAlpha(1)) + } - func same(_ a: ThemeRGB, _ b: ThemeRGB) -> Bool { - abs(a.red - b.red) < 0.002 && abs(a.green - b.green) < 0.002 && abs(a.blue - b.blue) < 0.002 - } + func same(_ a: ThemeRGB, _ b: ThemeRGB) -> Bool { + abs(a.red - b.red) < 0.002 && abs(a.green - b.green) < 0.002 && abs(a.blue - b.blue) < 0.002 + } - /// Waits for the window to follow a settings change (the main pane observes the settings). - func settle(_ root: WindowRootView, until condition: () -> Bool) async { - for _ in 0..<150 where !condition() { - try? await Task.sleep(for: .milliseconds(10)) - root.layoutSubtreeIfNeeded() + /// Waits for the window to follow a settings change (the main pane observes the settings). + func settle(_ root: WindowRootView, until condition: () -> Bool) async { + for _ in 0..<150 where !condition() { + try? await Task.sleep(for: .milliseconds(10)) + root.layoutSubtreeIfNeeded() + } } - } - @Test func withPaneOutlinesTheSidebarDrawsTheMainPanesSurface() async { - defer { load(.object([:])) } - load(outlined()) - let (window, root) = openRoot() - defer { window.close() } - await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } - #expect(same(sidebarBackground(root), mainPaneBackground(root))) - #expect(same(mainPaneBackground(root), root.themeTokens.surfaceBackground.withAlpha(1))) - } + @Test func withPaneOutlinesTheSidebarDrawsTheMainPanesSurface() async { + defer { load(.object([:])) } + load(outlined()) + let (window, root) = openRoot() + defer { window.close() } + await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } + #expect(same(sidebarBackground(root), mainPaneBackground(root))) + #expect(same(mainPaneBackground(root), root.themeTokens.surfaceBackground.withAlpha(1))) + } - @Test func separateKeepsTheOldShadedSidebar() async { - defer { load(.object([:])) } - load(appearance(["sidebarSurface": .string("separate")])) - let (window, root) = openRoot() - defer { window.close() } - await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } - #expect(same(sidebarBackground(root), separateSidebar(root))) - #expect(same(mainPaneBackground(root), root.themeTokens.surfaceBackground.withAlpha(1))) - } + @Test func separateKeepsTheOldShadedSidebar() async { + defer { load(.object([:])) } + load(appearance(["sidebarSurface": .string("separate")])) + let (window, root) = openRoot() + defer { window.close() } + await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } + #expect(same(sidebarBackground(root), separateSidebar(root))) + #expect(same(mainPaneBackground(root), root.themeTokens.surfaceBackground.withAlpha(1))) + } - @Test func switchingTheSettingRepaintsTheOpenWindow() async { - defer { load(.object([:])) } - load(outlined()) - let (window, root) = openRoot() - defer { window.close() } - - load(outlined(["sidebarSurface": .string("separate")])) - await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } - #expect(same(sidebarBackground(root), separateSidebar(root))) - - load(outlined(["sidebarSurface": .string("same")])) - await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } - #expect(same(sidebarBackground(root), mainPaneBackground(root))) - - // Removing the key from the file returns to the default. - load(outlined(["sidebarSurface": .string("separate")])) - await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } - load(outlined()) - await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } - #expect(same(sidebarBackground(root), mainPaneBackground(root))) - } + @Test func switchingTheSettingRepaintsTheOpenWindow() async { + defer { load(.object([:])) } + load(outlined()) + let (window, root) = openRoot() + defer { window.close() } + + load(outlined(["sidebarSurface": .string("separate")])) + await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } + #expect(same(sidebarBackground(root), separateSidebar(root))) + + load(outlined(["sidebarSurface": .string("same")])) + await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } + #expect(same(sidebarBackground(root), mainPaneBackground(root))) + + // Removing the key from the file returns to the default. + load(outlined(["sidebarSurface": .string("separate")])) + await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } + load(outlined()) + await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } + #expect(same(sidebarBackground(root), mainPaneBackground(root))) + } - @Test func aBadValueKeepsTheDefaultAndSaysWhy() async { - defer { load(.object([:])) } - let diagnostics = load(outlined(["sidebarSurface": .string("purple")])) - #expect(diagnostics.contains { $0.path == "appearance.sidebarSurface" && $0.kind == .invalidValue }) - let (window, root) = openRoot() - defer { window.close() } - await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } - #expect(same(sidebarBackground(root), mainPaneBackground(root))) - } + @Test func aBadValueKeepsTheDefaultAndSaysWhy() async { + defer { load(.object([:])) } + let diagnostics = load(outlined(["sidebarSurface": .string("purple")])) + #expect(diagnostics.contains { $0.path == "appearance.sidebarSurface" && $0.kind == .invalidValue }) + let (window, root) = openRoot() + defer { window.close() } + await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } + #expect(same(sidebarBackground(root), mainPaneBackground(root))) + } - /// With `appearance.borders` none no pane outline or edge line parts the sidebar from the - /// main pane, so the shared surface keeps a faint tonal step there: darker than the pane, - /// lighter than the separate look. - @Test func withNoLinesTheSidebarKeepsAFaintStep() async { - defer { load(.object([:])) } - load(appearance(["borders": .string("none")])) - let (window, root) = openRoot() - defer { window.close() } - let pane = root.themeTokens.surfaceBackground.withAlpha(1) - func luminance(_ color: ThemeRGB) -> Double { color.relativeLuminance } - await settle(root) { - let sidebar = luminance(sidebarBackground(root)) - return sidebar < luminance(pane) && sidebar > luminance(separateSidebar(root)) - } - #expect(luminance(sidebarBackground(root)) < luminance(pane)) - #expect(luminance(sidebarBackground(root)) > luminance(separateSidebar(root))) - #expect(same(mainPaneBackground(root), pane)) - } + /// With `appearance.borders` none no pane outline or edge line parts the sidebar from the + /// main pane, so the shared surface keeps a faint tonal step there: darker than the pane, + /// lighter than the separate look. + @Test func withNoLinesTheSidebarKeepsAFaintStep() async { + defer { load(.object([:])) } + load(appearance(["borders": .string("none")])) + let (window, root) = openRoot() + defer { window.close() } + let pane = root.themeTokens.surfaceBackground.withAlpha(1) + func luminance(_ color: ThemeRGB) -> Double { color.relativeLuminance } + await settle(root) { + let sidebar = luminance(sidebarBackground(root)) + return sidebar < luminance(pane) && sidebar > luminance(separateSidebar(root)) + } + #expect(luminance(sidebarBackground(root)) < luminance(pane)) + #expect(luminance(sidebarBackground(root)) > luminance(separateSidebar(root))) + #expect(same(mainPaneBackground(root), pane)) + } - /// The default inside the curved main pane: panes are flush and divided by one line, with no - /// outline beside the sidebar, so the shared surface keeps the faint step there. - @Test func byDefaultFlushPanesKeepAFaintStepBesideTheSidebar() async { - load(.object([:])) - let (window, root) = openRoot() - defer { window.close() } - let pane = root.themeTokens.surfaceBackground.withAlpha(1) - await settle(root) { sidebarBackground(root).relativeLuminance < pane.relativeLuminance } - #expect(sidebarBackground(root).relativeLuminance < pane.relativeLuminance) - #expect(sidebarBackground(root).relativeLuminance > separateSidebar(root).relativeLuminance) - #expect(same(mainPaneBackground(root), pane)) - } + /// The default inside the curved main pane: panes are flush and divided by one line, with no + /// outline beside the sidebar, so the shared surface keeps the faint step there. + @Test func byDefaultFlushPanesKeepAFaintStepBesideTheSidebar() async { + load(.object([:])) + let (window, root) = openRoot() + defer { window.close() } + let pane = root.themeTokens.surfaceBackground.withAlpha(1) + await settle(root) { sidebarBackground(root).relativeLuminance < pane.relativeLuminance } + #expect(sidebarBackground(root).relativeLuminance < pane.relativeLuminance) + #expect(sidebarBackground(root).relativeLuminance > separateSidebar(root).relativeLuminance) + #expect(same(mainPaneBackground(root), pane)) + } - /// Cards (like dividers and none) draw no pane outline, and the sidebar's edge line is off by - /// default, so nothing parts the shared surface either: the faint step stays. - @Test func withoutPaneOutlinesTheSidebarKeepsAFaintStep() async { - defer { load(.object([:])) } - load(.object(["layout": .object(["paneSeparation": .string("cards")])])) - let (window, root) = openRoot() - defer { window.close() } - let pane = root.themeTokens.surfaceBackground.withAlpha(1) - await settle(root) { - let sidebar = sidebarBackground(root).relativeLuminance - return sidebar < pane.relativeLuminance && sidebar > separateSidebar(root).relativeLuminance - } - #expect(sidebarBackground(root).relativeLuminance < pane.relativeLuminance) - #expect(sidebarBackground(root).relativeLuminance > separateSidebar(root).relativeLuminance) - #expect(same(mainPaneBackground(root), pane)) + /// Cards (like dividers and none) draw no pane outline, and the sidebar's edge line is off by + /// default, so nothing parts the shared surface either: the faint step stays. + @Test func withoutPaneOutlinesTheSidebarKeepsAFaintStep() async { + defer { load(.object([:])) } + load(.object(["layout": .object(["paneSeparation": .string("cards")])])) + let (window, root) = openRoot() + defer { window.close() } + let pane = root.themeTokens.surfaceBackground.withAlpha(1) + await settle(root) { + let sidebar = sidebarBackground(root).relativeLuminance + return sidebar < pane.relativeLuminance && sidebar > separateSidebar(root).relativeLuminance + } + #expect(sidebarBackground(root).relativeLuminance < pane.relativeLuminance) + #expect(sidebarBackground(root).relativeLuminance > separateSidebar(root).relativeLuminance) + #expect(same(mainPaneBackground(root), pane)) + } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WebviewPaneOneEdgeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WebviewPaneOneEdgeTests.swift index 954432b53f10..510d47b7eb3a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WebviewPaneOneEdgeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WebviewPaneOneEdgeTests.swift @@ -1,6 +1,7 @@ import AppKit @testable import CmuxNextAgentPane @testable import CmuxNextApp +@testable import CmuxNextBrowser import CmuxNextDesign @testable import CmuxNextLayout import CmuxNextPages @@ -8,139 +9,225 @@ import CmuxNextSidebar @testable import CmuxNextTabs import Testing -/// One surface edge (Leo 2026-10-13, the New Tab page on cosmic-gray): a webview pane showed the -/// curved main pane's rounded card and, a few points inside it, a second faint rounded box: the -/// pane's own content area, clipped to the pane corner radius and outlined by the pane border. -/// The page itself draws no frame. Between a webview and the window exactly one layer draws an -/// edge, the main pane card, as for any other pane. -@MainActor @Suite(.serialized) struct WebviewPaneOneEdgeTests { - private actor NoHost: AgentPaneHostProviding { - func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } - } +extension MainPaneGutterSuites { + /// One surface edge (Leo 2026-10-13, the New Tab page on cosmic-gray): a webview pane showed the + /// curved main pane's rounded card and, a few points inside it, a second faint rounded box: the + /// pane's own content area, clipped to the pane corner radius and outlined by the pane border. + /// The page itself draws no frame. Between a webview and the window exactly one layer draws an + /// edge, the main pane card, as for any other pane. + /// + /// One outline at the window edge (Leo 2026-10-13, an agent chat pane over wallpaper art): at the + /// window's top-right corner the window's own rounded edge showed and, a gutter inside it, a second + /// rounded outline: the main pane card stood off the window edge, and the chrome shade over that + /// gutter stepped darker than the card over the art. The window's frame is an edge too: with the + /// flush opt-in (Debug Settings gutter 0) the main pane takes the window's own edge and corners + /// where it meets the window, so one outline shows. The default keeps today's 6 pt gutter. + @MainActor @Suite(.serialized) struct WebviewPaneOneEdgeTests { + private actor NoHost: AgentPaneHostProviding { + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } + } - /// Hands the layout one pane: the app's pane view (tab strip and content host) showing `content`. - final class Provider: LayoutPaneContentProvider { - let content: NSView - private(set) var pane: PaneContentView? + /// Hands the layout one pane: the app's pane view (tab strip and content host) showing `content`. + final class Provider: LayoutPaneContentProvider { + let content: NSView + private(set) var pane: PaneContentView? - init(content: NSView) { self.content = content } + init(content: NSView) { self.content = content } - func makeContentView(for pane: PaneID) -> NSView { - let view = PaneContentView(stripModel: TabStripModel(tabs: [TabItem(id: TabID("tab"), title: "New Tab")]), - reveal: LaunchReveal(deadline: .zero)) - view.show(content) - self.pane = view - return view + func makeContentView(for pane: PaneID) -> NSView { + let view = PaneContentView(stripModel: TabStripModel(tabs: [TabItem(id: TabID("tab"), title: "New Tab")]), + reveal: LaunchReveal(deadline: .zero)) + view.show(content) + self.pane = view + return view + } } - } - /// A window as the app builds it: the sidebar, the curved main pane card, and one workspace - /// whose single pane shows `content`. - final class Rig { - let window: NSWindow - let root: WindowRootView - let layout: LayoutRootView - let provider: Provider - - init(content: NSView) { - _ = NSApplication.shared - provider = Provider(content: content) - let model = LayoutModel(screens: [LayoutScreen(id: "s", name: "s", layout: .splits(.leaf("p1")))], - activeScreenID: "s", focusedPane: "p1") - layout = LayoutRootView(model: model, contentProvider: provider) - root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) - window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.contentView = root - root.show(layout) - for _ in 0..<2 { - root.needsLayout = true - root.layoutSubtreeIfNeeded() + /// A window as the app builds it: the sidebar, the curved main pane card, and one workspace + /// whose single pane shows `content`. + final class Rig { + let window: NSWindow + let root: WindowRootView + let layout: LayoutRootView + let provider: Provider + + /// The window's room theme, with `art` as its wallpaper when given. + let room = ThemeScope(level: .room) + + init(content: NSView, art: BackdropArt? = nil, sidebarSide: SidebarSide = .left, sidebarHidden: Bool = false) { + _ = NSApplication.shared + provider = Provider(content: content) + let model = LayoutModel(screens: [LayoutScreen(id: "s", name: "s", layout: .splits(.leaf("p1")))], + activeScreenID: "s", focusedPane: "p1") + layout = LayoutRootView(model: model, contentProvider: provider) + let sidebarModel = SidebarModel() + if sidebarHidden { sidebarModel.presentation = .hidden } + root = WindowRootView(sidebar: SidebarContainerView(model: sidebarModel)) + window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + if let art { room.pinBackdrop(.selection(.art(art))) } + room.root(root) + room.adopt(window) + window.contentView = root + root.sidebarSide = sidebarSide + if sidebarHidden { + root.sidebar.isHidden = true + root.sidebarHidden = true + } + root.themeDidChange() + root.show(layout) + for _ in 0..<2 { + root.needsLayout = true + root.layoutSubtreeIfNeeded() + } } - } - func tearDown() { - window.orderOut(nil) - window.close() - } + func tearDown() { + window.orderOut(nil) + window.close() + } - /// Every layer between `content` (itself included) and the window that draws a surface - /// edge: a visible border, or a corner radius that clips. The pane border lives in the - /// layout's overlay plane, not in the ancestor chain, so it is added when it shows. - func edges(of content: NSView) -> [String] { - var found: [String] = [] - var view: NSView? = content - while let current = view { - if let layer = current.layer, !current.isHidden { - if layer.borderWidth > 0 && layer.opacity > 0 { - found.append("\(type(of: current)) border \(layer.borderWidth)") - } else if layer.cornerRadius > 0 && layer.masksToBounds { - found.append("\(type(of: current)) radius \(layer.cornerRadius)") + /// Every layer between `content` (itself included) and the window that draws a surface + /// edge: a visible border, or a corner radius that clips. The pane border lives in the + /// layout's overlay plane, not in the ancestor chain, so it is added when it shows. + func edges(of content: NSView) -> [String] { + var found: [String] = [] + var view: NSView? = content + while let current = view { + if let layer = current.layer, !current.isHidden { + if layer.borderWidth > 0 && layer.opacity > 0 { + found.append("\(type(of: current)) border \(layer.borderWidth)") + } else if layer.cornerRadius > 0 && layer.masksToBounds { + found.append("\(type(of: current)) radius \(layer.cornerRadius)") + } } + view = current.superview + } + let host = layout.screenViews["s"]?.displayedHosts.first + if host?.chrome.showsBorder == true || layout.model.style.showsPaneBorder { + found.append("PaneOverlayView border") } - view = current.superview + return found } - let host = layout.screenViews["s"]?.displayedHosts.first - if host?.chrome.showsBorder == true || layout.model.style.showsPaneBorder { - found.append("PaneOverlayView border") + + /// The window edges the main pane meets: every edge but the sidebar's while it shows. + var windowEdges: [NSRectEdge] { + let all: [NSRectEdge] = [.maxY, .maxX, .minY, .minX] + if root.sidebarHidden { return all } + return all.filter { $0 != (root.sidebarSide == .left ? .minX : .maxX) } + } + + /// Every rounded outline where the main pane meets the window's `edge`, the window's own + /// frame included: a card standing off the edge (a gutter step), a stroke on the card, a + /// card corner that is not the window's, and a chrome shade band along the edge. + func outlines(at edge: NSRectEdge) -> [String] { + let windowRadius = CEFClipShape.windowCornerRadius(window) + var found = ["window frame radius \(windowRadius)"] + let bounds = root.bounds, card = root.contentHost.frame + let gap: CGFloat = switch edge { + case .maxY: bounds.maxY - card.maxY + case .maxX: bounds.maxX - card.maxX + case .minY: card.minY - bounds.minY + default: card.minX - bounds.minX + } + if gap > 0.25 { found.append("card \(gap) pt inside the window edge") } + if let layer = root.contentHost.layer { + if layer.borderWidth > 0 { found.append("card border \(layer.borderWidth)") } + if layer.masksToBounds, layer.cornerRadius > 0, abs(layer.cornerRadius - windowRadius) > 0.25 { + found.append("card corner \(layer.cornerRadius) inside the window's \(windowRadius)") + } + } + // Half a point inside the window edge, along the pane's middle. + let probe: CGPoint = switch edge { + case .maxY: CGPoint(x: card.midX, y: bounds.maxY - 0.5) + case .maxX: CGPoint(x: bounds.maxX - 0.5, y: card.midY) + case .minY: CGPoint(x: card.midX, y: bounds.minY + 0.5) + default: CGPoint(x: bounds.minX + 0.5, y: card.midY) + } + let shade = root.mainPaneShade.layer?.sublayers?.lazy.compactMap { $0 as? CAShapeLayer }.first + if let shade, let path = shade.path, path.contains(probe, using: .evenOdd), + let fill = shade.fillColor, fill.alpha > 0 { + found.append("chrome shade band \(fill.alpha)") + } + return found } - return found } - } - /// The single edge is the main pane card's rounded clip. - private func expectOneEdge(_ rig: Rig, content: NSView, _ what: String) throws { - try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") - try #require(content.isDescendant(of: rig.root.contentHost), "\(what) is hosted in the main pane") - let edges = rig.edges(of: content) - #expect(edges.count == 1, "\(what): one surface edge between the page and the window, got \(edges)") - #expect(edges.first?.hasPrefix("MainPaneCardView") == true, "\(what): the edge is the main pane card's, got \(edges)") - #expect((rig.root.contentHost.layer?.cornerRadius ?? 0) > 0, "\(what): the card is rounded") - } + /// The single edge is the main pane card's rounded clip. + private func expectOneEdge(_ rig: Rig, content: NSView, _ what: String) throws { + try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") + try #require(content.isDescendant(of: rig.root.contentHost), "\(what) is hosted in the main pane") + let edges = rig.edges(of: content) + #expect(edges.count == 1, "\(what): one surface edge between the page and the window, got \(edges)") + #expect(edges.first?.hasPrefix("MainPaneCardView") == true, "\(what): the edge is the main pane card's, got \(edges)") + #expect((rig.root.contentHost.layer?.cornerRadius ?? 0) > 0, "\(what): the card is rounded") + } - private static func page(_ html: String) throws -> URL { - let directory = FileManager.default.temporaryDirectory.appending(path: "one-edge-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let file = directory.appending(path: "index.html") - try Data(html.utf8).write(to: file) - return file - } + private static func page(_ html: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appending(path: "one-edge-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appending(path: "index.html") + try Data(html.utf8).write(to: file) + return file + } - @Test func theNewTabPageHasOneSurfaceEdge() throws { - let file = try Self.page("") - defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } - let model = AgentPaneModel(host: NoHost(), newTab: AgentPaneNewTab(kind: .agent)) - let newTab = try #require(AgentPaneView(model: model, source: .bundled(file))) - let rig = Rig(content: newTab) - defer { rig.tearDown() } - try expectOneEdge(rig, content: newTab, "New Tab") - } + @Test func theNewTabPageHasOneSurfaceEdge() throws { + let file = try Self.page("") + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + let model = AgentPaneModel(host: NoHost(), newTab: AgentPaneNewTab(kind: .agent)) + let newTab = try #require(AgentPaneView(model: model, source: .bundled(file))) + let rig = Rig(content: newTab) + defer { rig.tearDown() } + try expectOneEdge(rig, content: newTab, "New Tab") + } - /// A first-party page pane (History, Settings, App Store, Activity) is a `PageWebView`. - @Test func aPagePaneHasOneSurfaceEdge() throws { - let file = try Self.page("") - defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } - let descriptor = PageDescriptor(id: "test.one-edge", resource: "one-edge", namespaces: []) - let page = try #require(PageWebView(descriptor: descriptor, root: file.deletingLastPathComponent(), routes: [])) - let rig = Rig(content: page) - defer { rig.tearDown() } - try expectOneEdge(rig, content: page, "page pane") - } + /// A first-party page pane (History, Settings, App Store, Activity) is a `PageWebView`. + @Test func aPagePaneHasOneSurfaceEdge() throws { + let file = try Self.page("") + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + let descriptor = PageDescriptor(id: "test.one-edge", resource: "one-edge", namespaces: []) + let page = try #require(PageWebView(descriptor: descriptor, root: file.deletingLastPathComponent(), routes: [])) + let rig = Rig(content: page) + defer { rig.tearDown() } + try expectOneEdge(rig, content: page, "page pane") + } + + /// A webview pane's edges are any other pane's: a plain view (a terminal's place) gets the same one. + @Test func aWebviewPaneHasTheEdgesOfAnyOtherPane() throws { + let file = try Self.page("") + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + let newTab = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost(), newTab: AgentPaneNewTab(kind: .agent)), + source: .bundled(file))) + let web = Rig(content: newTab) + defer { web.tearDown() } + let plain = NSView() + let other = Rig(content: plain) + defer { other.tearDown() } + #expect(web.edges(of: newTab) == other.edges(of: plain)) + } - /// A webview pane's edges are any other pane's: a plain view (a terminal's place) gets the same one. - @Test func aWebviewPaneHasTheEdgesOfAnyOtherPane() throws { - let file = try Self.page("") - defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } - let newTab = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost(), newTab: AgentPaneNewTab(kind: .agent)), - source: .bundled(file))) - let web = Rig(content: newTab) - defer { web.tearDown() } - let plain = NSView() - let other = Rig(content: plain) - defer { other.tearDown() } - #expect(web.edges(of: newTab) == other.edges(of: plain)) + /// With the Debug Settings flush opt-in (gutter 0), where the main pane meets the window (top, + /// trailing, bottom, and the leading edge with the sidebar hidden) exactly one rounded outline + /// shows, the window's own, over wallpaper art and on an opaque theme, with the sidebar on + /// either side or hidden. + @Test(arguments: [BackdropArt?.some(.goyaSaturn), nil]) + func whereThePaneMeetsTheWindowOneOutlineShows(art: BackdropArt?) throws { + try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") + try MainPaneGutterSuites.withGutter(0) { + for (side, hidden) in [(SidebarSide.left, false), (.right, false), (.left, true)] { + let rig = Rig(content: NSView(), art: art, sidebarSide: side, sidebarHidden: hidden) + defer { rig.tearDown() } + if art != nil { try #require(rig.root.backdrop.art != nil, "the window shows wallpaper art") } + let what = "\(art.map { "art \($0.rawValue)" } ?? "opaque"), sidebar \(hidden ? "hidden" : side.rawValue)" + for edge in rig.windowEdges { + let outlines = rig.outlines(at: edge) + #expect(outlines.count == 1, "\(what), window edge \(edge.rawValue): one outline, got \(outlines)") + } + } + } + } } } From 4020e9d28dd7a325c4ecf116d7e248a067a69c9d Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 19:26:57 -0700 Subject: [PATCH 122/183] cmux-next: a cmux-render fence runs inline as a render card; render cards open in the side panel (#20525) * test(cmux-next): a cmux-render fence draws as an inline render card Red: bun test test/render-fence.test.tsx (2 fail: no card for a closed cmux-render fence; the card has no Open to the side panel). Co-Authored-By: Claude Opus 5.5 * feat(cmux-next): a cmux-render fence runs inline as a render card; render cards open in the side panel Any harness can show a live page in the transcript without a render tool: a closed ```cmux-render fence draws as the render card a render call gets, in the same sandboxed render frame, named by the page's , waiting for Run. A fence still arriving stays code; the dev server, with no render frame, shows the source. The page is reply text, so it persists with the chat. A render card's Open hands its page to the side panel (preview, Code, Save). Green: bun test test/render-fence.test.tsx test/artifact-panel.test.tsx (10 pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-next): a cmux-render fence that never closed stays code Red: bun test test/render-fence.test.tsx (1 fail: a reply cut with its fence open, and a fence in a streaming list item, draw as cards). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-next): only a closed cmux-render fence draws as a card The parser marks a fence with no closing line open, and only a closed cmux-render fence becomes a render card. A fence in a list item while the reply streams, or one a cancelled reply left open, stays code, so Run never starts a partial page. Green: bun test test/render-fence.test.tsx test/artifact-panel.test.tsx (11 pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-next): a rewritten cmux-render page is the panel's next version Red: bun test test/render-fence.test.tsx (1 fail: the panel finds no versions of a cmux-render page). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cmux-next): a cmux-render page is an HTML document, so its rewrites are versions A cmux-render fence is an HTML document to the side panel: a reply that rewrites a page under the same <title> is its next version, and the panel opened from a render card steps between them. Green: bun test test/render-fence.test.tsx test/artifact-panel.test.tsx (12 pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(next): PR autofix at ab5da44a708f - vp fmt webviews/src/pages/home-channels/AttachmentViews.tsx - vp fmt webviews/src/pages/home-channels/Composer.tsx - vp fmt webviews/src/pages/home-channels/HomeChannelsPage.tsx - vp fmt webviews/src/pages/home-channels/attachmentService.ts - vp fmt webviews/src/pages/home-channels/icons.tsx Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38579610384 * chore(next): PR autofix at 233382b07719 - vp fmt webviews/src/pages/home-channels/cloudHome.ts Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38649245714 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: cmux-autofix[bot] <cmux-autofix[bot]@users.noreply.github.com> --- .../acpmux/conversation/Markdown.tsx | 22 ++- .../acpmux/conversation/RenderCard.tsx | 19 ++- .../acpmux/conversation/artifactPreview.ts | 2 + .../acpmux/conversation/renderCall.ts | 12 ++ webviews/test/render-fence.test.tsx | 130 ++++++++++++++++++ 5 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 webviews/test/render-fence.test.tsx diff --git a/webviews/src/agent-session/acpmux/conversation/Markdown.tsx b/webviews/src/agent-session/acpmux/conversation/Markdown.tsx index 1b972564fb0e..aceb806d8336 100644 --- a/webviews/src/agent-session/acpmux/conversation/Markdown.tsx +++ b/webviews/src/agent-session/acpmux/conversation/Markdown.tsx @@ -2,13 +2,16 @@ // A small GFM-subset Markdown renderer for assistant messages. It produces the same // DOM shape for every transcript: headings, paragraphs (single newlines are line // breaks), nested ordered/bullet/task lists, blockquotes, rules, aligned tables, fenced -// code blocks rendered by @pierre/diffs (see CodeBlock.tsx), vega-lite charts (DiagramBlock.tsx), and `$…$`, `$$…$$`, `\(…\)` +// code blocks rendered by @pierre/diffs (see CodeBlock.tsx), vega-lite charts (DiagramBlock.tsx), live +// ```cmux-render pages (RenderCard.tsx), and `$…$`, `$$…$$`, `\(…\)` // and `\[…\]` math typeset by KaTeX (see Math.tsx). import { Fragment, memo, useId, useMemo, useRef, type ReactNode } from "react"; import { useT } from "../i18n"; import { safeHref } from "../model"; import { CodeBlock } from "./CodeBlock"; import { DiagramBlock, isChart } from "./DiagramBlock"; +import { canRender, RenderCard } from "./RenderCard"; +import { renderFence } from "./renderCall"; import { isRemoteMedia, RemoteMedia } from "../chips/ReplyMedia"; import { CodeHandoff, PlainCode } from "./StreamingCode"; import type { Reveal } from "./RevealedMarkdown"; @@ -33,7 +36,8 @@ export type MdBlock = | { type: "blockquote"; children: MdBlock[] } | { type: "list"; ordered: boolean; start: number; items: MdListItem[] } | { type: "table"; align: Align[]; header: string[]; rows: string[][] } - | { type: "code"; lang: string; code: string } + /** `open`: the fence has no closing line yet (a streaming or cut reply). */ + | { type: "code"; lang: string; code: string; open?: true } | { type: "math"; tex: string } /** `[^id]: text`, drawn in the notes under the reply (`Markdown`), not where it is written. */ | { type: "footnote"; id: string; text: string }; @@ -68,8 +72,14 @@ function parseLines(lines: string[], depth = 0): MdBlock[] { const body: string[] = []; i++; while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) body.push(lines[i++]); + const open = i >= lines.length; i++; - out.push({ type: "code", lang: fence[1] || "text", code: body.join("\n") }); + out.push({ + type: "code", + lang: fence[1] || "text", + code: body.join("\n"), + ...(open ? { open: true as const } : {}), + }); continue; } const display = displayMath(lines, i); @@ -638,12 +648,16 @@ function Block({ </div> ); } - case "code": + case "code": { if (code === "open") return <PlainCode code={block.code} lang={block.lang} open />; if (code === "plain") return <PlainCode code={block.code} lang={block.lang} />; if (isChart(block.lang, block.code)) return <DiagramBlock lang={block.lang} code={block.code} />; + // A page in a closed ```cmux-render fence runs in the render frame; the dev server has none. + const render = canRender() && !block.open ? renderFence(block.lang, block.code) : undefined; + if (render) return <RenderCard call={render} />; if (code === "handoff") return <CodeHandoff code={block.code} lang={block.lang} />; return <CodeBlock code={block.code} lang={block.lang} />; + } case "math": return <MathDisplay tex={block.tex} />; case "footnote": { diff --git a/webviews/src/agent-session/acpmux/conversation/RenderCard.tsx b/webviews/src/agent-session/acpmux/conversation/RenderCard.tsx index 88ff637d1fad..6efb30cf263c 100644 --- a/webviews/src/agent-session/acpmux/conversation/RenderCard.tsx +++ b/webviews/src/agent-session/acpmux/conversation/RenderCard.tsx @@ -4,9 +4,12 @@ // policy allows no connection. The frame asks for the HTML once it loads and reports its height; // the card grows to it, up to a cap a reader can lift. Nothing runs until the reader presses Run: // the frame shares the pane's web process, so a busy loop in agent HTML would freeze the pane. The -// pane on a dev server has no render frame, so there the call stays a plain tool row. -import { useEffect, useRef, useState } from "react"; +// pane on a dev server has no render frame, so there the call stays a plain tool row. Open hands the +// page to the side panel beside the chat (ArtifactPanel.tsx), with its source a tab away. +import { useContext, useEffect, useRef, useState } from "react"; import { useT } from "../i18n"; +import { ArtifactContext } from "./artifactPreview"; +import { Expand } from "./icons"; import { RENDER_FRAME_MIN_HEIGHT, type RenderCall } from "./renderCall"; export const RENDER_FRAME_URL = "cmux-agent://render/frame"; @@ -67,6 +70,7 @@ export function useRenderFrame(html: string) { export function RenderCard({ call }: { call: RenderCall }) { const t = useT(); + const open = useContext(ArtifactContext); const { frame, height } = useRenderFrame(call.html); const [expanded, setExpanded] = useState(false); const [running, setRunning] = useState(() => ranRenders.has(call.html)); @@ -97,6 +101,17 @@ export function RenderCard({ call }: { call: RenderCall }) { {expanded ? t("render.collapse") : t("render.expand")} </button> )} + {open && ( + <button + type="button" + className="cv-iconbtn cv-iconbtn--compact" + aria-label={t("artifact.open")} + title={t("artifact.open")} + onClick={() => open({ kind: "html", lang: "html", code: call.html })} + > + <Expand /> + </button> + )} </div> {running && ( <iframe diff --git a/webviews/src/agent-session/acpmux/conversation/artifactPreview.ts b/webviews/src/agent-session/acpmux/conversation/artifactPreview.ts index 531fe49c66f3..54cf685c5436 100644 --- a/webviews/src/agent-session/acpmux/conversation/artifactPreview.ts +++ b/webviews/src/agent-session/acpmux/conversation/artifactPreview.ts @@ -9,6 +9,8 @@ const KINDS: Record<string, ArtifactKind> = { html: "html", htm: "html", xhtml: "html", + // A page the reply runs inline (renderCall.ts); its rewrites are versions of one document. + "cmux-render": "html", svg: "svg", markdown: "markdown", md: "markdown", diff --git a/webviews/src/agent-session/acpmux/conversation/renderCall.ts b/webviews/src/agent-session/acpmux/conversation/renderCall.ts index 698b752b08a2..941ca9ff1701 100644 --- a/webviews/src/agent-session/acpmux/conversation/renderCall.ts +++ b/webviews/src/agent-session/acpmux/conversation/renderCall.ts @@ -1,7 +1,9 @@ // Tool calls that hand the transcript HTML to show live: `cmux mcp`'s `render` and T3 Code's // `html_render`, under whatever prefix the harness gives an MCP tool (`mcp__cmux__render`, // `cmux.render`, `cmux/render`). An ended turn draws each as a render card (RenderCard.tsx). +// A harness with no render tool writes the page in a ```cmux-render fence instead (renderFence). import type { AcpmuxActivity } from "../model"; +import { documentName } from "./artifactPreview"; type Tool = NonNullable<AcpmuxActivity["tool"]>; @@ -39,3 +41,13 @@ function parseInput(summary: string | undefined): Record<string, unknown> | unde return undefined; } } + +/// The fence language whose closed block draws as a render card in the reply (Markdown.tsx). +export const RENDER_FENCE = "cmux-render"; + +/// The page a reply's fence renders, or nil for every other fence. Its heading is the page's <title>. +export function renderFence(lang: string, code: string): RenderCall | undefined { + if (lang.toLowerCase() !== RENDER_FENCE || !code.trim()) return undefined; + const title = documentName({ kind: "html", lang: "html", code }); + return title ? { html: code, title } : { html: code }; +} diff --git a/webviews/test/render-fence.test.tsx b/webviews/test/render-fence.test.tsx new file mode 100644 index 000000000000..81898b6905ef --- /dev/null +++ b/webviews/test/render-fence.test.tsx @@ -0,0 +1,130 @@ +// Inline renders in a reply: an agent with no render tool writes its page in a ```cmux-render fence, +// and the closed fence draws as a render card in the transcript, the same card a render tool call +// gets (RenderCard.tsx). A fence still arriving stays code, and a page with no render frame (the dev +// server, a gallery) shows the source. The card's Open hands the page to the side panel. +import { afterAll, expect, test } from "bun:test"; +import { JSDOM, VirtualConsole } from "jsdom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +const dom = new JSDOM("<!doctype html><div id=root></div>", { + url: "cmux-agent://pane/index.html", + pretendToBeVisual: true, + virtualConsole: new VirtualConsole(), +}); +const scope = globalThis as Record<string, unknown>; +const replacements: Record<string, unknown> = { + window: dom.window, + document: dom.window.document, + location: dom.window.location, + navigator: dom.window.navigator, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + Node: dom.window.Node, + customElements: dom.window.customElements, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + IntersectionObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, +}; +// The code card's highlighter (@pierre/diffs) defines its elements, which read DOM constructors, on import. +for (const key of Object.getOwnPropertyNames(dom.window)) + if (/^(HTML|SVG|CSS|Shadow|Document|Mutation|KeyboardEvent)/.test(key) && !(key in scope)) + replacements[key] = (dom.window as unknown as Record<string, unknown>)[key]; +const saved = Object.keys(replacements).map((key) => [key, key in scope, scope[key]] as const); +Object.assign(scope, replacements); +const host = dom.window.document.getElementById("root")!; +let root: Root | undefined; + +afterAll(() => { + act(() => root?.unmount()); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +const PAGE = "<title>Desk clamp"; +const REPLY = `Here is the clamp:\n\n\`\`\`cmux-render\n${PAGE}\n\`\`\`\n\nRun it to turn the model.`; + +const button = (label: string) => + [...host.querySelectorAll("button")].find( + (element) => element.getAttribute("aria-label") === label || element.textContent === label, + ); + +async function draw(markdown: string, streaming = false, open?: (artifact: unknown) => void) { + const { createRoot } = await import("react-dom/client"); + const { Markdown } = await import("../src/agent-session/acpmux/conversation/Markdown"); + const { ArtifactContext } = await import("../src/agent-session/acpmux/conversation/artifactPreview"); + act(() => root?.unmount()); + root = createRoot(host); + act(() => + root!.render( + + {markdown} + , + ), + ); +} + +test("a closed cmux-render fence draws as a render card named by its page's title", async () => { + await draw(REPLY); + const card = host.querySelector(".acpmux-render-card"); + expect(card).not.toBeNull(); + expect(card!.querySelector(".acpmux-render-card-title")?.textContent).toBe("Desk clamp"); + // Nothing runs until the reader presses Run, as for a render tool call. + expect(host.querySelector("iframe")).toBeNull(); + act(() => button("Run")!.click()); + const frame = host.querySelector("iframe")!; + expect(frame.getAttribute("src")).toBe("cmux-agent://render/frame"); + expect(frame.getAttribute("sandbox")).toBe("allow-scripts"); + // The prose around the fence still draws. + expect(host.textContent).toContain("Run it to turn the model."); +}); + +test("a cmux-render fence still arriving stays code", async () => { + await draw(`Here is the clamp:\n\n\`\`\`cmux-render\nDesk clamp`, true); + expect(host.querySelector(".acpmux-render-card")).toBeNull(); + expect(host.textContent).toContain(""); +}); + +test("the render card's Open hands its page to the side panel", async () => { + const opened: unknown[] = []; + await draw(REPLY, false, (artifact) => opened.push(artifact)); + act(() => button("Open preview")!.click()); + expect(opened).toEqual([{ kind: "html", lang: "html", code: PAGE }]); +}); + +test("only a fence that closed draws as a card: not one in a streaming list, nor one a cut reply left open", async () => { + // A reply that ended (cancelled or cut) with its fence still open. + await draw(`Here is the clamp:\n\n\`\`\`cmux-render\nDesk clamp`); + expect(host.querySelector(".acpmux-render-card")).toBeNull(); + expect(host.textContent).toContain(""); + // A fence inside a list item while the reply streams: only the last top-level block knows it is open. + await draw(`- the clamp:\n\n \`\`\`cmux-render\n Desk clamp`, true); + expect(host.querySelector(".acpmux-render-card")).toBeNull(); +}); + +test("a page a reply rewrites under the same title is its next version in the side panel", async () => { + const { artifactVersions } = await import("../src/agent-session/acpmux/conversation/artifactVersions"); + const page = (body: string) => `Desk clamp${body}`; + const fence = (body: string) => `\`\`\`cmux-render\n${page(body)}\n\`\`\``; + const replies = [`First try:\n\n${fence("

v1

")}`, `Thicker jaw:\n\n${fence("

v2

")}`]; + // What a render card's Open hands the panel. + const opened = { kind: "html" as const, lang: "html", code: page("

v2

") }; + expect(artifactVersions(replies, opened).map((version) => version.code)).toEqual([ + page("

v1

"), + page("

v2

"), + ]); +}); From 222fb8400dc7a275bfc737eec68b16c38df16179 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:39:32 -0700 Subject: [PATCH 123/183] acpmux: a job object per agent on Windows (cx-ncc.54) What the agent's own process group is on Unix (process_group(0), killpg) is a job object on Windows (src/job.rs). A direct agent starts suspended (CREATE_SUSPENDED), joins a new kill-on-close job, then resumes (NtResumeProcess: std and tokio give the process handle, not the main thread's), so nothing it starts can run outside the job. Where Unix sends SIGTERM then SIGKILL to the group (kill, terminate, the leader-gone cleanup that frees the output pipe), Windows ends the job (no polite signal exists for a job). The daemon's handle closing (its exit or a crash) ends every agent and everything they started. Agent hosts are not ported to Windows yet, so the daemon keeps direct children there (enable_agent_hosts is a no-op on Windows): without this every session of a daemon with a disk store failed on Windows (the host spawn was a stub). Sessions do not outlive the daemon on Windows until the agent host port. Behavior tests (tests/windows_daemon.rs, harness cmd.exe starting a background ping with a unique target; green on the Windows Server 2022 VM and red on the parent commit without job objects): an agent that starts a background process and exits leaves no process behind; a running agent and its background process end when the daemon is killed outright. --- cmux-tui/crates/acpmux/src/agent.rs | 49 +++++++- cmux-tui/crates/acpmux/src/hub/hosts.rs | 4 +- cmux-tui/crates/acpmux/src/job.rs | 84 ++++++++++++++ cmux-tui/crates/acpmux/src/lib.rs | 2 + .../crates/acpmux/tests/windows_daemon.rs | 106 ++++++++++++++++++ 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/job.rs diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index ea7effe5a912..735a556e356d 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -147,6 +147,9 @@ pub struct ChildAgent { /// (`codex_app_server.rs`) and run directly under acpmux. codex: Option, hosted: Option, + /// Windows: the agent's job (its process group on Unix). + #[cfg(windows)] + job: Option, } /// The in-process Codex bridge of a direct child: its translator, and the @@ -263,8 +266,8 @@ pub(crate) fn harness_command( .stderr(Stdio::piped()) .kill_on_drop(true); // Own process group, so stopping the session stops everything the - // agent started underneath it (background shells included). Windows - // port: a job object there (a later landing). + // agent started underneath it (background shells included). Windows: a + // job object (`job.rs`), made where the agent is spawned. #[cfg(unix)] cmd.process_group(0); crate::harness_limits::lift_claude(&mut cmd, profile, session.is_some()); @@ -324,6 +327,14 @@ pub(crate) fn command_env(cmd: &Command) -> Vec<(String, String)> { } impl ChildAgent { + /// Ends every process in the agent's job (Windows; killpg on Unix). + #[cfg(windows)] + fn kill_job(&self) { + if let Some(job) = &self.job { + job.kill(); + } + } + /// Spawn the agent and start its reader loop. Inbound requests and /// notifications are delivered on `inbound`. pub async fn spawn( @@ -361,9 +372,25 @@ impl ChildAgent { order: Mutex::new(()), }); let mut cmd = harness_command(name, profile, cwd, command_line, session)?; + #[cfg(windows)] + cmd.creation_flags(crate::job::START_SUSPENDED); let mut child = cmd .spawn() .with_context(|| format!("spawn agent {name}: {}", profile.argv.join(" ")))?; + #[cfg(windows)] + let job = { + let contained = child + .raw_handle() + .ok_or_else(|| std::io::Error::other("the agent has no process handle")) + .and_then(crate::job::Job::contain); + match contained { + Ok(job) => Some(job), + Err(e) => { + let _ = child.start_kill(); + anyhow::bail!("spawn agent {name}: its job object: {e}"); + } + } + }; let pid = child.id(); let stdin = child.stdin.take().context("agent stdin")?; let stdout = child.stdout.take().context("agent stdout")?; @@ -383,6 +410,8 @@ impl ChildAgent { translator: translator.clone(), codex, hosted: None, + #[cfg(windows)] + job, }); // Writer task. @@ -445,6 +474,8 @@ impl ChildAgent { libc::killpg(pg as i32, libc::SIGKILL); } } + #[cfg(windows)] + agent_for_exit.kill_job(); } continue; } @@ -629,6 +660,12 @@ impl ChildAgent { unsafe { libc::killpg(pid as i32, libc::SIGTERM); } + // Windows has no polite signal for a job: it ends at once. + #[cfg(windows)] + { + let _ = pid; + self.kill_job(); + } if let Some(child) = guard.as_mut() { let _ = tokio::time::timeout(KILL_GRACE, child.wait()).await; } @@ -681,6 +718,12 @@ impl ChildAgent { libc::killpg(pg, libc::SIGTERM); } } + // Windows has no polite signal for a job: it ends at once. + #[cfg(windows)] + { + let _ = pgid; + self.kill_job(); + } let _ = tokio::time::timeout(grace, async { // The exit watcher may hold this lock while it reaps, and frees // it once the child is gone; otherwise wait on the exit here. @@ -1023,6 +1066,8 @@ impl ChildAgent { exit_seen: std::sync::atomic::AtomicBool::new(false), exit: tokio::sync::Notify::new(), }), + #[cfg(windows)] + job: None, }); agent.start_reader(link); (agent, responses) diff --git a/cmux-tui/crates/acpmux/src/hub/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index 9e052eb81d61..f2c5d37e274f 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -713,8 +713,10 @@ impl Hub { } /// Run new agents under agent hosts (see `agent_hosts_enabled`). + /// Windows: agent hosts are not ported yet, so agents stay direct + /// children in job objects (sessions do not outlive the daemon there). pub fn enable_agent_hosts(&self) { - self.agent_hosts.store(true, Ordering::SeqCst); + self.agent_hosts.store(cfg!(unix), Ordering::SeqCst); } pub(super) async fn spawn_hosted_child( diff --git a/cmux-tui/crates/acpmux/src/job.rs b/cmux-tui/crates/acpmux/src/job.rs new file mode 100644 index 000000000000..be6a72664086 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/job.rs @@ -0,0 +1,84 @@ +//! A job object per agent on Windows: what the agent's own process group is +//! on Unix. Everything the agent starts (shells, tools, servers) is in its +//! job, so stopping the session stops all of it, and the job is closed with +//! kill-on-close, so nothing outlives the daemon's handle to it. +//! +//! The agent starts suspended (CREATE_SUSPENDED), joins its job, then +//! resumes: no process it starts can begin before it is in the job. + +use std::io; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; +use std::ptr::null; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +use windows_sys::Win32::System::Threading::CREATE_SUSPENDED; + +/// The creation flag `contain` expects the agent to have started with. +pub(crate) const START_SUSPENDED: u32 = CREATE_SUSPENDED; + +#[link(name = "ntdll")] +unsafe extern "system" { + /// Resumes every thread of a process (ntdll; stable since Windows XP). + /// std and tokio give the process handle only, not the main thread's. + fn NtResumeProcess(process: HANDLE) -> i32; +} + +/// One agent's job. +pub(crate) struct Job(OwnedHandle); + +impl Job { + /// Puts the suspended `process` in a new kill-on-close job, then + /// resumes it. On an error the caller kills the process. + pub(crate) fn contain(process: RawHandle) -> io::Result { + // SAFETY: no name, default security. + let handle = unsafe { CreateJobObjectW(null(), null()) }; + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + // SAFETY: a handle just created and owned here. + let job = Job(unsafe { OwnedHandle::from_raw_handle(handle) }); + // SAFETY: plain data. + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: a job handle and a structure of the size passed. + if unsafe { + SetInformationJobObject( + job.raw(), + JobObjectExtendedLimitInformation, + (&raw const limits).cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: valid job and process handles. + if unsafe { AssignProcessToJobObject(job.raw(), process) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: a process handle with resume rights (the creator's). + let status = unsafe { NtResumeProcess(process) }; + if status < 0 { + return Err(io::Error::other(format!("NtResumeProcess: NTSTATUS {status:#x}"))); + } + Ok(job) + } + + /// Ends every process in the job (SIGKILL to the group on Unix). + pub(crate) fn kill(&self) { + // SAFETY: a job handle owned here. + unsafe { TerminateJobObject(self.raw(), 1) }; + } + + fn raw(&self) -> HANDLE { + self.0.as_raw_handle() + } +} + +// The job handle is used only through its own calls. +unsafe impl Send for Job {} +unsafe impl Sync for Job {} diff --git a/cmux-tui/crates/acpmux/src/lib.rs b/cmux-tui/crates/acpmux/src/lib.rs index ebcd82557d23..ddc999b1dc1f 100644 --- a/cmux-tui/crates/acpmux/src/lib.rs +++ b/cmux-tui/crates/acpmux/src/lib.rs @@ -36,6 +36,8 @@ pub mod harness_config; pub mod harness_health; pub mod harness_limits; pub mod hub; +#[cfg(windows)] +mod job; pub mod launch_policy; pub mod live_models; pub mod local_stream; diff --git a/cmux-tui/crates/acpmux/tests/windows_daemon.rs b/cmux-tui/crates/acpmux/tests/windows_daemon.rs index 9225b85a4324..2b6e45d83f44 100644 --- a/cmux-tui/crates/acpmux/tests/windows_daemon.rs +++ b/cmux-tui/crates/acpmux/tests/windows_daemon.rs @@ -550,3 +550,109 @@ fn two_starts_at_once_share_one_daemon() { assert!(!pids[0].is_empty() && pids[0] == pids[1], "two daemons answered: {pids:?}"); let _ = std::fs::remove_dir_all(&home); } + +/// How many ping.exe processes have `marker` in their command line. +fn pings_with(marker: &str) -> usize { + let script = format!( + "@(Get-CimInstance Win32_Process -Filter \"Name='PING.EXE'\" | Where-Object {{ $_.CommandLine -like '*{marker}*' }}).Count" + ); + let out = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .output() + .expect("run powershell"); + String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .unwrap_or_else(|_| panic!("count: {}", text(&out))) +} + +/// Until `pings_with(marker)` is `want` (at least `want` with `at_least`), +/// or `limit`. +fn wait_pings(marker: &str, want: usize, limit: Duration) -> usize { + wait_pings_by(marker, |n| n == want, limit) +} + +fn wait_pings_by(marker: &str, done: impl Fn(usize) -> bool, limit: Duration) -> usize { + let deadline = Instant::now() + limit; + loop { + let n = pings_with(marker); + if done(n) || Instant::now() >= deadline { + return n; + } + std::thread::sleep(Duration::from_millis(300)); + } +} + +/// A loopback address only this test pings, so its processes are found by +/// command line. +fn ping_marker(salt: u32) -> String { + let n = std::process::id().wrapping_add(salt); + format!("127.{}.{}.{}", 1 + n % 250, 1 + (n / 250) % 250, 1 + salt % 250) +} + +/// A config with one harness `grand`: cmd.exe running `script`. +fn grand_config(home: &Path, script: &str) { + std::fs::create_dir_all(home).unwrap(); + let config = serde_json::json!({ + "harnesses": {"grand": {"argv": ["cmd.exe", "/d", "/c", script]}}, + "defaultHarness": "grand", + "permissionPolicy": "approve-all", + }); + std::fs::write(home.join("config.json"), config.to_string()).unwrap(); +} + +/// An agent that starts a background process and exits at once: the +/// process it left (in the agent's job) is ended, as killpg ends the +/// agent's group on Unix. +#[test] +fn an_agent_leaves_no_process_behind_when_it_exits() { + let _spawns = spawns(); + let exe = exe(); + let home = scratch("jobexit"); + let marker = ping_marker(1); + grand_config(&home, &format!("start /b ping -n 300 {marker}")); + let out = acpmux(&exe, &home).args(["daemon", "start"]).output().expect("daemon start"); + assert!(out.status.success(), "acpmux daemon start failed: {}", text(&out)); + let new = acpmux(&exe, &home) + .args(["new", "--detach", "--cwd"]) + .arg(&home) + .output() + .expect("run acpmux new"); + let left = wait_pings(&marker, 0, Duration::from_secs(20)); + let out = acpmux(&exe, &home).arg("shutdown").output().expect("run acpmux shutdown"); + assert!(out.status.success(), "acpmux shutdown failed: {}", text(&out)); + assert_eq!(left, 0, "the agent's background ping outlived it (acpmux new: {})", text(&new)); + let _ = std::fs::remove_dir_all(&home); +} + +/// A running agent and what it started end with the daemon even when the +/// daemon is killed outright: the agent's job is kill-on-close. +#[test] +fn a_killed_daemon_takes_its_agents_with_it() { + let _spawns = spawns(); + let exe = exe(); + let home = scratch("jobkill"); + let marker = ping_marker(2); + grand_config(&home, &format!("start /b ping -n 300 {marker} & ping -n 300 {marker}")); + let log = std::env::temp_dir().join(format!("amxw-jobkill-{}.log", std::process::id())); + let mut daemon = Daemon::start(&exe, &home, log); + wait_ready(&exe, &home, &mut daemon); + // The agent never answers `initialize`: `new` waits; the agent runs. + let mut new = acpmux(&exe, &home) + .args(["new", "--detach", "--cwd"]) + .arg(&home) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn acpmux new"); + // The session's agent and the harness's model probe each run both. + let running = wait_pings_by(&marker, |n| n >= 2, Duration::from_secs(30)); + daemon.child.kill().unwrap(); + let _ = daemon.child.wait(); + let left = wait_pings(&marker, 0, Duration::from_secs(20)); + let _ = new.kill(); + let _ = new.wait(); + assert!(running >= 2, "the agent and its background ping did not start:\n{}", daemon.log()); + assert_eq!(left, 0, "processes of a killed daemon's agent survived:\n{}", daemon.log()); + let _ = std::fs::remove_dir_all(&home); +} From e9bab88e4e81713a941f7121d169a373eba33b1d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:06:21 -0700 Subject: [PATCH 124/183] acpmux: a stopped Windows agent gets Ctrl+Break before its job ends (cx-ncc.54) Coordinator decision: a polite stop first, as SIGTERM to the group on Unix. Each agent now leads its own process group (CREATE_NEW_PROCESS_GROUP with CREATE_SUSPENDED); kill and terminate send CTRL_BREAK_EVENT to that group (the daemon and its agents share one console, hidden for a started daemon), wait the same grace as Unix (KILL_GRACE / the caller's grace), then end the job. The leader-gone cleanup still ends the job at once (the agent has exited). Breakaway from the job stays refused (no JOB_OBJECT_LIMIT_BREAKAWAY_OK): stricter cleanup than setsid on Unix. Behavior test (tests/windows_daemon.rs): the agent is this test binary's ctrl_break_agent, which records a Ctrl+Break in its session folder; after `acpmux shutdown` the record exists. Green on the Windows Server 2022 VM, red on the parent commit. --- cmux-tui/crates/acpmux/src/agent.rs | 22 +++--- cmux-tui/crates/acpmux/src/job.rs | 27 ++++++- .../crates/acpmux/tests/windows_daemon.rs | 79 +++++++++++++++++++ 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index 735a556e356d..f2f3529724e7 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -373,7 +373,7 @@ impl ChildAgent { }); let mut cmd = harness_command(name, profile, cwd, command_line, session)?; #[cfg(windows)] - cmd.creation_flags(crate::job::START_SUSPENDED); + cmd.creation_flags(crate::job::AGENT_FLAGS); let mut child = cmd .spawn() .with_context(|| format!("spawn agent {name}: {}", profile.argv.join(" ")))?; @@ -660,15 +660,15 @@ impl ChildAgent { unsafe { libc::killpg(pid as i32, libc::SIGTERM); } - // Windows has no polite signal for a job: it ends at once. + // Windows: Ctrl+Break to the agent's process group first. #[cfg(windows)] - { - let _ = pid; - self.kill_job(); - } + crate::job::ctrl_break(pid); if let Some(child) = guard.as_mut() { let _ = tokio::time::timeout(KILL_GRACE, child.wait()).await; } + // Then the job ends, stragglers included (SIGKILL on Unix). + #[cfg(windows)] + self.kill_job(); #[cfg(unix)] unsafe { libc::killpg(pid as i32, libc::SIGKILL); @@ -718,11 +718,10 @@ impl ChildAgent { libc::killpg(pg, libc::SIGTERM); } } - // Windows has no polite signal for a job: it ends at once. + // Windows: Ctrl+Break to the agent's process group first. #[cfg(windows)] - { - let _ = pgid; - self.kill_job(); + if let Some(pg) = pgid { + crate::job::ctrl_break(pg as u32); } let _ = tokio::time::timeout(grace, async { // The exit watcher may hold this lock while it reaps, and frees @@ -732,6 +731,9 @@ impl ChildAgent { } }) .await; + // Then the job ends, stragglers included (SIGKILL on Unix). + #[cfg(windows)] + self.kill_job(); #[cfg(unix)] if let Some(pg) = pgid { unsafe { diff --git a/cmux-tui/crates/acpmux/src/job.rs b/cmux-tui/crates/acpmux/src/job.rs index be6a72664086..1cc918039076 100644 --- a/cmux-tui/crates/acpmux/src/job.rs +++ b/cmux-tui/crates/acpmux/src/job.rs @@ -15,10 +15,15 @@ use windows_sys::Win32::System::JobObjects::{ JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject, TerminateJobObject, }; -use windows_sys::Win32::System::Threading::CREATE_SUSPENDED; +use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED}; -/// The creation flag `contain` expects the agent to have started with. -pub(crate) const START_SUSPENDED: u32 = CREATE_SUSPENDED; +/// The creation flags of an agent: suspended (`contain` resumes it in its +/// job) and the leader of its own process group, so Ctrl+Break reaches it +/// and what it starts, and nothing else. +pub(crate) const AGENT_FLAGS: u32 = CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP; + +/// `CTRL_BREAK_EVENT`. +const CTRL_BREAK_EVENT: u32 = 1; #[link(name = "ntdll")] unsafe extern "system" { @@ -27,6 +32,22 @@ unsafe extern "system" { fn NtResumeProcess(process: HANDLE) -> i32; } +#[link(name = "kernel32")] +unsafe extern "system" { + /// Sends a console control event to a process group sharing our console. + fn GenerateConsoleCtrlEvent(event: u32, group: u32) -> i32; +} + +/// The polite stop (SIGTERM to the group on Unix): Ctrl+Break to the +/// agent's process group (`group` is its leader's pid). The daemon and its +/// agents share one console (hidden for a started daemon). False when no +/// event could be sent (no console); the caller ends the job after its +/// grace either way. +pub(crate) fn ctrl_break(group: u32) -> bool { + // SAFETY: no pointers; an unknown group only fails. + unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, group) != 0 } +} + /// One agent's job. pub(crate) struct Job(OwnedHandle); diff --git a/cmux-tui/crates/acpmux/tests/windows_daemon.rs b/cmux-tui/crates/acpmux/tests/windows_daemon.rs index 2b6e45d83f44..42c4ff209729 100644 --- a/cmux-tui/crates/acpmux/tests/windows_daemon.rs +++ b/cmux-tui/crates/acpmux/tests/windows_daemon.rs @@ -656,3 +656,82 @@ fn a_killed_daemon_takes_its_agents_with_it() { assert_eq!(left, 0, "processes of a killed daemon's agent survived:\n{}", daemon.log()); let _ = std::fs::remove_dir_all(&home); } + +/// Stopping a running agent asks first: its process group gets Ctrl+Break +/// (SIGTERM on Unix), and only after the grace does the job end it. The +/// agent is this test binary's `ctrl_break_agent`, which records the event. +#[test] +fn a_stopped_agent_gets_ctrl_break_before_its_job_ends() { + let _spawns = spawns(); + let exe = exe(); + let home = scratch("jobbreak"); + std::fs::create_dir_all(&home).unwrap(); + // The agent runs a copy: the build folder may be locked by other runs. + let agent = home.join("agent.exe"); + std::fs::copy(std::env::current_exe().unwrap(), &agent).unwrap(); + let config = serde_json::json!({ + "harnesses": {"breaker": {"argv": [agent, "--ignored", "--exact", "ctrl_break_agent", "--test-threads", "1"]}}, + "defaultHarness": "breaker", + "permissionPolicy": "approve-all", + }); + std::fs::write(home.join("config.json"), config.to_string()).unwrap(); + let log = std::env::temp_dir().join(format!("amxw-jobbreak-{}.log", std::process::id())); + let mut daemon = Daemon::start(&exe, &home, log); + wait_ready(&exe, &home, &mut daemon); + let mut new = acpmux(&exe, &home) + .args(["new", "--detach", "--cwd"]) + .arg(&home) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn acpmux new"); + let deadline = Instant::now() + Duration::from_secs(30); + while !home.join("agent-ready").exists() { + assert!(Instant::now() < deadline, "the agent did not start:\n{}", daemon.log()); + std::thread::sleep(Duration::from_millis(100)); + } + let out = acpmux(&exe, &home).arg("shutdown").output().expect("run acpmux shutdown"); + let _ = new.kill(); + let _ = new.wait(); + let _ = daemon.child.wait(); + assert!(out.status.success(), "acpmux shutdown failed: {}", text(&out)); + assert!( + home.join("agent-ctrl-break").exists(), + "the agent got no Ctrl+Break before its job ended:\n{}", + daemon.log() + ); + let _ = std::fs::remove_dir_all(&home); +} + +/// The agent of `a_stopped_agent_gets_ctrl_break_before_its_job_ends`: +/// records a Ctrl+Break in its session folder, handles it, and keeps +/// running until its job ends. +#[test] +#[ignore = "run as an agent by a_stopped_agent_gets_ctrl_break_before_its_job_ends"] +fn ctrl_break_agent() { + #[link(name = "kernel32")] + unsafe extern "system" { + fn SetConsoleCtrlHandler( + handler: Option i32>, + add: i32, + ) -> i32; + } + static HOME: std::sync::OnceLock = std::sync::OnceLock::new(); + unsafe extern "system" fn on_ctrl(event: u32) -> i32 { + // CTRL_BREAK_EVENT + if event == 1 { + if let Some(home) = HOME.get() { + let _ = std::fs::write(home.join("agent-ctrl-break"), b"1"); + } + return 1; + } + 0 + } + // The session folder (`acpmux new --cwd`) is the agent's current folder. + let home = std::env::current_dir().unwrap(); + HOME.set(home.clone()).unwrap(); + // SAFETY: a static handler for this process. + assert_ne!(unsafe { SetConsoleCtrlHandler(Some(on_ctrl), 1) }, 0); + std::fs::write(home.join("agent-ready"), b"1").unwrap(); + std::thread::sleep(Duration::from_secs(300)); +} From 5964ce8d5d8b7539db51a00975b32fe9fb6540fc Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:38:12 -0700 Subject: [PATCH 125/183] acpmux: the harness command builder (harness_command, resolved_profile_env, command_env) moves unchanged to agent/command.rs, so agent.rs stays under the 1000-line god-file limit with the Windows agent job (cx-ncc.54) Base reds: exact base 4020e9d28dd7 (feat-cmux-next, contains acl 06732b6a656a; cmux-tui full run 38671491429). The gate excludes a red job only when that run has the same job red with the same error lines; expected: "valgrind-leak-check (startup)" and "valgrind-leak-check" (red on feat-cmux-next 12b1e29b82d9, run 38357900702: "does not contain exact test config::tests::ghostty_config_files::load_uses_file_ghostty_defaults_without_invoking_external_resolver"; valgrind 0 errors). This landing changes only cmux-tui/crates/acpmux. The Windows start-race fix moved into landing 4. --- cmux-tui/crates/acpmux/src/agent.rs | 169 +------------------ cmux-tui/crates/acpmux/src/agent/command.rs | 170 ++++++++++++++++++++ 2 files changed, 174 insertions(+), 165 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/agent/command.rs diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index f2f3529724e7..82f61e868280 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -8,13 +8,15 @@ use crate::rpc::{Id, Message, RpcError}; use anyhow::{Context, Result, anyhow}; use serde_json::Value; use std::collections::HashMap; -use std::process::Stdio; use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, Command}; +use tokio::process::Child; use tokio::sync::{Mutex, mpsc, oneshot}; +mod command; +pub(crate) use command::{command_env, harness_command}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Direction { /// acpmux -> agent (stdin) @@ -163,169 +165,6 @@ struct CodexLink { order: Mutex<()>, } -/// The harness command line and environment, as acpmux runs it: the login -/// environment, no nested-agent markers, the session's `ACPMUX_*` caller -/// context, and the profile's env. Shared by direct children and hosts. -pub(crate) fn harness_command( - name: &str, - profile: &HarnessProfile, - cwd: &std::path::Path, - command_line: Option<(String, Vec)>, - session: Option<(&str, &str)>, -) -> Result { - // A session's Claude Code (a planned command line) gets cmux's browser - // and computer use tools and skills (agent_tools.rs); a remote chain's - // sandboxed plan carries --strict-mcp-config and gets none. - let claude_session = profile.kind == crate::config::HarnessKind::ClaudeStdio - && command_line.is_some() - && session.is_some(); - let owned: (String, Vec) = match command_line { - Some(c) => c, - None => { - let (program, args) = profile - .argv - .split_first() - .ok_or_else(|| anyhow!("agent {name} has an empty argv"))?; - (program.clone(), args.to_vec()) - } - }; - if profile.kind == crate::config::HarnessKind::Terminal { - return Err(anyhow!( - "{name} is a terminal harness without ACP; open it with `cmux harness run {name}`" - )); - } - let owned = if let (true, Some((session_id, _))) = (claude_session, session) { - let extra = crate::agent_tools::claude_args_for(false, &profile.env, &owned.1, session_id); - (owned.0, owned.1.into_iter().chain(extra).collect()) - } else { - owned - }; - let mut env = resolved_profile_env(profile)?; - // The Codex bridge runs the configured codex binary: `CODEX_PATH` of the - // profile or its family (`defaults.codex.env`), as the ACP adapter does. - let owned = match env.get("CODEX_PATH").filter(|p| !p.is_empty()) { - Some(path) if crate::codex_app_server::is_launch(&owned.0, &owned.1) => { - (path.clone(), owned.1) - } - _ => owned, - }; - // A bound route's provider variables the inherited env must not keep - // (routes.rs): an earlier route's key never reaches this process. - let route_unset = env.remove(crate::routes::UNSET_KEY); - env.remove(crate::routes::PROFILE_ROUTE_KEY); - let (program, args) = (&owned.0, &owned.1); - let mut cmd = Command::new(program); - // Startup files changed since the last read: refresh in the background. - crate::login_env::refresh_if_changed(); - // The login shell's allow-list for this harness's family (login_env.rs). - let family_name = crate::config::derive_family(name, profile); - let family = Some(family_name.as_str()); - crate::login_env::apply_tokio_for(&mut cmd, family, Some(&env)); - crate::config::scrub_nested_claude_env_tokio_for(&mut cmd, family); - // Caller context, herdr-style: the agent knows which session it is. - for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("ACPMUX_") { - cmd.env_remove(&k); - } - } - // Helper tokens reach the cmux-cua MCP server through its own env only. - crate::cua_socket::scrub_agent_env(&mut cmd); - // A nested launch must not be taken for its parent's thread. - cmd.env_remove("CODEX_THREAD_ID").env_remove("OMPCODE"); - // The daemon's own terminal (it may have started in a cmux terminal) is - // not the agent's: a stale id would open the agent's tabs beside it. - for key in crate::agent_tools::INHERITED_CALLER_ENV { - cmd.env_remove(key); - } - for k in route_unset.iter().flat_map(|list| list.split(',')) { - cmd.env_remove(k); - } - if let Some((id, sname)) = session { - cmd.env("ACPMUX_ENV", "1") - .env("ACPMUX_SESSION_ID", id) - .env("ACPMUX_SESSION_NAME", sname) - .env("ACPMUX_SOCKET", crate::config::socket_path()) - // The caller the cmux CLI names to the app: its tabs open in the - // column right of this session's chat (beside_caller). - .env(crate::agent_tools::AGENT_SESSION_ENV, id); - } - cmd.args(crate::harness_limits::launch_args(program, args, session.map(|_| (cwd, &env)))) - .envs(env.iter()) - // A terminal's launch credential names that one terminal; no agent - // acts as the terminal that started acpmux, not even through a preset. - .env_remove(crate::config::LAUNCH_CREDENTIAL_ENV) - // Nor as the Chief whose host started acpmux (config::CHIEF_CREDENTIAL_ENV). - .env_remove(crate::config::CHIEF_CREDENTIAL_ENV[0]) - .env_remove(crate::config::CHIEF_CREDENTIAL_ENV[1]) - // Claude refuses to nest inside another Claude session. - .env_remove("CLAUDECODE") - .env_remove("CLAUDE_CODE_ENTRYPOINT") - .current_dir(cwd) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - // Own process group, so stopping the session stops everything the - // agent started underneath it (background shells included). Windows: a - // job object (`job.rs`), made where the agent is spawned. - #[cfg(unix)] - cmd.process_group(0); - crate::harness_limits::lift_claude(&mut cmd, profile, session.is_some()); - // A Claude agent's sign-in inputs, for a clear message if it has none. - match (family == Some("claude"), session) { - (true, Some((id, _))) => crate::claude_auth::remember(id, &command_env(&cmd)), - (false, Some((id, _))) => crate::claude_auth::forget(id), - _ => {} - } - Ok(cmd) -} - -/// The profile's env with its `${keychain:…}` and `${env:…}` references -/// resolved (`config::profiles`). The secret store is asked only when a -/// value holds a reference; the lookup blocks, so a multi-thread runtime -/// moves it off its worker first. -fn resolved_profile_env( - profile: &HarnessProfile, -) -> Result> { - let mut env = profile.env.clone(); - if !crate::config::profiles::has_env_refs(&env) { - return Ok(env); - } - let resolve = |env: &mut std::collections::BTreeMap| { - crate::config::profiles::resolve_env_refs( - env, - &|var| crate::login_env::allowed_var(var).or_else(|| std::env::var(var).ok()), - &crate::config::profiles::keychain_lookup, - ) - }; - let multi_thread = tokio::runtime::Handle::try_current() - .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread); - let result = if multi_thread { - tokio::task::block_in_place(|| resolve(&mut env)) - } else { - resolve(&mut env) - }; - result.map_err(|e| anyhow!(e))?; - Ok(env) -} - -/// The full environment `cmd` gives its child (inherited, then changed). -pub(crate) fn command_env(cmd: &Command) -> Vec<(String, String)> { - let mut env: std::collections::BTreeMap = std::env::vars().collect(); - for (k, v) in cmd.as_std().get_envs() { - let k = k.to_string_lossy().into_owned(); - match v { - Some(v) => { - env.insert(k, v.to_string_lossy().into_owned()); - } - None => { - env.remove(&k); - } - } - } - env.into_iter().collect() -} - impl ChildAgent { /// Ends every process in the agent's job (Windows; killpg on Unix). #[cfg(windows)] diff --git a/cmux-tui/crates/acpmux/src/agent/command.rs b/cmux-tui/crates/acpmux/src/agent/command.rs new file mode 100644 index 000000000000..eefb27c2d206 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/agent/command.rs @@ -0,0 +1,170 @@ +//! The harness command line and environment, as acpmux runs it, for direct +//! children (`agent.rs`) and agent hosts. + +use crate::config::HarnessProfile; +use anyhow::{Result, anyhow}; +use std::process::Stdio; +use tokio::process::Command; + +/// The harness command line and environment, as acpmux runs it: the login +/// environment, no nested-agent markers, the session's `ACPMUX_*` caller +/// context, and the profile's env. Shared by direct children and hosts. +pub(crate) fn harness_command( + name: &str, + profile: &HarnessProfile, + cwd: &std::path::Path, + command_line: Option<(String, Vec)>, + session: Option<(&str, &str)>, +) -> Result { + // A session's Claude Code (a planned command line) gets cmux's browser + // and computer use tools and skills (agent_tools.rs); a remote chain's + // sandboxed plan carries --strict-mcp-config and gets none. + let claude_session = profile.kind == crate::config::HarnessKind::ClaudeStdio + && command_line.is_some() + && session.is_some(); + let owned: (String, Vec) = match command_line { + Some(c) => c, + None => { + let (program, args) = profile + .argv + .split_first() + .ok_or_else(|| anyhow!("agent {name} has an empty argv"))?; + (program.clone(), args.to_vec()) + } + }; + if profile.kind == crate::config::HarnessKind::Terminal { + return Err(anyhow!( + "{name} is a terminal harness without ACP; open it with `cmux harness run {name}`" + )); + } + let owned = if let (true, Some((session_id, _))) = (claude_session, session) { + let extra = crate::agent_tools::claude_args_for(false, &profile.env, &owned.1, session_id); + (owned.0, owned.1.into_iter().chain(extra).collect()) + } else { + owned + }; + let mut env = resolved_profile_env(profile)?; + // The Codex bridge runs the configured codex binary: `CODEX_PATH` of the + // profile or its family (`defaults.codex.env`), as the ACP adapter does. + let owned = match env.get("CODEX_PATH").filter(|p| !p.is_empty()) { + Some(path) if crate::codex_app_server::is_launch(&owned.0, &owned.1) => { + (path.clone(), owned.1) + } + _ => owned, + }; + // A bound route's provider variables the inherited env must not keep + // (routes.rs): an earlier route's key never reaches this process. + let route_unset = env.remove(crate::routes::UNSET_KEY); + env.remove(crate::routes::PROFILE_ROUTE_KEY); + let (program, args) = (&owned.0, &owned.1); + let mut cmd = Command::new(program); + // Startup files changed since the last read: refresh in the background. + crate::login_env::refresh_if_changed(); + // The login shell's allow-list for this harness's family (login_env.rs). + let family_name = crate::config::derive_family(name, profile); + let family = Some(family_name.as_str()); + crate::login_env::apply_tokio_for(&mut cmd, family, Some(&env)); + crate::config::scrub_nested_claude_env_tokio_for(&mut cmd, family); + // Caller context, herdr-style: the agent knows which session it is. + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("ACPMUX_") { + cmd.env_remove(&k); + } + } + // Helper tokens reach the cmux-cua MCP server through its own env only. + crate::cua_socket::scrub_agent_env(&mut cmd); + // A nested launch must not be taken for its parent's thread. + cmd.env_remove("CODEX_THREAD_ID").env_remove("OMPCODE"); + // The daemon's own terminal (it may have started in a cmux terminal) is + // not the agent's: a stale id would open the agent's tabs beside it. + for key in crate::agent_tools::INHERITED_CALLER_ENV { + cmd.env_remove(key); + } + for k in route_unset.iter().flat_map(|list| list.split(',')) { + cmd.env_remove(k); + } + if let Some((id, sname)) = session { + cmd.env("ACPMUX_ENV", "1") + .env("ACPMUX_SESSION_ID", id) + .env("ACPMUX_SESSION_NAME", sname) + .env("ACPMUX_SOCKET", crate::config::socket_path()) + // The caller the cmux CLI names to the app: its tabs open in the + // column right of this session's chat (beside_caller). + .env(crate::agent_tools::AGENT_SESSION_ENV, id); + } + cmd.args(crate::harness_limits::launch_args(program, args, session.map(|_| (cwd, &env)))) + .envs(env.iter()) + // A terminal's launch credential names that one terminal; no agent + // acts as the terminal that started acpmux, not even through a preset. + .env_remove(crate::config::LAUNCH_CREDENTIAL_ENV) + // Nor as the Chief whose host started acpmux (config::CHIEF_CREDENTIAL_ENV). + .env_remove(crate::config::CHIEF_CREDENTIAL_ENV[0]) + .env_remove(crate::config::CHIEF_CREDENTIAL_ENV[1]) + // Claude refuses to nest inside another Claude session. + .env_remove("CLAUDECODE") + .env_remove("CLAUDE_CODE_ENTRYPOINT") + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + // Own process group, so stopping the session stops everything the + // agent started underneath it (background shells included). Windows: a + // job object (`job.rs`), made where the agent is spawned. + #[cfg(unix)] + cmd.process_group(0); + crate::harness_limits::lift_claude(&mut cmd, profile, session.is_some()); + // A Claude agent's sign-in inputs, for a clear message if it has none. + match (family == Some("claude"), session) { + (true, Some((id, _))) => crate::claude_auth::remember(id, &command_env(&cmd)), + (false, Some((id, _))) => crate::claude_auth::forget(id), + _ => {} + } + Ok(cmd) +} + +/// The profile's env with its `${keychain:…}` and `${env:…}` references +/// resolved (`config::profiles`). The secret store is asked only when a +/// value holds a reference; the lookup blocks, so a multi-thread runtime +/// moves it off its worker first. +fn resolved_profile_env( + profile: &HarnessProfile, +) -> Result> { + let mut env = profile.env.clone(); + if !crate::config::profiles::has_env_refs(&env) { + return Ok(env); + } + let resolve = |env: &mut std::collections::BTreeMap| { + crate::config::profiles::resolve_env_refs( + env, + &|var| crate::login_env::allowed_var(var).or_else(|| std::env::var(var).ok()), + &crate::config::profiles::keychain_lookup, + ) + }; + let multi_thread = tokio::runtime::Handle::try_current() + .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread); + let result = if multi_thread { + tokio::task::block_in_place(|| resolve(&mut env)) + } else { + resolve(&mut env) + }; + result.map_err(|e| anyhow!(e))?; + Ok(env) +} + +/// The full environment `cmd` gives its child (inherited, then changed). +pub(crate) fn command_env(cmd: &Command) -> Vec<(String, String)> { + let mut env: std::collections::BTreeMap = std::env::vars().collect(); + for (k, v) in cmd.as_std().get_envs() { + let k = k.to_string_lossy().into_owned(); + match v { + Some(v) => { + env.insert(k, v.to_string_lossy().into_owned()); + } + None => { + env.remove(&k); + } + } + } + env.into_iter().collect() +} From 90269fbcd52ca40f2b7b4e28f2d1d6ff2ef567a5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:48:55 -0700 Subject: [PATCH 126/183] Regenerate settings schema, MDM manifests and settings page strings after merging tip d28e48ca Exports from nx-remote job 1014-183753-674f73 (Xcode 27), page strings from gen-strings.mjs on a Freestyle VM. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/mdm/com.manaflow.cmux.json | 6 +++ docs/mdm/com.manaflow.cmux.plist | 12 +++++ docs/mdm/managed-preferences.md | 1 + schemas/settings/settings-schema.json | 51 ++++++++++++++++++- .../src/pages/settings/generated/strings.json | 42 +++++++++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index 0e4ea2a0e641..063bfcec7217 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -1461,6 +1461,12 @@ "title" : "Keep Location in Photos and Videos", "type" : "boolean" }, + "labs.classicHome" : { + "default" : false, + "description" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts.", + "title" : "Classic Home", + "type" : "boolean" + }, "labs.previewFeatures" : { "default" : false, "description" : "Unfinished surfaces, such as the agent session's coverage label and Pull requests view.", diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index d84662a5829d..eaee73890684 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -4513,6 +4513,18 @@ pfm_type boolean
+ + pfm_default + + pfm_description + Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts. + pfm_name + labs.classicHome + pfm_title + Classic Home + pfm_type + boolean + pfm_default diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index ff241f0f7b1d..21806994591e 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -319,6 +319,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `notifications.attention.moveToTop` | boolean | `true` | | Move the Workspace to the Top. A notification moves its workspace to the top of the unpinned rows, unless you are looking at its pane. | | `notifications.mutedWorkspaces` | array | `[]` | | Muted Workspaces. Workspace ids whose notifications are silent. Mute a workspace from its sidebar row menu. | | `labs.previewFeatures` | boolean | `false` | | Show Preview Features. Unfinished surfaces, such as the agent session's coverage label and Pull requests view. | +| `labs.classicHome` | boolean | `false` | | Classic Home. Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts. | | `feed.github.enabled` | boolean | `false` | | Connect GitHub. Uses your gh login to read notifications and review requests on this Mac. Sign in with gh auth login first. | | `feed.github.pollIntervalSeconds` | real | `120` | 60 to 900 | Refresh Interval. Seconds between GitHub refreshes. Refresh in the Inbox runs immediately. | | `agentPane.links.outsideRoots` | string | `"open"` | `confirm`, `text`, `open` | Files Outside the Project. What a file link in a reply does when the file is outside the chat's folders. Keys and .env files never open. | diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index 24ccbd08fa6f..51d157a62316 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -910,6 +910,7 @@ "key" : "settings.group.labs", "rows" : [ "labs.previewFeatures", + "labs.classicHome", "appearance.experimentalControls" ], "title" : { @@ -22320,6 +22321,54 @@ }, "validation" : "portable" }, + { + "accepts" : [ + true, + false + ], + "agent_refusal" : null, + "agent_settable" : true, + "consumers" : [ + "cmux-next" + ], + "default" : false, + "default_label" : null, + "group" : { + "key" : "settings.group.labs", + "text" : "Labs" + }, + "help" : { + "key" : "settings.labs.classicHome.help", + "text" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts." + }, + "kept_on_reset_all" : false, + "key" : "labs.classicHome", + "keywords" : [ + "home", + "chief", + "classic", + "dock", + "page" + ], + "kind" : "toggle", + "page_hidden" : false, + "path" : [ + "labs", + "classicHome" + ], + "refuses" : [ + "true", + 1, + null + ], + "section" : "advanced", + "summary" : null, + "title" : { + "key" : "settings.labs.classicHome", + "text" : "Classic Home" + }, + "validation" : "portable" + }, { "accepts" : [ true, @@ -23723,7 +23772,7 @@ "validation" : "portable" } ], - "schema_hash" : "9dcda0838cf75d7a440dd5b189e174e55926e46bedbfadb1d07aaf4268bdf3db", + "schema_hash" : "bcd28b55ae95c1dc208eb2f6284da7af98930017e4294732258c1ba9f5563b5a", "sections" : [ { "id" : "general", diff --git a/webviews/src/pages/settings/generated/strings.json b/webviews/src/pages/settings/generated/strings.json index 422658037610..7ebe5f28ac61 100644 --- a/webviews/src/pages/settings/generated/strings.json +++ b/webviews/src/pages/settings/generated/strings.json @@ -469,6 +469,8 @@ "settings.history.terminalCommands.summary": "Shows finished commands in History", "settings.home.keepLocation": "Keep Location in Photos and Videos", "settings.home.keepLocation.help": "When off, location data is removed from photos and videos before they are attached.", + "settings.labs.classicHome": "Classic Home", + "settings.labs.classicHome.help": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts.", "settings.labs.previewFeatures": "Show Preview Features", "settings.labs.previewFeatures.help": "Unfinished surfaces, such as the agent session's coverage label and Pull requests view.", "settings.layout.centerFocusedColumn": "Center Focused Column", @@ -1487,6 +1489,8 @@ "settings.history.terminalCommands.summary": "يعرض الأوامر المكتملة في السجل", "settings.home.keepLocation": "الاحتفاظ بالموقع في الصور ومقاطع الفيديو", "settings.home.keepLocation.help": "عند الإيقاف، تُزال بيانات الموقع من الصور ومقاطع الفيديو قبل إرفاقها.", + "settings.labs.classicHome": "الصفحة الرئيسية الكلاسيكية", + "settings.labs.classicHome.help": "عرض الصفحة الرئيسية كصفحة كاملة بدلًا من مساحة عمل يكون فيها Chief مثبتًا على اليسار. يسري التغيير بعد إعادة تشغيل cmux.", "settings.labs.previewFeatures": "إظهار ميزات المعاينة", "settings.labs.previewFeatures.help": "واجهات غير مكتملة، مثل تسمية تغطية جلسة الوكيل وعرض طلبات السحب.", "settings.layout.centerFocusedColumn": "توسيط العمود المحدد", @@ -2505,6 +2509,8 @@ "settings.history.terminalCommands.summary": "Prikazuje završene komande u Historiji", "settings.home.keepLocation": "Zadrži lokaciju u fotografijama i videozapisima", "settings.home.keepLocation.help": "Kada je isključeno, podaci o lokaciji uklanjaju se iz fotografija i videozapisa prije prilaganja.", + "settings.labs.classicHome": "Klasična početna", + "settings.labs.classicHome.help": "Prikaži početnu kao cijelu stranicu umjesto radnog prostora s Chiefom usidrenim lijevo. Primjenjuje se nakon ponovnog pokretanja cmuxa.", "settings.labs.previewFeatures": "Prikaži funkcije u pregledu", "settings.labs.previewFeatures.help": "Nedovršeni dijelovi, poput oznake pokrivenosti sesije agenta i prikaza zahtjeva za povlačenje.", "settings.layout.centerFocusedColumn": "Centriraj fokusiranu kolonu", @@ -3523,6 +3529,8 @@ "settings.history.terminalCommands.summary": "Viser færdige kommandoer i Historik", "settings.home.keepLocation": "Behold lokation i fotos og videoer", "settings.home.keepLocation.help": "Når den er slået fra, fjernes lokationsdata fra fotos og videoer, før de vedhæftes.", + "settings.labs.classicHome": "Klassisk Hjem", + "settings.labs.classicHome.help": "Vis Hjem som en hel side i stedet for et arbejdsområde med Chief fastgjort til venstre. Træder i kraft, når cmux genstartes.", "settings.labs.previewFeatures": "Vis forhåndsvisningsfunktioner", "settings.labs.previewFeatures.help": "Ufærdige flader, f.eks. agentsessionens dækningsetiket og visningen af pull requests.", "settings.layout.centerFocusedColumn": "Centrér kolonnen i fokus", @@ -4541,6 +4549,8 @@ "settings.history.terminalCommands.summary": "Zeigt beendete Befehle im Verlauf", "settings.home.keepLocation": "Ort in Fotos und Videos behalten", "settings.home.keepLocation.help": "Wenn aus, werden Ortsdaten aus Fotos und Videos entfernt, bevor sie angehängt werden.", + "settings.labs.classicHome": "Klassisches Home", + "settings.labs.classicHome.help": "Home als ganze Seite statt als Arbeitsbereich mit links angedocktem Chief anzeigen. Wirkt nach dem Neustart von cmux.", "settings.labs.previewFeatures": "Vorschaufunktionen anzeigen", "settings.labs.previewFeatures.help": "Unfertige Oberflächen, etwa die Abdeckungsangabe der Agent-Sitzung und die Pull-Request-Ansicht.", "settings.layout.centerFocusedColumn": "Fokussierte Spalte zentrieren", @@ -5559,6 +5569,8 @@ "settings.history.terminalCommands.summary": "Muestra los comandos terminados en el Historial", "settings.home.keepLocation": "Mantener la ubicación en fotos y vídeos", "settings.home.keepLocation.help": "Si está desactivado, los datos de ubicación se eliminan de las fotos y los vídeos antes de adjuntarlos.", + "settings.labs.classicHome": "Inicio clásico", + "settings.labs.classicHome.help": "Muestra Inicio como una página completa en lugar de un espacio de trabajo con Chief acoplado a la izquierda. Se aplica después de reiniciar cmux.", "settings.labs.previewFeatures": "Mostrar funciones en vista previa", "settings.labs.previewFeatures.help": "Superficies sin terminar, como la etiqueta de cobertura de la sesión del agente y la vista de solicitudes de incorporación.", "settings.layout.centerFocusedColumn": "Centrar la columna enfocada", @@ -6577,6 +6589,8 @@ "settings.history.terminalCommands.summary": "Affiche les commandes terminées dans l’historique", "settings.home.keepLocation": "Conserver la position dans les photos et vidéos", "settings.home.keepLocation.help": "Si désactivé, les données de position sont supprimées des photos et vidéos avant leur ajout en pièce jointe.", + "settings.labs.classicHome": "Accueil classique", + "settings.labs.classicHome.help": "Afficher l'Accueil en pleine page plutôt que comme un espace de travail avec Chief ancré à gauche. Prend effet après le redémarrage de cmux.", "settings.labs.previewFeatures": "Afficher les fonctionnalités en aperçu", "settings.labs.previewFeatures.help": "Surfaces inachevées, comme l'étiquette de couverture de la session de l'agent et la vue des pull requests.", "settings.layout.centerFocusedColumn": "Centrer la colonne active", @@ -7595,6 +7609,8 @@ "settings.history.terminalCommands.summary": "Mostra i comandi completati nella Cronologia", "settings.home.keepLocation": "Mantieni la posizione in foto e video", "settings.home.keepLocation.help": "Se disattivato, i dati sulla posizione vengono rimossi da foto e video prima di allegarli.", + "settings.labs.classicHome": "Home classica", + "settings.labs.classicHome.help": "Mostra la Home come pagina intera invece che come spazio di lavoro con Chief agganciato a sinistra. Ha effetto dopo il riavvio di cmux.", "settings.labs.previewFeatures": "Mostra funzioni in anteprima", "settings.labs.previewFeatures.help": "Superfici non finite, come l'etichetta di copertura della sessione dell'agente e la vista delle pull request.", "settings.layout.centerFocusedColumn": "Centra la colonna attiva", @@ -8613,6 +8629,8 @@ "settings.history.terminalCommands.summary": "完了したコマンドを履歴に表示", "settings.home.keepLocation": "写真とビデオの位置情報を残す", "settings.home.keepLocation.help": "オフのときは、写真とビデオを添付する前に位置情報を削除します。", + "settings.labs.classicHome": "クラシックホーム", + "settings.labs.classicHome.help": "ホームを、Chief を左にドッキングしたワークスペースではなく、全画面のページとして表示します。cmux の再起動後に反映されます。", "settings.labs.previewFeatures": "プレビュー機能を表示", "settings.labs.previewFeatures.help": "エージェントセッションのカバレッジ表示やプルリクエスト表示など、未完成の画面。", "settings.layout.centerFocusedColumn": "フォーカス中のカラムを中央に", @@ -9631,6 +9649,8 @@ "settings.history.terminalCommands.summary": "បង្ហាញពាក្យបញ្ជាដែលបានបញ្ចប់ក្នុងប្រវត្តិ", "settings.home.keepLocation": "រក្សាទីតាំងក្នុងរូបថត និងវីដេអូ", "settings.home.keepLocation.help": "នៅពេលបិទ ទិន្នន័យទីតាំងត្រូវបានលុបចេញពីរូបថត និងវីដេអូ មុនពេលភ្ជាប់។", + "settings.labs.classicHome": "ទំព័រដើមបុរាណ", + "settings.labs.classicHome.help": "បង្ហាញទំព័រដើមជាទំព័រពេញ ជំនួសឱ្យកន្លែងធ្វើការដែលមាន Chief ភ្ជាប់នៅខាងឆ្វេង។ មានប្រសិទ្ធភាពបន្ទាប់ពីចាប់ផ្ដើម cmux ឡើងវិញ។", "settings.labs.previewFeatures": "បង្ហាញមុខងារមើលជាមុន", "settings.labs.previewFeatures.help": "ផ្ទៃដែលមិនទាន់បញ្ចប់ ដូចជាស្លាកវិសាលភាពនៃវគ្គភ្នាក់ងារ និងទិដ្ឋភាពសំណើទាញ។", "settings.layout.centerFocusedColumn": "ដាក់ជួរឈរដែលផ្ដោតនៅកណ្ដាល", @@ -10649,6 +10669,8 @@ "settings.history.terminalCommands.summary": "완료된 명령을 기록에 표시", "settings.home.keepLocation": "사진 및 비디오의 위치 정보 유지", "settings.home.keepLocation.help": "끄면 사진과 비디오를 첨부하기 전에 위치 데이터를 제거합니다.", + "settings.labs.classicHome": "클래식 홈", + "settings.labs.classicHome.help": "Chief가 왼쪽에 고정된 작업 공간 대신 홈을 전체 페이지로 표시합니다. cmux를 다시 시작한 후 적용됩니다.", "settings.labs.previewFeatures": "미리 보기 기능 표시", "settings.labs.previewFeatures.help": "에이전트 세션의 적용 범위 레이블과 풀 리퀘스트 보기처럼 아직 완성되지 않은 화면입니다.", "settings.layout.centerFocusedColumn": "포커스된 열 가운데 정렬", @@ -11667,6 +11689,8 @@ "settings.history.terminalCommands.summary": "Viser fullførte kommandoer i Historikk", "settings.home.keepLocation": "Behold posisjon i bilder og videoer", "settings.home.keepLocation.help": "Når den er av, fjernes posisjonsdata fra bilder og videoer før de legges ved.", + "settings.labs.classicHome": "Klassisk Hjem", + "settings.labs.classicHome.help": "Vis Hjem som en hel side i stedet for et arbeidsområde med Chief festet til venstre. Trer i kraft når cmux startes på nytt.", "settings.labs.previewFeatures": "Vis forhåndsvisningsfunksjoner", "settings.labs.previewFeatures.help": "Uferdige flater, for eksempel agentøktens dekningsetikett og visningen av pull-forespørsler.", "settings.layout.centerFocusedColumn": "Midtstill kolonnen i fokus", @@ -12685,6 +12709,8 @@ "settings.history.terminalCommands.summary": "Pokazuje zakończone polecenia w Historii", "settings.home.keepLocation": "Zachowaj lokalizację w zdjęciach i filmach", "settings.home.keepLocation.help": "Gdy wyłączone, dane lokalizacji są usuwane ze zdjęć i filmów przed ich załączeniem.", + "settings.labs.classicHome": "Klasyczny Start", + "settings.labs.classicHome.help": "Pokazuj Start jako pełną stronę zamiast obszaru roboczego z Chiefem zadokowanym po lewej. Zmiana działa po ponownym uruchomieniu cmux.", "settings.labs.previewFeatures": "Pokaż funkcje w wersji zapoznawczej", "settings.labs.previewFeatures.help": "Niedokończone elementy, takie jak etykieta pokrycia sesji agenta i widok pull requestów.", "settings.layout.centerFocusedColumn": "Wyśrodkuj aktywną kolumnę", @@ -13703,6 +13729,8 @@ "settings.history.terminalCommands.summary": "Mostra os comandos concluídos no Histórico", "settings.home.keepLocation": "Manter a localização em fotos e vídeos", "settings.home.keepLocation.help": "Quando desativado, os dados de localização são removidos de fotos e vídeos antes de serem anexados.", + "settings.labs.classicHome": "Início clássico", + "settings.labs.classicHome.help": "Mostra o Início como uma página inteira em vez de um espaço de trabalho com o Chief encaixado à esquerda. Vale depois que o cmux for reiniciado.", "settings.labs.previewFeatures": "Mostrar recursos em prévia", "settings.labs.previewFeatures.help": "Superfícies inacabadas, como o rótulo de cobertura da sessão do agente e a visualização de pull requests.", "settings.layout.centerFocusedColumn": "Centralizar coluna em foco", @@ -14721,6 +14749,8 @@ "settings.history.terminalCommands.summary": "Показывает завершённые команды в истории", "settings.home.keepLocation": "Сохранять геопозицию в фото и видео", "settings.home.keepLocation.help": "Если выключено, данные о местоположении удаляются из фото и видео перед вложением.", + "settings.labs.classicHome": "Классическая главная", + "settings.labs.classicHome.help": "Показывать главную как целую страницу, а не рабочее пространство с Chief, закреплённым слева. Применяется после перезапуска cmux.", "settings.labs.previewFeatures": "Показывать предварительные функции", "settings.labs.previewFeatures.help": "Незавершённые элементы, например метка покрытия сеанса агента и представление запросов на слияние.", "settings.layout.centerFocusedColumn": "Центрировать активную колонку", @@ -15739,6 +15769,8 @@ "settings.history.terminalCommands.summary": "แสดงคำสั่งที่เสร็จแล้วในประวัติ", "settings.home.keepLocation": "เก็บตำแหน่งที่ตั้งไว้ในรูปภาพและวิดีโอ", "settings.home.keepLocation.help": "เมื่อปิด ข้อมูลตำแหน่งที่ตั้งจะถูกลบออกจากรูปภาพและวิดีโอก่อนแนบ", + "settings.labs.classicHome": "หน้าหลักแบบคลาสสิก", + "settings.labs.classicHome.help": "แสดงหน้าหลักเป็นหน้าเต็มแทนพื้นที่ทำงานที่มี Chief ปักหมุดอยู่ทางซ้าย มีผลหลังจากเริ่ม cmux ใหม่", "settings.labs.previewFeatures": "แสดงฟีเจอร์ตัวอย่าง", "settings.labs.previewFeatures.help": "ส่วนที่ยังไม่เสร็จ เช่น ป้ายความครอบคลุมของเซสชันเอเจนต์และมุมมองคำขอดึง", "settings.layout.centerFocusedColumn": "จัดคอลัมน์ที่โฟกัสไว้ตรงกลาง", @@ -16757,6 +16789,8 @@ "settings.history.terminalCommands.summary": "Biten komutları Geçmiş'te gösterir", "settings.home.keepLocation": "Fotoğraf ve videolarda konumu koru", "settings.home.keepLocation.help": "Kapalıyken, konum verileri fotoğraf ve videolar eklenmeden önce kaldırılır.", + "settings.labs.classicHome": "Klasik Ana Sayfa", + "settings.labs.classicHome.help": "Ana Sayfa'yı, Chief'in solda sabitlendiği bir çalışma alanı yerine tam sayfa olarak göster. cmux yeniden başlatıldıktan sonra geçerli olur.", "settings.labs.previewFeatures": "Önizleme Özelliklerini Göster", "settings.labs.previewFeatures.help": "Ajan oturumunun kapsam etiketi ve çekme istekleri görünümü gibi bitmemiş yüzeyler.", "settings.layout.centerFocusedColumn": "Odaktaki Sütunu Ortala", @@ -17775,6 +17809,8 @@ "settings.history.terminalCommands.summary": "Показує завершені команди в історії", "settings.home.keepLocation": "Зберігати геопозицію у фото й відео", "settings.home.keepLocation.help": "Якщо вимкнено, дані про геопозицію видаляються з фото й відео перед вкладенням.", + "settings.labs.classicHome": "Класична головна", + "settings.labs.classicHome.help": "Показувати головну як цілу сторінку, а не робочий простір із Chief, закріпленим ліворуч. Застосовується після перезапуску cmux.", "settings.labs.previewFeatures": "Показувати попередні функції", "settings.labs.previewFeatures.help": "Незавершені елементи, як-от позначка покриття сеансу агента та подання запитів на злиття.", "settings.layout.centerFocusedColumn": "Центрувати активну колонку", @@ -18793,6 +18829,8 @@ "settings.history.terminalCommands.summary": "Hiện các lệnh đã chạy xong trong Lịch sử", "settings.home.keepLocation": "Giữ vị trí trong ảnh và video", "settings.home.keepLocation.help": "Khi tắt, dữ liệu vị trí sẽ bị xóa khỏi ảnh và video trước khi đính kèm.", + "settings.labs.classicHome": "Trang chủ cổ điển", + "settings.labs.classicHome.help": "Hiển thị Trang chủ dạng trang đầy đủ thay vì không gian làm việc có Chief neo bên trái. Có hiệu lực sau khi khởi động lại cmux.", "settings.labs.previewFeatures": "Hiện tính năng xem trước", "settings.labs.previewFeatures.help": "Các phần chưa hoàn thiện, như nhãn phạm vi của phiên tác tử và chế độ xem yêu cầu kéo.", "settings.layout.centerFocusedColumn": "Căn giữa cột đang chọn", @@ -19811,6 +19849,8 @@ "settings.history.terminalCommands.summary": "在历史记录中列出已完成的命令", "settings.home.keepLocation": "在照片和视频中保留位置", "settings.home.keepLocation.help": "关闭时,照片和视频在附加前会移除位置数据。", + "settings.labs.classicHome": "经典主页", + "settings.labs.classicHome.help": "将主页显示为完整页面,而不是左侧停靠 Chief 的工作区。重新启动 cmux 后生效。", "settings.labs.previewFeatures": "显示预览功能", "settings.labs.previewFeatures.help": "尚未完成的界面,例如智能体会话的覆盖范围标签和拉取请求视图。", "settings.layout.centerFocusedColumn": "居中显示焦点列", @@ -20829,6 +20869,8 @@ "settings.history.terminalCommands.summary": "在歷史記錄中列出已完成的指令", "settings.home.keepLocation": "在照片和影片中保留位置", "settings.home.keepLocation.help": "關閉時,照片和影片在附加前會移除位置資料。", + "settings.labs.classicHome": "經典主頁", + "settings.labs.classicHome.help": "將主頁顯示為完整頁面,而非左側停駐 Chief 的工作區。重新啟動 cmux 後生效。", "settings.labs.previewFeatures": "顯示預覽功能", "settings.labs.previewFeatures.help": "尚未完成的介面,例如代理程式工作階段的涵蓋範圍標籤與提取要求檢視。", "settings.layout.centerFocusedColumn": "置中顯示焦點欄", From 3d2910272ad74e96ebe404d0ce85f376fe03c785 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:02:00 -0700 Subject: [PATCH 127/183] cmux-next: CmuxNextApp cap 113750 -> 113810 for the motion landing chief go 2026-10-15, one-time, follow-up cx-me3xd Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/cmux-next/app-size-baseline.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/cmux-next/app-size-baseline.txt b/scripts/cmux-next/app-size-baseline.txt index 9020042e4817..643c8ac18db7 100644 --- a/scripts/cmux-next/app-size-baseline.txt +++ b/scripts/cmux-next/app-size-baseline.txt @@ -2,4 +2,4 @@ # Packages/macOS/CmuxNext/Sources/CmuxNextApp/**/*.swift. It may only go down # (check-app-size.sh --update-baseline). New features go in their own target # (plans/cmux-next/code-quality.md SW14). -113750 +113810 From f0b44b2d9754702c17435a76f8e87e0c98b37bf7 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:13:53 -0700 Subject: [PATCH 128/183] ci: warm Vite before browser tests, per-file limits, 40 min cap (cx-v6fbz) (#20733) * test: a webviews test file can declare a longer limit (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * fix: editor-page declares a 240 s limit; the runner honors per-file limits (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * fix: browser tests warm their Vite server before the first page load (cx-v6fbz) Vite prints its port before it has transformed a module, so a test's first goto paid for the whole module graph inside its 30 s navigation timeout. On a busy CI runner that failed chat-find and text-field-focus (#20697), and the gallery-frame and agent-pane browser warm-ups hit the same wait (#20681). warmDevServer requests the page and every module it imports first. Co-Authored-By: Claude Opus 5.5 * fix: diff-virtualization clicks a tree row only while it is still under the pointer (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * ci: react-apps-check job cap 30 -> 40 min (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * fix: the composer browser tests new on base warm their Vite server too (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * check: a test that starts a Vite dev server must warm it (cx-v6fbz) The browser-lane check now fails a test file that starts a dev server (vite.listen() or a vp dev spawn) without warmViteServer/warmDevServer. On base it flags 80 of 240 test files; on this head none. Co-Authored-By: Claude Opus 5.5 * test: a declared webviews file limit never shortens the default (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * fix: the longer of a declared and the default webviews file limit wins (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * fix: review follow-ups: settings-scroll warms after its readiness poll; the diff row check climbs out of shadow roots (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * fix: agent-pane-bottom-stack (new on base) warms its Vite server (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * fix: text-field-focus declares a 240 s limit; its gallery warm-up and 22 loads took about 115 s (cx-v6fbz) Co-Authored-By: Claude Opus 5.5 * ci: move the react-apps-check cap raise to its own PR The ci-web.yml edit widens this PR's web scope to the web/ unit shards, which are red on base. The cap raise lands separately. Co-Authored-By: Claude Opus 5.5 * test: give dev-server files 240 s and settle abandoned warm-up visits Review follow-up: the spawned-server files and menu-focus warm the whole gallery graph before their browser warm-up, which can pass the default 120 s file limit on a loaded runner. A failed warm-up visit no longer leaves the other in-flight visits as unhandled rejections. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- scripts/ci/run-webviews-tests.sh | 17 ++++- tests/test_webviews_tests_per_file.py | 19 +++++ webviews/scripts/check-browser-test-lanes.ts | 18 ++++- webviews/test/agent-history.test.ts | 2 + .../test/agent-pane-attach-typing.test.ts | 2 + .../test/agent-pane-background-tasks.test.ts | 2 + webviews/test/agent-pane-bottom-stack.test.ts | 2 + webviews/test/agent-pane-branches.test.ts | 2 + webviews/test/agent-pane-chat-column.test.ts | 2 + webviews/test/agent-pane-diff-find.test.ts | 2 + ...gent-pane-edit-awaiting-permission.test.ts | 2 + webviews/test/agent-pane-edited-side.test.ts | 2 + webviews/test/agent-pane-follow-live.test.ts | 2 + .../test/agent-pane-harness-catalog.test.ts | 2 + .../test/agent-pane-history-paging.test.ts | 2 + webviews/test/agent-pane-mode-keys.test.ts | 2 + webviews/test/agent-pane-peer-chat.test.ts | 2 + webviews/test/agent-pane-plan.test.ts | 2 + webviews/test/agent-pane-pr-dock.test.ts | 2 + webviews/test/agent-pane-queue.test.ts | 2 + .../test/agent-pane-reading-place.test.ts | 2 + webviews/test/agent-pane-replay.test.ts | 2 + webviews/test/agent-pane-secret.test.ts | 2 + webviews/test/agent-pane-seen.test.ts | 2 + .../test/agent-pane-session-offer.test.ts | 2 + .../test/agent-pane-side-question.test.ts | 2 + webviews/test/agent-pane-soft-edges.test.ts | 2 + webviews/test/agent-pane-status-marks.test.ts | 2 + webviews/test/agent-pane-steer-split.test.ts | 2 + webviews/test/agent-pane-steer.test.ts | 2 + webviews/test/agent-pane-step-rows.test.ts | 2 + webviews/test/agent-pane-turn-footer.test.ts | 2 + .../test/agent-pane-unknown-harness.test.ts | 2 + webviews/test/agent-pane-unsent-draft.test.ts | 2 + webviews/test/changes-address-thread.test.ts | 2 + webviews/test/changes-review-threads.test.ts | 2 + webviews/test/changes-stage-hunk-keys.test.ts | 2 + webviews/test/chat-find.test.ts | 5 +- webviews/test/chat-palette-actions.test.ts | 2 + .../test/code-block-highlight-height.test.ts | 2 + webviews/test/composer-capture.test.ts | 2 + webviews/test/composer-edges.test.ts | 2 + webviews/test/composer-history.test.ts | 2 + webviews/test/composer-location-side.test.ts | 2 + webviews/test/composer-location-strip.test.ts | 2 + webviews/test/composer-mention.test.ts | 2 + webviews/test/composer-rim-hairline.test.ts | 2 + webviews/test/composer-row-layout.test.ts | 2 + .../test/composer-search-files-key.test.ts | 2 + webviews/test/composer-send-stop.test.ts | 2 + webviews/test/composer-turn-cycle.test.ts | 2 + webviews/test/desktop-feel.test.ts | 5 +- webviews/test/diff-toolbar-narrow.test.ts | 2 + webviews/test/diff-virtualization.test.ts | 31 +++++++- webviews/test/editor-page.test.ts | 7 +- webviews/test/files-panel-coverage.test.ts | 5 +- webviews/test/ghost-button-balance.test.ts | 2 + webviews/test/history-closed-rows.test.ts | 2 + webviews/test/ime-composition-keys.test.ts | 5 +- webviews/test/instructions-notice.test.ts | 2 + webviews/test/menu-focus.test.ts | 3 + webviews/test/new-chat-effort-chip.test.ts | 2 + .../test/new-tab-add-project-layer.test.ts | 2 + webviews/test/new-tab-list-motion.test.ts | 2 + webviews/test/new-tab-omnibar-pointer.test.ts | 2 + webviews/test/new-tab-picker-clip.test.ts | 2 + webviews/test/new-tab-scroll.test.ts | 2 + webviews/test/new-tab-typing.test.ts | 2 + webviews/test/newtab-quiet-focus.test.ts | 2 + .../test/permission-panel-buttons.test.ts | 2 + webviews/test/popup-escape.test.ts | 2 + webviews/test/popup-reduce-motion.test.ts | 2 + webviews/test/secret-card.test.ts | 2 + webviews/test/settings-scroll.test.ts | 14 ++-- webviews/test/sources-changes.test.ts | 2 + webviews/test/status-glyphs.test.ts | 2 + webviews/test/suggestion-lists-float.test.ts | 2 + webviews/test/support/warmDevServer.ts | 72 +++++++++++++++++++ webviews/test/text-field-focus.test.ts | 8 ++- webviews/test/transcript-fling.test.ts | 2 + webviews/test/transcript-follow.test.ts | 2 + webviews/test/transcript-thought.test.ts | 2 + webviews/test/turn-fork.test.ts | 2 + webviews/test/ui-a11y.test.ts | 5 +- webviews/test/worktree-creating.test.ts | 2 + 85 files changed, 339 insertions(+), 17 deletions(-) create mode 100644 webviews/test/support/warmDevServer.ts diff --git a/scripts/ci/run-webviews-tests.sh b/scripts/ci/run-webviews-tests.sh index 8b50b307a761..06d014b73388 100755 --- a/scripts/ci/run-webviews-tests.sh +++ b/scripts/ci/run-webviews-tests.sh @@ -7,7 +7,8 @@ # jsdom globals). A process per file keeps that leak inside one file, and a # wall-clock timeout per file names a stuck file instead of hanging the job. # -# CMUX_WEB_TEST_FILE_TIMEOUT: seconds one file may run (default 120). +# CMUX_WEB_TEST_FILE_TIMEOUT: seconds one file may run (default 120); a file may declare more +# with a `// cmux-web-test-timeout: SECONDS` header line. # CMUX_WEB_TEST_JOBS: files run at once (default: CMUX_CI_CPU_BUDGET, the cores a fleet # worker granted the step, else the CPU count). # @@ -59,6 +60,16 @@ run_with_timeout() { fi } +# file_limit FILE DEFAULT: the seconds FILE may run. A file whose own tests need more than the +# default names its limit on a header line (first 20 lines), `// cmux-web-test-timeout: SECONDS` +# (editor-page: 24 browser tests in two engines, about 115 s on the CI runner, were killed at +# 120 s mid-WebKit). The longer of the two wins, so a declared limit never shortens the default. +file_limit() { + local declared + declared="$(head -n 20 "$1" 2> /dev/null | sed -n 's|^// cmux-web-test-timeout: \([1-9][0-9]*\)$|\1|p' | head -n 1)" + if [ -n "$declared" ] && [ "$declared" -gt "$2" ]; then echo "$declared"; else echo "$2"; fi +} + if [ "${1:-}" = --one-cached ]; then # xargs -n 3 appends FILE CACHE SEED after the fixed arguments. exec bash "$0" --one "$4" "$2" "$3" "$5" "${6:-}" @@ -66,7 +77,7 @@ fi if [ "${1:-}" = --one ]; then # Worker: run one file, write its log and status into the results directory. A dev server # file gets a Vite cache of its own (CMUX_VITE_CACHE_DIR), a copy of a warm one when given. - file="$2" results="$3" timeout_seconds="$4" cache="${5:-}" seed="${6:-}" + file="$2" results="$3" timeout_seconds="$(file_limit "$2" "$4")" cache="${5:-}" seed="${6:-}" log="$results/$(printf '%s' "$file" | tr '/' '_').log" status=0 if [ -n "$cache" ]; then @@ -202,6 +213,6 @@ done < <(sort -k2 "$results/summary") echo "### webviews tests: $count files, $failed failed or timed out" while read -r result file _; do - [ "$result" = PASS ] || echo "$result $file (limit ${timeout_seconds}s)" + [ "$result" = PASS ] || echo "$result $file (limit $(file_limit "$file" "$timeout_seconds")s)" done < <(sort -k2 "$results/summary") [ "$failed" -eq 0 ] diff --git a/tests/test_webviews_tests_per_file.py b/tests/test_webviews_tests_per_file.py index 90a5f3ea1a29..f589da27c339 100644 --- a/tests/test_webviews_tests_per_file.py +++ b/tests/test_webviews_tests_per_file.py @@ -49,6 +49,7 @@ fi case "$2" in *hang*) sleep 60 ;; + *slow*) sleep 3; echo "(pass) $2" ;; *fail*) echo "(fail) a broken test"; exit 1 ;; *) echo "(pass) $2" ;; esac @@ -171,6 +172,24 @@ def test_dev_server_files_run_at_once_each_on_its_own_warm_vite_cache(self): self.assertEqual(sum(warm == "cold" for _, _, warm in runs), 4, runs) self.assertGreater(max(int(n) for n in self.peak.read_text(encoding="utf-8").split()), 1, output) + def test_a_file_can_declare_a_longer_limit_than_the_default(self): + # editor-page runs 24 browser tests in two engines, about 115 s on the CI runner: the + # 120 s default killed it mid-WebKit, and its later tests read "browser has been closed". + # A file names its own limit on a header line; one without it keeps the default. + contents = { + "test/slow-declared.test.ts": "// cmux-web-test-timeout: 8\n", + "test/slow-default.test.ts": "", + # A declared limit never shortens a longer CMUX_WEB_TEST_FILE_TIMEOUT. + "test/slow-shorter.test.ts": "// cmux-web-test-timeout: 1\n", + } + result, _, calls = self.run_runner(list(contents), contents=contents) + output = result.stdout + result.stderr + self.assertEqual(calls, sorted(f"./{name}" for name in contents)) + self.assertNotRegex(output, r"(FAIL|TIMEOUT)[^\n]*test/slow-declared\.test\.ts") + self.assertRegex(output, r"TIMEOUT[^\n]*test/slow-default\.test\.ts \(limit 2s\)") + self.assertIn("test/slow-declared.test.ts", output) + self.assertRegex(output, r"TIMEOUT[^\n]*test/slow-shorter\.test\.ts \(limit 2s\)") + def test_no_test_files_is_an_error(self): result, _, _ = self.run_runner([]) self.assertNotEqual(result.returncode, 0) diff --git a/webviews/scripts/check-browser-test-lanes.ts b/webviews/scripts/check-browser-test-lanes.ts index 56f0a7af1bf9..7887119dfdc1 100644 --- a/webviews/scripts/check-browser-test-lanes.ts +++ b/webviews/scripts/check-browser-test-lanes.ts @@ -76,6 +76,21 @@ export function checkBrowserLaneSource(file: string, text: string): string[] { ]; } +/** + * A test that starts a Vite dev server warms it (test/support/warmDevServer.ts) before a browser + * loads a page: Vite transforms each module on its first request, and a cold first load ran past + * the 30 s navigation timeout on a busy CI runner (#20697, #20681). + */ +export function checkDevServerWarmSource(file: string, text: string): string[] { + const inProcess = /\bawait\s+vite\.listen\(\)/.test(text); + const spawned = text.includes("node_modules/.bin/vp") && /\[\s*"dev",/.test(text); + if (!inProcess && !spawned) return []; + if (/\bwarm(?:Vite|Dev)Server\(/.test(text)) return []; + return [ + `${path.relative(root, file)}: starts a Vite dev server without warming it; call ${inProcess ? "warmViteServer(vite, page)" : "warmDevServer(base, page)"} from ./support/warmDevServer once it listens`, + ]; +} + if (import.meta.main) { const errors: string[] = []; function scan(directory: string) { @@ -83,7 +98,8 @@ if (import.meta.main) { const file = path.join(directory, entry.name); if (entry.isDirectory()) scan(file); else if (/[._](?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) { - errors.push(...checkBrowserLaneSource(file, readFileSync(file, "utf8"))); + const text = readFileSync(file, "utf8"); + errors.push(...checkBrowserLaneSource(file, text), ...checkDevServerWarmSource(file, text)); } } } diff --git a/webviews/test/agent-history.test.ts b/webviews/test/agent-history.test.ts index ba74458f96a2..c8ead4a20821 100644 --- a/webviews/test/agent-history.test.ts +++ b/webviews/test/agent-history.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-history.test.ts", async () => { setDefaultTimeout(120_000); @@ -47,6 +48,7 @@ await requireBrowserLane("agent-history.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-attach-typing.test.ts b/webviews/test/agent-pane-attach-typing.test.ts index 0913645e656b..cbc8b1789311 100644 --- a/webviews/test/agent-pane-attach-typing.test.ts +++ b/webviews/test/agent-pane-attach-typing.test.ts @@ -13,6 +13,7 @@ import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { PNG } from "pngjs"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-attach-typing.test.ts", async () => { setDefaultTimeout(120_000); @@ -35,6 +36,7 @@ await requireBrowserLane("agent-pane-attach-typing.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-background-tasks.test.ts b/webviews/test/agent-pane-background-tasks.test.ts index 0172a94f77c2..a11fc63a8f8e 100644 --- a/webviews/test/agent-pane-background-tasks.test.ts +++ b/webviews/test/agent-pane-background-tasks.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-background-tasks.test.ts", async () => { setDefaultTimeout(120_000); @@ -141,6 +142,7 @@ await requireBrowserLane("agent-pane-background-tasks.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the notes. diff --git a/webviews/test/agent-pane-bottom-stack.test.ts b/webviews/test/agent-pane-bottom-stack.test.ts index 95cf67c16031..a8caee320e4a 100644 --- a/webviews/test/agent-pane-bottom-stack.test.ts +++ b/webviews/test/agent-pane-bottom-stack.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-bottom-stack.test.ts", async () => { setDefaultTimeout(120_000); @@ -171,6 +172,7 @@ await requireBrowserLane("agent-pane-bottom-stack.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI: load it here, again if it stalls. for (let attempt = 1; ; attempt++) { diff --git a/webviews/test/agent-pane-branches.test.ts b/webviews/test/agent-pane-branches.test.ts index 1a3644900963..c90496d07b6e 100644 --- a/webviews/test/agent-pane-branches.test.ts +++ b/webviews/test/agent-pane-branches.test.ts @@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-branches.test.ts", async () => { setDefaultTimeout(120_000); @@ -145,6 +146,7 @@ await requireBrowserLane("agent-pane-branches.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the versions. diff --git a/webviews/test/agent-pane-chat-column.test.ts b/webviews/test/agent-pane-chat-column.test.ts index fb9e4d79349e..903d7004ddb3 100644 --- a/webviews/test/agent-pane-chat-column.test.ts +++ b/webviews/test/agent-pane-chat-column.test.ts @@ -23,6 +23,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-chat-column.test.ts", async () => { setDefaultTimeout(120_000); @@ -139,6 +140,7 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI: load it here, again if it stalls. for (let attempt = 1; ; attempt++) { diff --git a/webviews/test/agent-pane-diff-find.test.ts b/webviews/test/agent-pane-diff-find.test.ts index 439d07757950..5733c97eb78e 100644 --- a/webviews/test/agent-pane-diff-find.test.ts +++ b/webviews/test/agent-pane-diff-find.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-diff-find.test.ts", async () => { setDefaultTimeout(120_000); @@ -141,6 +142,7 @@ await requireBrowserLane("agent-pane-diff-find.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the card. diff --git a/webviews/test/agent-pane-edit-awaiting-permission.test.ts b/webviews/test/agent-pane-edit-awaiting-permission.test.ts index b05c1bb4fc5d..b5766431dc80 100644 --- a/webviews/test/agent-pane-edit-awaiting-permission.test.ts +++ b/webviews/test/agent-pane-edit-awaiting-permission.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-edit-awaiting-permission.test.ts", async () => { setDefaultTimeout(120_000); @@ -189,6 +190,7 @@ await requireBrowserLane("agent-pane-edit-awaiting-permission.test.ts", async () logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here first. for (let attempt = 1; ; attempt++) { diff --git a/webviews/test/agent-pane-edited-side.test.ts b/webviews/test/agent-pane-edited-side.test.ts index 89740c7a6ebd..8129a278c165 100644 --- a/webviews/test/agent-pane-edited-side.test.ts +++ b/webviews/test/agent-pane-edited-side.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-edited-side.test.ts", async () => { setDefaultTimeout(120_000); @@ -125,6 +126,7 @@ await requireBrowserLane("agent-pane-edited-side.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the card. diff --git a/webviews/test/agent-pane-follow-live.test.ts b/webviews/test/agent-pane-follow-live.test.ts index ae83f24a1001..b59b4be268ed 100644 --- a/webviews/test/agent-pane-follow-live.test.ts +++ b/webviews/test/agent-pane-follow-live.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-follow-live.test.ts", async () => { setDefaultTimeout(120_000); @@ -124,6 +125,7 @@ await requireBrowserLane("agent-pane-follow-live.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); for (const [name, engine] of engines) browsers.set(name, await engine.launch({ headless: true })); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-harness-catalog.test.ts b/webviews/test/agent-pane-harness-catalog.test.ts index 6aba9d99d43f..0b875e8820d3 100644 --- a/webviews/test/agent-pane-harness-catalog.test.ts +++ b/webviews/test/agent-pane-harness-catalog.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-harness-catalog.test.ts", async () => { setDefaultTimeout(120_000); @@ -163,6 +164,7 @@ await requireBrowserLane("agent-pane-harness-catalog.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the page. diff --git a/webviews/test/agent-pane-history-paging.test.ts b/webviews/test/agent-pane-history-paging.test.ts index dc71dfee9817..b18b28da0d5b 100644 --- a/webviews/test/agent-pane-history-paging.test.ts +++ b/webviews/test/agent-pane-history-paging.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-history-paging.test.ts", async () => { setDefaultTimeout(240_000); @@ -158,6 +159,7 @@ await requireBrowserLane("agent-pane-history-paging.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-mode-keys.test.ts b/webviews/test/agent-pane-mode-keys.test.ts index 1d97cea23797..b36543988a75 100644 --- a/webviews/test/agent-pane-mode-keys.test.ts +++ b/webviews/test/agent-pane-mode-keys.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-mode-keys.test.ts", async () => { setDefaultTimeout(120_000); @@ -110,6 +111,7 @@ await requireBrowserLane("agent-pane-mode-keys.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the menu. diff --git a/webviews/test/agent-pane-peer-chat.test.ts b/webviews/test/agent-pane-peer-chat.test.ts index 40af11081d90..c5386959a7d4 100644 --- a/webviews/test/agent-pane-peer-chat.test.ts +++ b/webviews/test/agent-pane-peer-chat.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-peer-chat.test.ts", async () => { setDefaultTimeout(120_000); @@ -102,6 +103,7 @@ await requireBrowserLane("agent-pane-peer-chat.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // In CI the first load of the dev server can stall past 30 s while it transforms deps // (#20276's run 38480535894), as in agent-pane-replay. Load it here, again if it stalls, so diff --git a/webviews/test/agent-pane-plan.test.ts b/webviews/test/agent-pane-plan.test.ts index 4d6d4be6250b..00fcc14647cd 100644 --- a/webviews/test/agent-pane-plan.test.ts +++ b/webviews/test/agent-pane-plan.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-plan.test.ts", async () => { setDefaultTimeout(120_000); @@ -143,6 +144,7 @@ await requireBrowserLane("agent-pane-plan.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the checklist. diff --git a/webviews/test/agent-pane-pr-dock.test.ts b/webviews/test/agent-pane-pr-dock.test.ts index b205058ffb6f..f4f523ba5cd5 100644 --- a/webviews/test/agent-pane-pr-dock.test.ts +++ b/webviews/test/agent-pane-pr-dock.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-pr-dock.test.ts", async () => { setDefaultTimeout(120_000); @@ -132,6 +133,7 @@ await requireBrowserLane("agent-pane-pr-dock.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the bars. diff --git a/webviews/test/agent-pane-queue.test.ts b/webviews/test/agent-pane-queue.test.ts index de0e94b2c8f2..251a7ce0a5cd 100644 --- a/webviews/test/agent-pane-queue.test.ts +++ b/webviews/test/agent-pane-queue.test.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-queue.test.ts", async () => { setDefaultTimeout(120_000); @@ -142,6 +143,7 @@ await requireBrowserLane("agent-pane-queue.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-reading-place.test.ts b/webviews/test/agent-pane-reading-place.test.ts index 7ea4c85efc7d..3dcb786b2e0e 100644 --- a/webviews/test/agent-pane-reading-place.test.ts +++ b/webviews/test/agent-pane-reading-place.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-reading-place.test.ts", async () => { setDefaultTimeout(240_000); @@ -112,6 +113,7 @@ await requireBrowserLane("agent-pane-reading-place.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-replay.test.ts b/webviews/test/agent-pane-replay.test.ts index d5b27b7bd12f..575432e96699 100644 --- a/webviews/test/agent-pane-replay.test.ts +++ b/webviews/test/agent-pane-replay.test.ts @@ -16,6 +16,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; import { REPLAY_SESSION, replayFixtureEvents } from "../src/agent-session/acpmux/replay/replayFixture"; await requireBrowserLane("agent-pane-replay.test.ts", async () => { @@ -97,6 +98,7 @@ await requireBrowserLane("agent-pane-replay.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // In CI the first load of a cold dev server never showed the chat, while every later load // did within seconds. Load it here, again if it stalls, so the tests time only the replay; diff --git a/webviews/test/agent-pane-secret.test.ts b/webviews/test/agent-pane-secret.test.ts index 5faf3d90f33d..879c1d384138 100644 --- a/webviews/test/agent-pane-secret.test.ts +++ b/webviews/test/agent-pane-secret.test.ts @@ -13,6 +13,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-secret.test.ts", async () => { setDefaultTimeout(120_000); @@ -132,6 +133,7 @@ await requireBrowserLane("agent-pane-secret.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-seen.test.ts b/webviews/test/agent-pane-seen.test.ts index ebdbbdf2a466..d10aa0eb5037 100644 --- a/webviews/test/agent-pane-seen.test.ts +++ b/webviews/test/agent-pane-seen.test.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-seen.test.ts", async () => { setDefaultTimeout(120_000); @@ -93,6 +94,7 @@ await requireBrowserLane("agent-pane-seen.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-session-offer.test.ts b/webviews/test/agent-pane-session-offer.test.ts index 23d3dbafedff..cd28333eb67e 100644 --- a/webviews/test/agent-pane-session-offer.test.ts +++ b/webviews/test/agent-pane-session-offer.test.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-session-offer.test.ts", async () => { setDefaultTimeout(120_000); @@ -138,6 +139,7 @@ await requireBrowserLane("agent-pane-session-offer.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-side-question.test.ts b/webviews/test/agent-pane-side-question.test.ts index c8b094daf62c..e7c2cddf1c01 100644 --- a/webviews/test/agent-pane-side-question.test.ts +++ b/webviews/test/agent-pane-side-question.test.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; import { REPLAY_SESSION, replayFixtureEvents } from "../src/agent-session/acpmux/replay/replayFixture"; await requireBrowserLane("agent-pane-side-question.test.ts", async () => { @@ -91,6 +92,7 @@ await requireBrowserLane("agent-pane-side-question.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the tests time only the side question. diff --git a/webviews/test/agent-pane-soft-edges.test.ts b/webviews/test/agent-pane-soft-edges.test.ts index cc9e1990c1d1..9b6b85c980db 100644 --- a/webviews/test/agent-pane-soft-edges.test.ts +++ b/webviews/test/agent-pane-soft-edges.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-soft-edges.test.ts", async () => { setDefaultTimeout(120_000); @@ -114,6 +115,7 @@ await requireBrowserLane("agent-pane-soft-edges.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-status-marks.test.ts b/webviews/test/agent-pane-status-marks.test.ts index b03f74e7bb14..98bcc363d48c 100644 --- a/webviews/test/agent-pane-status-marks.test.ts +++ b/webviews/test/agent-pane-status-marks.test.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-status-marks.test.ts", async () => { setDefaultTimeout(120_000); @@ -87,6 +88,7 @@ await requireBrowserLane("agent-pane-status-marks.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-steer-split.test.ts b/webviews/test/agent-pane-steer-split.test.ts index 7e333c5f00bb..3cab51ffc5a8 100644 --- a/webviews/test/agent-pane-steer-split.test.ts +++ b/webviews/test/agent-pane-steer-split.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-steer-split.test.ts", async () => { setDefaultTimeout(120_000); @@ -106,6 +107,7 @@ await requireBrowserLane("agent-pane-steer-split.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI (agent-pane-replay.test.ts): load it here, // again if it stalls, so the test times only the transcript. diff --git a/webviews/test/agent-pane-steer.test.ts b/webviews/test/agent-pane-steer.test.ts index ce2fc5172ac9..1df1de8dd894 100644 --- a/webviews/test/agent-pane-steer.test.ts +++ b/webviews/test/agent-pane-steer.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-steer.test.ts", async () => { setDefaultTimeout(120_000); @@ -157,6 +158,7 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-step-rows.test.ts b/webviews/test/agent-pane-step-rows.test.ts index c417e660ea50..33c73a33bbda 100644 --- a/webviews/test/agent-pane-step-rows.test.ts +++ b/webviews/test/agent-pane-step-rows.test.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-step-rows.test.ts", async () => { setDefaultTimeout(120_000); @@ -139,6 +140,7 @@ await requireBrowserLane("agent-pane-step-rows.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI: load it here, again if it stalls. for (let attempt = 1; ; attempt++) { diff --git a/webviews/test/agent-pane-turn-footer.test.ts b/webviews/test/agent-pane-turn-footer.test.ts index b7c062dad432..11fa45d3567d 100644 --- a/webviews/test/agent-pane-turn-footer.test.ts +++ b/webviews/test/agent-pane-turn-footer.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-turn-footer.test.ts", async () => { setDefaultTimeout(120_000); @@ -117,6 +118,7 @@ await requireBrowserLane("agent-pane-turn-footer.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); // A cold dev server's first load can stall in CI: load it here, again if it stalls. for (let attempt = 1; ; attempt++) { diff --git a/webviews/test/agent-pane-unknown-harness.test.ts b/webviews/test/agent-pane-unknown-harness.test.ts index 099f4d59cf7d..f9c0470aa1e0 100644 --- a/webviews/test/agent-pane-unknown-harness.test.ts +++ b/webviews/test/agent-pane-unknown-harness.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-unknown-harness.test.ts", async () => { setDefaultTimeout(120_000); @@ -155,6 +156,7 @@ await requireBrowserLane("agent-pane-unknown-harness.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/agent-pane-unsent-draft.test.ts b/webviews/test/agent-pane-unsent-draft.test.ts index fdb163e4e4d1..a436a46e65fd 100644 --- a/webviews/test/agent-pane-unsent-draft.test.ts +++ b/webviews/test/agent-pane-unsent-draft.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("agent-pane-unsent-draft.test.ts", async () => { setDefaultTimeout(120_000); @@ -33,6 +34,7 @@ await requireBrowserLane("agent-pane-unsent-draft.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/changes-address-thread.test.ts b/webviews/test/changes-address-thread.test.ts index 27ef2cfcd470..a23ff2290d06 100644 --- a/webviews/test/changes-address-thread.test.ts +++ b/webviews/test/changes-address-thread.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("changes-address-thread.test.ts", async () => { setDefaultTimeout(120_000); @@ -29,6 +30,7 @@ await requireBrowserLane("changes-address-thread.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the measured page, as composer-mention.test.ts does: the // first load can trigger Vite's dependency optimizer and reload its context mid-compile. diff --git a/webviews/test/changes-review-threads.test.ts b/webviews/test/changes-review-threads.test.ts index 4e09c92f7beb..d90c7572a608 100644 --- a/webviews/test/changes-review-threads.test.ts +++ b/webviews/test/changes-review-threads.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("changes-review-threads.test.ts", async () => { setDefaultTimeout(120_000); @@ -29,6 +30,7 @@ await requireBrowserLane("changes-review-threads.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the measured page, as composer-mention.test.ts does: the // first load can trigger Vite's dependency optimizer and reload its context mid-compile. diff --git a/webviews/test/changes-stage-hunk-keys.test.ts b/webviews/test/changes-stage-hunk-keys.test.ts index e81a6cd1932a..0082314bf0e7 100644 --- a/webviews/test/changes-stage-hunk-keys.test.ts +++ b/webviews/test/changes-stage-hunk-keys.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("changes-stage-hunk-keys.test.ts", async () => { setDefaultTimeout(120_000); @@ -28,6 +29,7 @@ await requireBrowserLane("changes-stage-hunk-keys.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the measured page, as composer-mention.test.ts does: the // first load can trigger Vite's dependency optimizer and reload its context mid-compile. diff --git a/webviews/test/chat-find.test.ts b/webviews/test/chat-find.test.ts index 910dabb1931b..2eb829107d4d 100644 --- a/webviews/test/chat-find.test.ts +++ b/webviews/test/chat-find.test.ts @@ -1,3 +1,4 @@ +// cmux-web-test-timeout: 240 // Find in Chat on the real agent pane (src/agent-session/acpmux, `?mock`: the in-page mock daemon // and its seeded worked turn), driven the way the app drives it: the `chat.find` page action that // Find (Cmd-F) sends to an agent pane, then the bar's own keys. @@ -14,6 +15,7 @@ import net from "node:net"; import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("chat-find.test.ts", async () => { setDefaultTimeout(120_000); @@ -50,8 +52,9 @@ await requireBrowserLane("chat-find.test.ts", async () => { server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); + await warmDevServer(base, "/?mock"); browser = await chromium.launch({ headless: true }); - }, 60_000); + }, 240_000); afterAll(async () => { await browser?.close(); diff --git a/webviews/test/chat-palette-actions.test.ts b/webviews/test/chat-palette-actions.test.ts index 8c344f106b4d..db9b865d8e2f 100644 --- a/webviews/test/chat-palette-actions.test.ts +++ b/webviews/test/chat-palette-actions.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("chat-palette-actions.test.ts", async () => { setDefaultTimeout(120_000); @@ -32,6 +33,7 @@ await requireBrowserLane("chat-palette-actions.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the test pages. The first load can trigger Vite's // dependency optimizer and reload its context while the real fixture is compiling; doing it diff --git a/webviews/test/code-block-highlight-height.test.ts b/webviews/test/code-block-highlight-height.test.ts index 2708b434d0f2..ff86cc8398e9 100644 --- a/webviews/test/code-block-highlight-height.test.ts +++ b/webviews/test/code-block-highlight-height.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("code-block-highlight-height.test.ts", async () => { setDefaultTimeout(120_000); @@ -36,6 +37,7 @@ await requireBrowserLane("code-block-highlight-height.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // The first load can run Vite's dependency optimizer and reload mid-compile. const warm = await browser.newPage({ viewport: { width: 760, height: 560 } }); diff --git a/webviews/test/composer-capture.test.ts b/webviews/test/composer-capture.test.ts index 41ec353b4fe3..81ab3006debf 100644 --- a/webviews/test/composer-capture.test.ts +++ b/webviews/test/composer-capture.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("composer-capture.test.ts", async () => { setDefaultTimeout(120_000); @@ -33,6 +34,7 @@ await requireBrowserLane("composer-capture.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/composer-edges.test.ts b/webviews/test/composer-edges.test.ts index 3d2b9d3a9307..6b121cbe64b3 100644 --- a/webviews/test/composer-edges.test.ts +++ b/webviews/test/composer-edges.test.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; type Edge = { shadows: { inset: boolean; alpha: number }[]; border: { width: number; alpha: number } }; @@ -90,6 +91,7 @@ await requireBrowserLane("composer-edges.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/composer-history.test.ts b/webviews/test/composer-history.test.ts index f7698700c91e..0ea50327a956 100644 --- a/webviews/test/composer-history.test.ts +++ b/webviews/test/composer-history.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("composer-history.test.ts", async () => { setDefaultTimeout(120_000); @@ -32,6 +33,7 @@ await requireBrowserLane("composer-history.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the test pages. The first load can trigger Vite's // dependency optimizer and reload its context while the real fixture is compiling; doing it diff --git a/webviews/test/composer-location-side.test.ts b/webviews/test/composer-location-side.test.ts index 224afa3dc4d3..b71bd89718cd 100644 --- a/webviews/test/composer-location-side.test.ts +++ b/webviews/test/composer-location-side.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("composer-location-side.test.ts", async () => { setDefaultTimeout(120_000); @@ -30,6 +31,7 @@ await requireBrowserLane("composer-location-side.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the measured page, as composer-mention.test.ts does: the // first load can trigger Vite's dependency optimizer and reload its context mid-compile. diff --git a/webviews/test/composer-location-strip.test.ts b/webviews/test/composer-location-strip.test.ts index 9ab1735c4acb..aae0ab842967 100644 --- a/webviews/test/composer-location-strip.test.ts +++ b/webviews/test/composer-location-strip.test.ts @@ -17,6 +17,7 @@ import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { PNG } from "pngjs"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; type Rect = { left: number; right: number; top: number; bottom: number; height: number }; @@ -43,6 +44,7 @@ await requireBrowserLane("composer-location-strip.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the stage once: the first load can run Vite's dependency optimizer and reload mid-compile. const warm = await browser.newPage({ viewport: { width: 720, height: 400 } }); diff --git a/webviews/test/composer-mention.test.ts b/webviews/test/composer-mention.test.ts index 4ce42e4777e0..bdd47ead27ba 100644 --- a/webviews/test/composer-mention.test.ts +++ b/webviews/test/composer-mention.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("composer-mention.test.ts", async () => { setDefaultTimeout(120_000); @@ -32,6 +33,7 @@ await requireBrowserLane("composer-mention.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the test pages. The first load can trigger Vite's // dependency optimizer and reload its context while the real fixture is compiling; doing it diff --git a/webviews/test/composer-rim-hairline.test.ts b/webviews/test/composer-rim-hairline.test.ts index 488f833a3825..b59df7d36a6c 100644 --- a/webviews/test/composer-rim-hairline.test.ts +++ b/webviews/test/composer-rim-hairline.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; type Inset = { spread: number; alpha: number }; @@ -38,6 +39,7 @@ await requireBrowserLane("composer-rim-hairline.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/composer-rim.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/composer-row-layout.test.ts b/webviews/test/composer-row-layout.test.ts index 5f4003f11768..2946a35f5951 100644 --- a/webviews/test/composer-row-layout.test.ts +++ b/webviews/test/composer-row-layout.test.ts @@ -19,6 +19,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; type Rect = { left: number; right: number; top: number; bottom: number; width: number }; type Row = { @@ -166,6 +167,7 @@ await requireBrowserLane("composer-row-layout.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the stage once: the first load can run Vite's dependency optimizer and reload mid-compile. const warm = await browser.newPage({ viewport: { width: 720, height: 300 } }); diff --git a/webviews/test/composer-search-files-key.test.ts b/webviews/test/composer-search-files-key.test.ts index 1fb71f1da281..c87593c1de76 100644 --- a/webviews/test/composer-search-files-key.test.ts +++ b/webviews/test/composer-search-files-key.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("composer-search-files-key.test.ts", async () => { setDefaultTimeout(120_000); @@ -29,6 +30,7 @@ await requireBrowserLane("composer-search-files-key.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/composer-send-stop.test.ts b/webviews/test/composer-send-stop.test.ts index 1acccd9919b8..2c3740da7bfc 100644 --- a/webviews/test/composer-send-stop.test.ts +++ b/webviews/test/composer-send-stop.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("composer-send-stop.test.ts", async () => { setDefaultTimeout(120_000); @@ -31,6 +32,7 @@ await requireBrowserLane("composer-send-stop.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // One throwaway load of the same stage first. Cold, beside the parallel test batch, compiling // the stage (every gallery entry file, then the dependency optimizer, which reloads the page diff --git a/webviews/test/composer-turn-cycle.test.ts b/webviews/test/composer-turn-cycle.test.ts index 35711fca682d..57e44c493f74 100644 --- a/webviews/test/composer-turn-cycle.test.ts +++ b/webviews/test/composer-turn-cycle.test.ts @@ -15,6 +15,7 @@ import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { TURN_CYCLE } from "../src/agent-session/acpmux/composerTurnCycle.gallery"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("composer-turn-cycle.test.ts", async () => { setDefaultTimeout(180_000); @@ -39,6 +40,7 @@ await requireBrowserLane("composer-turn-cycle.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the stage once: the first load can run Vite's dependency optimizer and reload mid-compile. const warm = await browser.newPage({ viewport: { width: 720, height: 520 } }); diff --git a/webviews/test/desktop-feel.test.ts b/webviews/test/desktop-feel.test.ts index bb9ceb0d1060..14aa2be0e342 100644 --- a/webviews/test/desktop-feel.test.ts +++ b/webviews/test/desktop-feel.test.ts @@ -1,3 +1,4 @@ +// cmux-web-test-timeout: 240 // DESKTOP-FEEL (R139) in real engines (headless Chromium and WebKit from Playwright), on the diff // viewer (test/browser/desktop-feel.html) and the markdown editor (/markdown on the dev server): // - content selects: a diff line's code, a file bar's path, markdown prose and a code block; @@ -18,6 +19,7 @@ import os from "node:os"; import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("desktop-feel.test.ts", async () => { setDefaultTimeout(120_000); @@ -94,7 +96,8 @@ const fenced = "code block text"; server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); - }, 60_000); + await warmDevServer(base, "/test/browser/desktop-feel.html"); + }, 240_000); afterAll(() => { server?.kill(); diff --git a/webviews/test/diff-toolbar-narrow.test.ts b/webviews/test/diff-toolbar-narrow.test.ts index 70f2deb00993..a13b6f550255 100644 --- a/webviews/test/diff-toolbar-narrow.test.ts +++ b/webviews/test/diff-toolbar-narrow.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("diff-toolbar-narrow.test.ts", async () => { setDefaultTimeout(120_000); @@ -30,6 +31,7 @@ await requireBrowserLane("diff-toolbar-narrow.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/diff-toolbar-narrow.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/diff-virtualization.test.ts b/webviews/test/diff-virtualization.test.ts index f53399c404ba..043827bda1cf 100644 --- a/webviews/test/diff-virtualization.test.ts +++ b/webviews/test/diff-virtualization.test.ts @@ -1,3 +1,4 @@ +// cmux-web-test-timeout: 240 // The real diff viewer in real engines (headless Chromium and WebKit from // Playwright), over a 2,000-file patch with two 20,000-line files // (test/browser/diff-virtualization.html, served by the webviews dev server): @@ -19,6 +20,7 @@ import net from "node:net"; import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("diff-virtualization.test.ts", async () => { setDefaultTimeout(120_000); @@ -79,7 +81,8 @@ await requireBrowserLane("diff-virtualization.test.ts", async () => { server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); - }, 60_000); + await warmDevServer(base, "/test/browser/diff-virtualization.html"); + }, 240_000); afterAll(() => { server?.kill(); @@ -196,6 +199,32 @@ await requireBrowserLane("diff-virtualization.test.ts", async () => { ); if (point != null) { await page.waitForTimeout(POINTER_SETTLE_MS); + // The virtualized tree can lay out again while the pointer settles (a loaded WebKit on + // CI): click only if the row is still under the point, else find it again. + const still = await page.evaluate( + ([name, x, y]) => { + let hit = document.elementFromPoint(Number(x), Number(y)); + while (hit?.shadowRoot != null) { + const inner = hit.shadowRoot.elementFromPoint(Number(x), Number(y)); + if (inner == null || inner === hit) break; + hit = inner; + } + // Climb out of any shadow root inside the row (an icon element) to the row itself. + for (let node: Element | null = hit; node != null;) { + if (node.getAttribute("data-type") === "item") { + return node.getAttribute("data-item-path")?.endsWith(`/${name}`) === true; + } + const root = node.getRootNode(); + node = node.parentElement ?? (root instanceof ShadowRoot ? root.host : null); + } + return false; + }, + [name, point.x, point.y] as const, + ); + if (!still) { + await page.waitForTimeout(100); + continue; + } await page.mouse.click(point.x, point.y); await page.waitForTimeout(900); return; diff --git a/webviews/test/editor-page.test.ts b/webviews/test/editor-page.test.ts index 56b4d9711ece..c24a6ec0a791 100644 --- a/webviews/test/editor-page.test.ts +++ b/webviews/test/editor-page.test.ts @@ -13,6 +13,9 @@ // The engines come from `playwright install chromium webkit`. Where they are not installed (the // Linux CI image), the browser cases are skipped and say so. EDITOR_SCREENSHOTS= saves // screenshots there (editor-*.png). +// +// 24 browser tests in two engines take about 115 s on the CI runner (run-webviews-tests.sh): +// cmux-web-test-timeout: 240 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs"; @@ -21,6 +24,7 @@ import os from "node:os"; import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("editor-page.test.ts", async () => { setDefaultTimeout(120_000); @@ -88,7 +92,8 @@ await requireBrowserLane("editor-page.test.ts", async () => { server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); - }, 60_000); + await warmDevServer(base, "/editor"); + }, 240_000); afterAll(() => { server?.kill(); diff --git a/webviews/test/files-panel-coverage.test.ts b/webviews/test/files-panel-coverage.test.ts index b35d5e4deed8..be3a14cefcbc 100644 --- a/webviews/test/files-panel-coverage.test.ts +++ b/webviews/test/files-panel-coverage.test.ts @@ -1,3 +1,4 @@ +// cmux-web-test-timeout: 240 // The files panel motion never shows a blank strip (files-panel-motion.ts), in headless Chromium // and WebKit on the latency harness's diff page: at every step of an open, a close and a toggle in // the middle of either slide, each point of the content's width is covered by the diff column, the @@ -15,6 +16,7 @@ import net from "node:net"; import path from "node:path"; import { chromium, webkit, type BrowserType } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("files-panel-coverage.test.ts", async () => { setDefaultTimeout(120_000); @@ -63,7 +65,8 @@ await requireBrowserLane("files-panel-coverage.test.ts", async () => { server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); - }, 60_000); + await warmDevServer(base, "/test/latency/diff.html"); + }, 240_000); afterAll(() => { server?.kill(); diff --git a/webviews/test/ghost-button-balance.test.ts b/webviews/test/ghost-button-balance.test.ts index 7bb26dc111e6..6463ffd762ba 100644 --- a/webviews/test/ghost-button-balance.test.ts +++ b/webviews/test/ghost-button-balance.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("ghost-button-balance.test.ts", async () => { setDefaultTimeout(120_000); @@ -46,6 +47,7 @@ await requireBrowserLane("ghost-button-balance.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // The first load can run Vite's dependency optimizer and reload mid-compile (composer-location-side). const warm = await browser.newPage({ viewport: { width: 900, height: 600 } }); diff --git a/webviews/test/history-closed-rows.test.ts b/webviews/test/history-closed-rows.test.ts index 51241078b444..0d1d1e3ab2de 100644 --- a/webviews/test/history-closed-rows.test.ts +++ b/webviews/test/history-closed-rows.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("history-closed-rows.test.ts", async () => { setDefaultTimeout(120_000); @@ -30,6 +31,7 @@ await requireBrowserLane("history-closed-rows.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/ime-composition-keys.test.ts b/webviews/test/ime-composition-keys.test.ts index 604661433b3d..b8d19ab0fe03 100644 --- a/webviews/test/ime-composition-keys.test.ts +++ b/webviews/test/ime-composition-keys.test.ts @@ -1,3 +1,4 @@ +// cmux-web-test-timeout: 240 // IME-KEYS (cx-dd5b4) in real engines (headless Chromium and WebKit from Playwright), on a real // page: the History page's dev entry (/history/?mock, the in-memory mock provider). // @@ -17,6 +18,7 @@ import net from "node:net"; import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("ime-composition-keys.test.ts", async () => { setDefaultTimeout(120_000); @@ -56,7 +58,8 @@ await requireBrowserLane("ime-composition-keys.test.ts", async () => { server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); - }, 60_000); + await warmDevServer(base, "/history/?mock"); + }, 240_000); afterAll(() => { server?.kill(); diff --git a/webviews/test/instructions-notice.test.ts b/webviews/test/instructions-notice.test.ts index 6d2764c940ec..d14ac28dab44 100644 --- a/webviews/test/instructions-notice.test.ts +++ b/webviews/test/instructions-notice.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("instructions-notice.test.ts", async () => { setDefaultTimeout(120_000); @@ -29,6 +30,7 @@ await requireBrowserLane("instructions-notice.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/menu-focus.test.ts b/webviews/test/menu-focus.test.ts index acd3ef2cd6bc..20aab9cc39c8 100644 --- a/webviews/test/menu-focus.test.ts +++ b/webviews/test/menu-focus.test.ts @@ -1,3 +1,4 @@ +// cmux-web-test-timeout: 240 // An open menu never draws a focus ring around itself, and its items are at full ink (Leo // 2026-10-11, the New Tab "…" menu: "hella ugly"). The popup takes focus when it opens so its keys // work, and the global keyboard ring (desktop.css) drew a thick square around the whole menu, while @@ -14,6 +15,7 @@ import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { loadEntries } from "../scripts/gallery/entries"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("menu-focus.test.ts", async () => { setDefaultTimeout(180_000); @@ -62,6 +64,7 @@ await requireBrowserLane("menu-focus.test.ts", async () => { server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); + await warmDevServer(base, "/gallery/frame.html"); browser = await engine!.launch({ headless: true }); // The first loads compile the gallery and can run Vite's dependency optimizer, which reloads // the page: with a cold server, whichever test came first timed out in `goto` or lost its page diff --git a/webviews/test/new-chat-effort-chip.test.ts b/webviews/test/new-chat-effort-chip.test.ts index 2d135ffa76c3..4ba80a0a80f3 100644 --- a/webviews/test/new-chat-effort-chip.test.ts +++ b/webviews/test/new-chat-effort-chip.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("new-chat-effort-chip.test.ts", async () => { setDefaultTimeout(120_000); @@ -31,6 +32,7 @@ await requireBrowserLane("new-chat-effort-chip.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/new-tab-add-project-layer.test.ts b/webviews/test/new-tab-add-project-layer.test.ts index 037dd8e043ec..d75f5a696aac 100644 --- a/webviews/test/new-tab-add-project-layer.test.ts +++ b/webviews/test/new-tab-add-project-layer.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("new-tab-add-project-layer.test.ts", async () => { setDefaultTimeout(120_000); @@ -28,6 +29,7 @@ await requireBrowserLane("new-tab-add-project-layer.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/new-tab-list-motion.test.ts b/webviews/test/new-tab-list-motion.test.ts index 855ccd1636eb..80e3b611aa14 100644 --- a/webviews/test/new-tab-list-motion.test.ts +++ b/webviews/test/new-tab-list-motion.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("new-tab-list-motion.test.ts", async () => { setDefaultTimeout(120_000); @@ -45,6 +46,7 @@ await requireBrowserLane("new-tab-list-motion.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // The first load can run Vite's dependency optimizer and reload mid-compile. const warm = await browser.newPage({ viewport: { width: 900, height: 700 } }); diff --git a/webviews/test/new-tab-omnibar-pointer.test.ts b/webviews/test/new-tab-omnibar-pointer.test.ts index 237afca5e087..8e6c44d0a665 100644 --- a/webviews/test/new-tab-omnibar-pointer.test.ts +++ b/webviews/test/new-tab-omnibar-pointer.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("new-tab-omnibar-pointer.test.ts", async () => { setDefaultTimeout(120_000); @@ -30,6 +31,7 @@ await requireBrowserLane("new-tab-omnibar-pointer.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/new-tab-picker.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/new-tab-picker-clip.test.ts b/webviews/test/new-tab-picker-clip.test.ts index 17771ac38bcd..685837e4d159 100644 --- a/webviews/test/new-tab-picker-clip.test.ts +++ b/webviews/test/new-tab-picker-clip.test.ts @@ -10,6 +10,7 @@ import { chromium, type Browser } from "playwright"; import { MODEL_PICKER_MIN_HEIGHT } from "../src/agent-session/acpmux/modelPickerLayout"; import { UI_ANCHOR_GAP } from "../src/ui/anchor"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("new-tab-picker-clip.test.ts", async () => { setDefaultTimeout(120_000); @@ -37,6 +38,7 @@ await requireBrowserLane("new-tab-picker-clip.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/new-tab-picker.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/new-tab-scroll.test.ts b/webviews/test/new-tab-scroll.test.ts index 4568a7d33580..d5d730ecb64d 100644 --- a/webviews/test/new-tab-scroll.test.ts +++ b/webviews/test/new-tab-scroll.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("new-tab-scroll.test.ts", async () => { setDefaultTimeout(120_000); @@ -46,6 +47,7 @@ await requireBrowserLane("new-tab-scroll.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // The first load can run Vite's dependency optimizer and reload mid-compile. const warm = await browser.newPage({ viewport: { width: 900, height: 600 } }); diff --git a/webviews/test/new-tab-typing.test.ts b/webviews/test/new-tab-typing.test.ts index 9b03f152958e..66d06ff030d1 100644 --- a/webviews/test/new-tab-typing.test.ts +++ b/webviews/test/new-tab-typing.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("new-tab-typing.test.ts", async () => { setDefaultTimeout(120_000); @@ -31,6 +32,7 @@ await requireBrowserLane("new-tab-typing.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/new-tab-typing.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/newtab-quiet-focus.test.ts b/webviews/test/newtab-quiet-focus.test.ts index fdfd8b89e1c5..95a5f19f33a5 100644 --- a/webviews/test/newtab-quiet-focus.test.ts +++ b/webviews/test/newtab-quiet-focus.test.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; type Rim = { spread: number; rgba: [number, number, number, number] }; @@ -36,6 +37,7 @@ await requireBrowserLane("newtab-quiet-focus.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/composer-rim.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/permission-panel-buttons.test.ts b/webviews/test/permission-panel-buttons.test.ts index c6b7fda47f97..35d94adf4f53 100644 --- a/webviews/test/permission-panel-buttons.test.ts +++ b/webviews/test/permission-panel-buttons.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("permission-panel-buttons.test.ts", async () => { setDefaultTimeout(120_000); @@ -31,6 +32,7 @@ await requireBrowserLane("permission-panel-buttons.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/permission-panel.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/popup-escape.test.ts b/webviews/test/popup-escape.test.ts index dd859d30116f..a64065ffae7b 100644 --- a/webviews/test/popup-escape.test.ts +++ b/webviews/test/popup-escape.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("popup-escape.test.ts", async () => { setDefaultTimeout(120_000); @@ -87,6 +88,7 @@ await requireBrowserLane("popup-escape.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); await (await stage("agent-pane.composer", "idle", { width: 900, height: 700 })).close(); }); diff --git a/webviews/test/popup-reduce-motion.test.ts b/webviews/test/popup-reduce-motion.test.ts index a8efd075872f..09b80b716557 100644 --- a/webviews/test/popup-reduce-motion.test.ts +++ b/webviews/test/popup-reduce-motion.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("popup-reduce-motion.test.ts", async () => { setDefaultTimeout(120_000); @@ -45,6 +46,7 @@ await requireBrowserLane("popup-reduce-motion.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // The first load can run Vite's dependency optimizer and reload mid-compile. const warm = await browser.newPage(); diff --git a/webviews/test/secret-card.test.ts b/webviews/test/secret-card.test.ts index 67634876bdb5..d060ac21e3d8 100644 --- a/webviews/test/secret-card.test.ts +++ b/webviews/test/secret-card.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("secret-card.test.ts", async () => { setDefaultTimeout(120_000); @@ -32,6 +33,7 @@ await requireBrowserLane("secret-card.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the test pages. The first load can trigger Vite's // dependency optimizer and reload its context while the real fixture is compiling; doing it diff --git a/webviews/test/settings-scroll.test.ts b/webviews/test/settings-scroll.test.ts index 2038b93fad9f..7688b1a0d4f6 100644 --- a/webviews/test/settings-scroll.test.ts +++ b/webviews/test/settings-scroll.test.ts @@ -1,3 +1,4 @@ +// cmux-web-test-timeout: 240 // The Settings page scrolls in one place (cx-jh7kq), in real engines (headless Chromium and WebKit // from Playwright) on the settings dev server with the in-memory mock (`?mock`): // - the content column is the page's only scroller: the document never scrolls, also while a @@ -15,6 +16,7 @@ import net from "node:net"; import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("settings-scroll.test.ts", async () => { setDefaultTimeout(120_000); @@ -51,16 +53,18 @@ await requireBrowserLane("settings-scroll.test.ts", async () => { ); base = `http://127.0.0.1:${port}`; const deadline = Date.now() + 60_000; - while (Date.now() < deadline) { + let listening = false; + while (!listening && Date.now() < deadline) { try { - if ((await fetch(`${base}/`)).ok) return; + listening = (await fetch(`${base}/`)).ok; } catch { // Not listening yet. } - await new Promise((resolve) => setTimeout(resolve, 250)); + if (!listening) await new Promise((resolve) => setTimeout(resolve, 250)); } - throw new Error("settings dev server did not start"); - }); + if (!listening) throw new Error("settings dev server did not start"); + await warmDevServer(base, "/"); + }, 180_000); afterAll(() => { if (server?.pid) process.kill(-server.pid, "SIGTERM"); diff --git a/webviews/test/sources-changes.test.ts b/webviews/test/sources-changes.test.ts index 64391482449f..b54d3a3859df 100644 --- a/webviews/test/sources-changes.test.ts +++ b/webviews/test/sources-changes.test.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("sources-changes.test.ts", async () => { setDefaultTimeout(120_000); @@ -47,6 +48,7 @@ await requireBrowserLane("sources-changes.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/status-glyphs.test.ts b/webviews/test/status-glyphs.test.ts index 30ffb5eade19..053a605610d8 100644 --- a/webviews/test/status-glyphs.test.ts +++ b/webviews/test/status-glyphs.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("status-glyphs.test.ts", async () => { setDefaultTimeout(120_000); @@ -35,6 +36,7 @@ await requireBrowserLane("status-glyphs.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); diff --git a/webviews/test/suggestion-lists-float.test.ts b/webviews/test/suggestion-lists-float.test.ts index 3a9c0ee9602c..50802d37eae4 100644 --- a/webviews/test/suggestion-lists-float.test.ts +++ b/webviews/test/suggestion-lists-float.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("suggestion-lists-float.test.ts", async () => { setDefaultTimeout(120_000); @@ -52,6 +53,7 @@ await requireBrowserLane("suggestion-lists-float.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); await (await stage("agent-pane.composer", "idle")).close(); }); diff --git a/webviews/test/support/warmDevServer.ts b/webviews/test/support/warmDevServer.ts new file mode 100644 index 000000000000..c946a71504dc --- /dev/null +++ b/webviews/test/support/warmDevServer.ts @@ -0,0 +1,72 @@ +// Warms a Vite dev server for a page before a test loads it. +// +// `vp dev` prints its port before it has transformed a module, and it transforms each one on its +// first request. A test's first `page.goto` therefore paid for the whole module graph: beside a +// second dev server and browser on the CI runner that took more than the 30 s navigation timeout +// (chat-find, text-field-focus), while the next load of the same page took 2 s. This requests the +// page and every module it imports, as the browser will, so the server caches each transform and +// the test's own timeouts measure the page, not the cold server. +// +// Same-origin paths are followed, absolute (`/src/…`, `/node_modules/.vite/deps/…`, `/@vite/client`) +// or relative to the file that names them (an entry page's `src="./harness.tsx"`); bare package +// names never reach the browser in dev, Vite rewrites them. Lazy imports are followed too: the +// gallery frame starts every `*.gallery.tsx` load on its first paint (src/gallery/entryStore.ts), +// about 2,300 modules, 35 s cold on an idle 96-core builder, so the browser pays for them anyway. + +import type { ViteDevServer } from "vite"; + +const IMPORT = /(?:\bfrom\s*|\bimport\s*\(?\s*)["']((?:\.{1,2})?\/[^"'\s]+)["']/g; +const SCRIPT = /]*\bsrc=["']((?:\.{1,2})?\/[^"']+)["']/g; + +/** Requests `page` on `base` and every module it imports; returns how many modules it fetched. */ +export async function warmDevServer(base: string, page: string, { timeoutMs = 100_000, limit = 4_000, jobs = 8 } = {}) { + const deadline = Date.now() + timeoutMs; + const seen = new Set(); + const queue: string[] = []; + const follow = (text: string, from: string, pattern: RegExp) => { + for (const match of text.matchAll(pattern)) { + const url = new URL(match[1], new URL(from, base)); + const path = url.pathname + url.search; + if (url.origin === new URL(base).origin && !seen.has(path) && seen.size < limit) { + seen.add(path); + queue.push(path); + } + } + }; + const fetchText = async (path: string) => { + const left = deadline - Date.now(); + if (left <= 0) throw new Error(`warmDevServer: ${page} still cold after ${timeoutMs} ms (${seen.size} modules)`); + const response = await fetch(new URL(path, base), { signal: AbortSignal.timeout(left) }); + return response.ok ? response.text() : ""; + }; + + const html = await fetchText(page); + follow(html, page, SCRIPT); + // Inline module scripts: Vite's HTML transform adds `/@vite/client` and the React preamble. + follow(html, page, IMPORT); + + const running = new Set>(); + let failure: unknown; + while (queue.length > 0 || running.size > 0) { + while (queue.length > 0 && running.size < jobs) { + const path = queue.shift()!; + const visit: Promise = fetchText(path) + .then((code) => follow(code, path, IMPORT)) + .catch((error: unknown) => { + failure ??= error; + }) + .finally(() => running.delete(visit)); + running.add(visit); + } + await Promise.race(running); + // The first failure ends the warm-up; the visits still running end on the same deadline. + if (failure !== undefined) throw failure; + } + return seen.size; +} + +/** `warmDevServer` for a server started in-process (`createServer(…)` then `listen()`). */ +export function warmViteServer(vite: ViteDevServer, page: string, options?: Parameters[2]) { + const { port } = vite.httpServer!.address() as { port: number }; + return warmDevServer(`http://127.0.0.1:${port}`, page, options); +} diff --git a/webviews/test/text-field-focus.test.ts b/webviews/test/text-field-focus.test.ts index a79ce5b9bc6a..9b1ab071b95e 100644 --- a/webviews/test/text-field-focus.test.ts +++ b/webviews/test/text-field-focus.test.ts @@ -13,6 +13,10 @@ // // The engine comes from `playwright install webkit`; where it is not installed, the cases are // skipped and say so. +// +// Starting Vite, warming the gallery (every entry, about 2,300 modules) and 22 page loads took +// about 115 s on the CI runner (run-webviews-tests.sh): +// cmux-web-test-timeout: 240 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawn, type ChildProcess } from "node:child_process"; import net from "node:net"; @@ -21,6 +25,7 @@ import fs from "node:fs"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { loadEntries } from "../scripts/gallery/entries"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("text-field-focus.test.ts", async () => { setDefaultTimeout(180_000); @@ -99,8 +104,9 @@ await requireBrowserLane("text-field-focus.test.ts", async () => { server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); + await warmDevServer(base, "/gallery/frame.html"); browser = await engine!.launch({ headless: true }); - }, 120_000); + }, 300_000); afterAll(async () => { await browser?.close(); diff --git a/webviews/test/transcript-fling.test.ts b/webviews/test/transcript-fling.test.ts index b005594728e4..cba795666842 100644 --- a/webviews/test/transcript-fling.test.ts +++ b/webviews/test/transcript-fling.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { webkit, type Browser } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("transcript-fling.test.ts", async () => { setDefaultTimeout(120_000); @@ -30,6 +31,7 @@ await requireBrowserLane("transcript-fling.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/transcript-fling.html"); browser = await webkit.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/transcript-follow.test.ts b/webviews/test/transcript-follow.test.ts index a06fc4fa779f..2c6610621f6d 100644 --- a/webviews/test/transcript-follow.test.ts +++ b/webviews/test/transcript-follow.test.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("transcript-follow.test.ts", async () => { setDefaultTimeout(120_000); @@ -29,6 +30,7 @@ await requireBrowserLane("transcript-follow.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/test/browser/transcript-follow.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { diff --git a/webviews/test/transcript-thought.test.ts b/webviews/test/transcript-thought.test.ts index 0936423982a7..a05d89a001b2 100644 --- a/webviews/test/transcript-thought.test.ts +++ b/webviews/test/transcript-thought.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("transcript-thought.test.ts", async () => { setDefaultTimeout(120_000); @@ -32,6 +33,7 @@ await requireBrowserLane("transcript-thought.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the test pages. The first load can trigger Vite's // dependency optimizer and reload its context while the real fixture is compiling; doing it diff --git a/webviews/test/turn-fork.test.ts b/webviews/test/turn-fork.test.ts index bd92e9723b31..944a106896ab 100644 --- a/webviews/test/turn-fork.test.ts +++ b/webviews/test/turn-fork.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("turn-fork.test.ts", async () => { setDefaultTimeout(120_000); @@ -32,6 +33,7 @@ await requireBrowserLane("turn-fork.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); // Warm the gallery stage once before the test pages. The first load can trigger Vite's // dependency optimizer and reload its context while the real fixture is compiling; doing it diff --git a/webviews/test/ui-a11y.test.ts b/webviews/test/ui-a11y.test.ts index 5f048203cd61..92f86770eba1 100644 --- a/webviews/test/ui-a11y.test.ts +++ b/webviews/test/ui-a11y.test.ts @@ -1,3 +1,4 @@ +// cmux-web-test-timeout: 240 // The ui wrapper and the migrated pages in real engines (headless Chromium and WebKit, Playwright), // keyboard only, left to right and right to left, with axe-core at each open state // (plans/cmux-next/a11y-foundation.md). Harness: test/browser/ui-a11y.html, served by the dev server. @@ -16,6 +17,7 @@ import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import markdownStrings from "../src/pages/markdown/generated/strings.json"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmDevServer } from "./support/warmDevServer"; await requireBrowserLane("ui-a11y.test.ts", async () => { setDefaultTimeout(120_000); @@ -67,7 +69,8 @@ await requireBrowserLane("ui-a11y.test.ts", async () => { server!.stderr!.on("data", onData); server!.on("exit", (code) => reject(new Error(`dev server exited ${code}`))); }); - }, 60_000); + await warmDevServer(base, "/test/browser/ui-a11y.html"); + }, 240_000); afterAll(() => { server?.kill(); diff --git a/webviews/test/worktree-creating.test.ts b/webviews/test/worktree-creating.test.ts index 2278d2b24267..e7535c7f86ee 100644 --- a/webviews/test/worktree-creating.test.ts +++ b/webviews/test/worktree-creating.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { chromium, type Browser } from "playwright"; import { playFailures } from "./support/galleryPlay"; import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("worktree-creating.test.ts", async () => { setDefaultTimeout(120_000); @@ -29,6 +30,7 @@ await requireBrowserLane("worktree-creating.test.ts", async () => { logLevel: "error", }); await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); browser = await chromium.launch({ headless: true }); }); afterAll(async () => { From 7e0897f7da0edbfbb7edb0d771b6e7f41eaec3fe Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:13:57 -0700 Subject: [PATCH 129/183] test: a plain command shows busy only after its threshold (cx-6so.11) (#20784) * test: a plain command shows busy only after its threshold (cx-6so.11) U3/U7: the daemon already publishes extra.busy {since_ms} on terminal records (terminal-activity-v1, command_activity.rs covers it) and StatusMapping.command applies status.inferCommandBusyAfter with one one-shot DemandTimer per command, but no client test held the threshold. This one drives a terminal record through the session stream into the tab hover card's shared indicator: off never shows, an old command shows busy, a young one shows nothing until its single recheck fires. Co-Authored-By: Claude Opus 5.5 * test: pin the busy threshold test against shared settings and slow runners (cx-6so.11) Review: another suite's settings apply can reset the global status behavior while the test awaits, the young command's start time was taken before slow AppKit setup, and the future-clock clamp went untested. Set the behavior synchronously with each read, compute each start time when the record is built, and make the young command a minute ahead by the daemon's clock (without the clamp it would never show). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../SharedIndicatorSurfacesTests.swift | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SharedIndicatorSurfacesTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SharedIndicatorSurfacesTests.swift index b0fec54068f4..41be0664dc7d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SharedIndicatorSurfacesTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SharedIndicatorSurfacesTests.swift @@ -19,11 +19,15 @@ struct SharedIndicatorSurfacesTests { /// `working` record, `extra.program_status`). A plain shell command no longer shows busy by /// default (cx-ag5.6.5, `status.inferCommandBusy`). func store(busy: Bool) throws -> DaemonStore { + try store(extra: busy ? #"{"program_status":[{"id":"","state":"working","app":"cargo","updated_seq":"1"}]}"# : "{}") + } + + /// A store with one terminal tab whose terminal record carries `extra`. + func store(extra: String) throws -> DaemonStore { let store = DaemonStore() let pane = PaneSnapshot(id: 1, tabs: [TabSnapshot(surface: 1, terminalResourceID: "term_7")]) let screen = ScreenSnapshot(id: 1, layout: .leaf(1), panes: [pane]) store.apply(snapshot: DaemonTree(workspaces: [WorkspaceSnapshot(id: 1, key: nil, name: "w", screens: [screen])])) - let extra = busy ? #"{"program_status":[{"id":"","state":"working","app":"cargo","updated_seq":"1"}]}"# : "{}" let line = #"{"type":"stream_item","item":{"kind":"snapshot","snapshot":{"terminals":[{"id":"term_7","extra":\#(extra)}],"extra":{"state":{}}}}}"# let item = try #require(SessionStreamItem.decode(Data(line.utf8))) store.session.apply(item, to: store.workspaces) @@ -60,6 +64,41 @@ struct SharedIndicatorSurfacesTests { #expect(shown(card) == (busy ? .working(progress: nil) : nil)) } + /// U3/U7 (cx-6so.11): with `status.inferCommandBusy` on, a plain shell command (the daemon's + /// `extra.busy.since_ms`, OSC 133 C..D) shows busy once it has run `inferCommandBusyAfter`; a + /// younger one shows nothing until its one-shot recheck fires (no polling), and with the setting + /// off it never shows. A daemon whose clock runs ahead counts from when this client saw the command. + @Test func aPlainCommandShowsBusyOnlyAfterItsThreshold() async throws { + let saved = DesignSettings.shared.statusBehavior + defer { DesignSettings.shared.statusBehavior = saved } + func tab(startedMsAgo age: Int64) throws -> TabModel { + let since = Int64(Date().timeIntervalSince1970 * 1000) - age + let extra = #"{"busy":{"since_ms":"\#(since)"}}"# + return try #require(try store(extra: extra).workspaces.first?.screens.first?.panes.first?.tabs.first) + } + // Sets the behavior and reads the indicator with no suspension between, so another suite's + // settings apply on the main actor cannot land in between. + func shown(_ tab: TabModel, infer: Bool, after: Double) -> StatusIndicatorState? { + DesignSettings.shared.statusBehavior.inferCommandBusy = infer + DesignSettings.shared.statusBehavior.inferCommandBusyAfter = after + let card = TabHoverCardView() + card.configure(.tab(TabItemMapping.shared.item(tab, fallbackTitle: "zsh"))) + return self.shown(card) + } + + #expect(try shown(tab(startedMsAgo: 60_000), infer: false, after: 3) == nil) + #expect(try shown(tab(startedMsAgo: 60_000), infer: true, after: 3) == .busy(progress: nil)) + + // A minute in the future by the daemon's clock: as young as when this client saw it. + let young = try tab(startedMsAgo: -60_000) + #expect(shown(young, infer: true, after: 1) == nil) + #expect(young.commandRecheck == 0) + let deadline = ContinuousClock.now + .seconds(10) + while young.commandRecheck == 0, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(50)) } + #expect(young.commandRecheck == 1) + #expect(shown(young, infer: true, after: 1) == .busy(progress: nil)) + } + @Test func homesAgentRowsShowTheirStateInTheIndicator() throws { let home = HomeFirstRunView(frame: .zero) home.setAgents([ From 53d739256c8a4cd6ff457e679231f7e92effee3c Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:18:51 -0700 Subject: [PATCH 130/183] agent pane: page the session list through the host's pane policy (cx-2lv1k) (#20752) * test: the pane policy passes session-list pages (watch limit, _acpmux/sessions limit/cursor) (red) Co-Authored-By: Claude Opus 5.5 * agent pane: page the session list through the host's pane policy (cx-2lv1k) Re-lands #20644 (reverted in #20717) with the host side it lacked: the Swift pane policy and its Rust mirror now pass `_acpmux/watch` `limit` and `_acpmux/sessions` `limit`/`cursor`. acpmux answers both from the same session summaries. The whole-list read uses watch without `limit`, which every host answers; remote panes never page. The browser test's stand-in daemon now refuses what policy.json refuses, and the policy's allowlist test scans sessionPages.ts. Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at 20cc5d268e8c - vp fmt webviews/src/pages/home-channels/cloudHome.ts Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38646861118 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- .../AcpmuxPaneMethods+Params.swift | 4 +- .../CmuxNextAgentPane/AcpmuxPaneMethods.swift | 3 + .../crates/cmux-agent-pane-policy/policy.json | 5 +- .../tests/cases/frames.json | 21 +- .../tests/cases/params.json | 32 +- .../tests/typescript.rs | 3 +- webviews/src/agent-session/acpmux/direct.ts | 48 ++- .../src/agent-session/acpmux/sessionPages.ts | 102 ++++++ .../test/agent-pane-session-pages.test.ts | 310 ++++++++++++++++++ 9 files changed, 509 insertions(+), 19 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/sessionPages.ts create mode 100644 webviews/test/agent-pane-session-pages.test.ts diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift index e93b9690e7a2..5ff4d33d67ec 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift @@ -14,7 +14,9 @@ nonisolated extension AcpmuxPaneMethods { "session/prompt": (["sessionId", "prompt", "_meta"], ["promptId", "steer", "interrupt", "context"]), "session/set_model": (["sessionId", "modelId"], []), "session/cancel": (["sessionId"], []), - "_acpmux/watch": (["enabled"], []), + // `limit`: the newest page of the session list, the rest by `_acpmux/sessions` `cursor` (cx-2lv1k). + "_acpmux/watch": (["enabled", "limit"], []), + "_acpmux/sessions": (["limit", "cursor"], []), // `maxBytes`: the most one page downloads, opening a chat or paging older turns in (cx-2lv1k). "_acpmux/events": (["sessionId", "afterSeq", "beforeSeq", "limit", "maxBytes", "kinds"], []), // `visible`: whether the pane shows the chat; acpmux owns unread from it (cx-n8ou). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift index 6421d7fc2800..2830ca887e24 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift @@ -22,6 +22,9 @@ nonisolated enum AcpmuxPaneMethods { "session/new", "session/prompt", "session/set_model", "session/set_mode", "session/set_config_option", // acpmux extensions (direct.ts). "_acpmux/watch", "_acpmux/events", "_acpmux/attach", "_acpmux/detach", "_acpmux/view", "_acpmux/warm", + // The session list's later pages (sessionPages.ts, cx-2lv1k): the same summaries watch answers, + // read by cursor after watch's first page. + "_acpmux/sessions", "_acpmux/kill", "_acpmux/prewarm", "_acpmux/harnesses", "_acpmux/models", "_acpmux/permission_respond", // Read-only, and its reply is filtered to ``replyShapes`` (ad349). "_acpmux/status", "_acpmux/draft_get", diff --git a/cmux-tui/crates/cmux-agent-pane-policy/policy.json b/cmux-tui/crates/cmux-agent-pane-policy/policy.json index fb1285c07ffa..7f623475fc42 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/policy.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/policy.json @@ -4,7 +4,7 @@ "initialize": "initialize", "requests": [ "session/new", "session/prompt", "session/set_model", "session/set_mode", "session/set_config_option", - "_acpmux/watch", "_acpmux/events", "_acpmux/attach", "_acpmux/detach", "_acpmux/view", "_acpmux/warm", + "_acpmux/watch", "_acpmux/sessions", "_acpmux/events", "_acpmux/attach", "_acpmux/detach", "_acpmux/view", "_acpmux/warm", "_acpmux/kill", "_acpmux/prewarm", "_acpmux/harnesses", "_acpmux/models", "_acpmux/permission_respond", "_acpmux/status", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/ask", "_acpmux/queue_remove", "_acpmux/queue_edit", "_acpmux/handoff_prepare", "_acpmux/handoff_get", "_acpmux/handoff_draft", "_acpmux/handoff_start", @@ -50,7 +50,8 @@ "session/prompt": {"params": ["sessionId", "prompt", "_meta"], "acpmux": ["promptId", "steer", "interrupt"]}, "session/set_model": {"params": ["sessionId", "modelId"], "acpmux": []}, "session/cancel": {"params": ["sessionId"], "acpmux": []}, - "_acpmux/watch": {"params": ["enabled"], "acpmux": []}, + "_acpmux/watch": {"params": ["enabled", "limit"], "acpmux": []}, + "_acpmux/sessions": {"params": ["limit", "cursor"], "acpmux": []}, "_acpmux/events": {"params": ["sessionId", "afterSeq", "beforeSeq", "limit", "maxBytes", "kinds"], "acpmux": []}, "_acpmux/attach": {"params": ["sessionId", "limit", "maxBytes", "kinds", "eventStream", "visible"], "acpmux": []}, "_acpmux/detach": {"params": ["sessionId"], "acpmux": []}, diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json index 3ceea0ba9509..f0960cffdf0d 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json @@ -464,6 +464,15 @@ "send": "unchanged" } }, + { + "name": "request _acpmux/sessions passes", + "text": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"_acpmux/sessions\",\"params\":{}}", + "first": false, + "token": "abc", + "expect": { + "send": "unchanged" + } + }, { "name": "request acp.session.fork passes", "text": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"acp.session.fork\",\"params\":{}}", @@ -766,24 +775,24 @@ } }, { - "name": "escaped '_acpmux/sessions' refused", - "text": "{\"jsonrpc\":\"2.0\",\"id\":511,\"method\":\"\\u005f\\u0061\\u0063\\u0070\\u006d\\u0075\\u0078\\u002f\\u0073\\u0065\\u0073\\u0073\\u0069\\u006f\\u006e\\u0073\",\"params\":{}}", + "name": "escaped '_acpmux/web_token_rotate' refused", + "text": "{\"jsonrpc\":\"2.0\",\"id\":511,\"method\":\"\\u005f\\u0061\\u0063\\u0070\\u006d\\u0075\\u0078\\u002f\\u0077\\u0065\\u0062\\u005f\\u0074\\u006f\\u006b\\u0065\\u006e\\u005f\\u0072\\u006f\\u0074\\u0061\\u0074\\u0065\",\"params\":{}}", "first": false, "token": null, "expect": { "refuse": "transport.method_refused", - "method": "_acpmux/sessions", + "method": "_acpmux/web_token_rotate", "id": "511" } }, { - "name": "notification '_acpmux/sessions' refused", - "text": "{\"jsonrpc\":\"2.0\",\"method\":\"_acpmux/sessions\",\"params\":{}}", + "name": "notification '_acpmux/web_token_rotate' refused", + "text": "{\"jsonrpc\":\"2.0\",\"method\":\"_acpmux/web_token_rotate\",\"params\":{}}", "first": false, "token": null, "expect": { "refuse": "transport.method_refused", - "method": "_acpmux/sessions", + "method": "_acpmux/web_token_rotate", "id": null } }, diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json index d70e4e44f995..b2b876bd0c25 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json @@ -482,12 +482,42 @@ "id": 1, "method": "_acpmux/watch", "params": { - "enabled": 1 + "enabled": 1, + "limit": 1 + } + }, + "mode_fields": null, + "breaks": false + }, + { + "name": "_acpmux/sessions every known param", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "_acpmux/sessions", + "params": { + "limit": 1, + "cursor": 1 } }, "mode_fields": null, "breaks": false }, + { + "name": "_acpmux/sessions unknown param", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "_acpmux/sessions", + "params": { + "limit": 1, + "cursor": "k1.2", + "query": "x" + } + }, + "mode_fields": null, + "breaks": true + }, { "name": "_acpmux/events every known param", "frame": { diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/typescript.rs b/cmux-tui/crates/cmux-agent-pane-policy/tests/typescript.rs index 77d90ee85f1f..87f88158bad7 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/typescript.rs +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/typescript.rs @@ -1,6 +1,6 @@ //! The lists against what the page sends (webviews/src/agent-session/acpmux), //! as AcpmuxPaneMethods.swift says they were made: every -//! `this.request("...")` in direct.ts, `HANDOFF_OPS` (handoff/protocol.ts), +//! `this.request("...")` in direct.ts and sessionPages.ts, `HANDOFF_OPS` (handoff/protocol.ts), //! `PERMISSION_GROUP_OPS` (permissions/protocol.ts), `FORK_OP` //! (operations.ts), `PREWARM_METHOD` (direct.ts), and the raw //! `session/cancel` notification. `file.search` (and the git reads, sent by a @@ -63,6 +63,7 @@ fn the_allowlist_is_what_the_page_sends() { let dir = pane(); let direct = read(&dir, "direct.ts"); let mut sent: BTreeSet = quoted_after(&direct, "this.request(").into_iter().collect(); + sent.extend(quoted_after(&read(&dir, "sessionPages.ts"), "this.request(")); sent.extend(object_values(&read(&dir, "handoff/protocol.ts"), "HANDOFF_OPS")); sent.extend(object_values(&read(&dir, "permissions/protocol.ts"), "PERMISSION_GROUP_OPS")); sent.insert(constant(&read(&dir, "operations.ts"), "FORK_OP")); diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index b07c83ca8909..c4cc0163162b 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -32,6 +32,7 @@ import { translate } from "./i18n"; import { experimentArm } from "../../experiments/experiment"; import { sessionOffer as sessionOfferExperiment } from "./offer/sessionOffer.experiment"; import { errorMessage } from "./transportErrors"; +import { SessionPager } from "./sessionPages"; import { isWarmableCwd } from "./warmFolders"; import { SubagentFold } from "./subagents/subagentFold"; import { WORKSPACE_CONTEXT } from "./workspaceContext"; @@ -540,6 +541,8 @@ export class AcpmuxDirectClient { private events: EventRecord[] = []; private rows = new OrderedRows(); private sessions: Session[] = []; + /// The session list a page at a time: the newest page opens the chat, the rest follow (cx-2lv1k). + private readonly sessionPages = new SessionPager((method, params) => this.request(method, params)); /// Sidebar entries by acpmux session object. A changed session arrives as a new object, so unchanged rows keep their entry and skip rendering. private sessionEntries = new WeakMap(); private selectedSessionId?: string; @@ -806,8 +809,20 @@ export class AcpmuxDirectClient { .map((peer: any) => (typeof peer?.name === "string" ? peer.name : undefined)) .filter((peer: string | undefined): peer is string => Boolean(peer)) : []; - const watched = await this.request("_acpmux/watch", { enabled: true }); - this.sessions = this.reread(watched?.sessions); + const first = await this.sessionPages.first(); + let laterPages = first?.next; + const linkedLater = + first !== undefined && + laterPages !== undefined && + this.selectedSessionId !== undefined && + !first.sessions.some((session) => session.sessionId === this.selectedSessionId); + if (first && !linkedLater) this.sessions = first.sessions; + else { + // A linked chat older than the first page, or a lagged watch restarted the list: read the + // whole list before calling the chat missing. + this.sessions = this.sessionPages.settle(await this.sessionPages.whole()); + laterPages = undefined; + } if (this.selectedSessionId && !this.sessions.some((session) => session.sessionId === this.selectedSessionId)) { // A linked session the daemon lacks is refused, never replaced by the most recent chat. if (this.host.sessionMustExist) this.missingSession = this.selectedSessionId; @@ -840,6 +855,7 @@ export class AcpmuxDirectClient { sessions: this.sessions.length, }); this.emit("connected"); + if (laterPages) void this.readLaterSessions(laterPages); } catch (error) { this.wire.lifecycle("connect failed", { message: error instanceof Error ? error.message : String(error), @@ -1009,8 +1025,16 @@ export class AcpmuxDirectClient { /// A selection made while the request was out is newer than the list. private async refreshSessions(): Promise { const generation = this.selectionGeneration; - const watched = await this.request("_acpmux/watch", { enabled: true }); - this.sessions = this.reread(watched?.sessions); + const first = await this.sessionPages.first(); + // A newer reread started meanwhile; it sets the list. + if (!first) return; + let list = first.sessions; + const add = (more: Session[]) => { + list = [...list, ...more]; + }; + if (first.next && !(await this.sessionPages.rest(first.next, () => list, add))) return; + // Changes that came while the pages did are newer than the pages. + this.sessions = this.sessionPages.settle(list); const missing = this.selectedSessionId !== undefined && !this.sessions.some((session) => session.sessionId === this.selectedSessionId); @@ -1030,12 +1054,17 @@ export class AcpmuxDirectClient { if (this.selectedSessionId) void this.attach(this.selectedSessionId, generation).catch(() => undefined); } - /// A full session list from `_acpmux/watch`. acpmux owns `unread` (cx-n8ou): the pane only - /// tells it whether it shows its chat (`attach` `visible`, `_acpmux/view`). - private reread(sessions: Session[] | undefined): Session[] { - return (sessions ?? []).filter((session) => session.sessionId); + /// The pages after the first, read once the chat is open. acpmux owns `unread` (cx-n8ou): the + /// pane only tells it whether it shows its chat (`attach` `visible`, `_acpmux/view`). + private async readLaterSessions(cursor: string): Promise { + await this.sessionPages.rest(cursor, () => this.sessions, this.addSessions).catch(() => undefined); } + private addSessions = (more: Session[]): void => { + this.sessions = [...this.sessions, ...more]; + if (!this.opening) this.emit("session changed"); + }; + /// The page was hidden or shown (a tab in a background workspace, an occluded window): acpmux /// leaves a turn that ends while nobody shows the chat unread, and showing it is seeing it. private showChanged = (): void => { @@ -1322,6 +1351,7 @@ export class AcpmuxDirectClient { private sessionChanged(params: any): void { const session = params?.session; if (params?.kind === "purged" && session?.sessionId) { + this.sessionPages.purgedSession(session.sessionId); this.sessions = this.sessions.filter((item) => item.sessionId !== session.sessionId); if (session.sessionId === this.selectedSessionId && this.handoff.state.busy !== "discarding") this.selectFallbackSession("session purged"); @@ -1329,6 +1359,7 @@ export class AcpmuxDirectClient { return; } if (!session?.sessionId) return; + this.sessionPages.changedSession(session); this.sessions = [...this.sessions.filter((item) => item.sessionId !== session.sessionId), session]; if (session.sessionId === this.selectedSessionId) { this.summary = { ...this.summary, ...session }; @@ -2654,6 +2685,7 @@ export class AcpmuxDirectClient { /// The socket's own onclose ignores a socket close() already let go of, so settle requests here. close(): void { this.closed = true; + this.sessionPages.stop(); if (typeof document !== "undefined") document.removeEventListener("visibilitychange", this.showChanged); if (this.reconnectTimer !== undefined) window.clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; diff --git a/webviews/src/agent-session/acpmux/sessionPages.ts b/webviews/src/agent-session/acpmux/sessionPages.ts new file mode 100644 index 000000000000..1533dd5b5195 --- /dev/null +++ b/webviews/src/agent-session/acpmux/sessionPages.ts @@ -0,0 +1,102 @@ +// The pane's session list in keyset pages (cx-2lv1k). `_acpmux/watch` with `limit` answers the +// newest sessions and a `nextCursor`; `_acpmux/sessions` with that cursor answers the next page. +// The first page draws the pane and opens its chat; the rest arrive behind it, so a long list on +// a remote link never holds up the transcript. Search, project groups and pinned rows need every +// session, so the list is always read to the end. A daemon that ignores `limit` answers the whole +// list with no cursor, as before. + +/** Sessions in one reply: the first page, and each later one. */ +export const SESSION_PAGE = 200; + +type Request = (method: string, params: Record) => Promise; +type Summary = { sessionId: string } & Record; + +export type SessionPage = { sessions: S[]; next?: string }; + +function pageOf(reply: any): SessionPage { + const sessions = (Array.isArray(reply?.sessions) ? reply.sessions : []).filter( + (session: any): session is S => typeof session?.sessionId === "string" && session.sessionId !== "", + ); + const next = typeof reply?.nextCursor === "string" && sessions.length > 0 ? reply.nextCursor : undefined; + return { sessions, ...(next ? { next } : {}) }; +} + +/** + * Reads the session list a page at a time. Starting a list again (a reconnect, a lagged watch) + * or `stop` ends the pages of the one before. Every `session_changed` since the list started is + * remembered: a page read before it carries the older state, so `settle` lays the changes over + * the pages, and `rest` never adds a session the pane already holds or one purged meanwhile. + */ +export class SessionPager { + private run = 0; + private purged = new Set(); + private changed = new Map(); + + constructor(private readonly request: Request) {} + + /** Turns the watch on and answers its first page, or undefined when a newer list started meanwhile. */ + async first(): Promise | undefined> { + const run = ++this.run; + this.purged.clear(); + this.changed.clear(); + const page = pageOf(await this.request("_acpmux/watch", { enabled: true, limit: SESSION_PAGE })); + return run === this.run ? page : undefined; + } + + /** + * The whole list in one reply, for a decision that cannot wait for the pages: watch without a + * `limit`, as every host answers it (a remote pane's host never pages). + */ + async whole(): Promise { + return pageOf(await this.request("_acpmux/watch", { enabled: true })).sessions; + } + + /** A `session_changed` seen while the list loads. */ + changedSession(session: S): void { + this.purged.delete(session.sessionId); + this.changed.set(session.sessionId, session); + } + + /** A `session_changed` purge seen while the list loads. */ + purgedSession(sessionId: string): void { + this.changed.delete(sessionId); + this.purged.add(sessionId); + } + + /** `list` with every change since the list started laid over it. */ + settle(list: S[]): S[] { + const kept = list.filter((session) => !this.purged.has(session.sessionId) && !this.changed.has(session.sessionId)); + return [...kept, ...this.changed.values()]; + } + + /** + * Reads every page after `cursor`, handing `add` the sessions the pane lacks. Answers false + * when a newer list replaced this one. A page that fails falls back to the whole list at once. + */ + async rest(cursor: string, held: () => S[], add: (sessions: S[]) => void): Promise { + const run = this.run; + let next: string | undefined = cursor; + while (next) { + let page: SessionPage; + try { + page = pageOf(await this.request("_acpmux/sessions", { limit: SESSION_PAGE, cursor: next })); + } catch { + if (run !== this.run) return false; + page = { sessions: await this.whole() }; + } + if (run !== this.run) return false; + const known = new Set(held().map((session) => session.sessionId)); + const fresh = page.sessions.filter( + (session) => !known.has(session.sessionId) && !this.purged.has(session.sessionId), + ); + if (fresh.length > 0) add(fresh); + next = page.next; + } + return true; + } + + /** Ends the pages in flight. */ + stop(): void { + this.run += 1; + } +} diff --git a/webviews/test/agent-pane-session-pages.test.ts b/webviews/test/agent-pane-session-pages.test.ts new file mode 100644 index 000000000000..72f95253b38e --- /dev/null +++ b/webviews/test/agent-pane-session-pages.test.ts @@ -0,0 +1,310 @@ +// l10n-allow-file: test fixture titles, not shipped UI. +// A daemon with a long session list opens a chat without first downloading the whole list +// (cx-2lv1k). The pane asks `_acpmux/watch` for the newest page, opens its chat, then reads the +// rest with `_acpmux/sessions` cursors behind it, so the list still ends up whole: the composer's +// projects, the picker and links to old chats need every session. A session purged or changed +// while a page is on its way keeps its newer state, and a link to a chat on a late page opens it. +// The stand-in daemon refuses what the cmux host's pane policy (policy.json) refuses, as the host +// would: #20644 sent params the policy lacked and local chats lost their list (cx-ibngi). +// +// Real Chromium from Playwright; skipped where it is not installed (the hosted webviews job). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { chromium, type Browser, type Page } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("agent-pane-session-pages.test.ts", async () => { + setDefaultTimeout(240_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("agent-pane-session-pages: skipping (run `bunx playwright install chromium`)"); + describe.skip("session pages", () => test("chromium", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + + // The cmux host's pane policy: the methods the page may send and each one's params. + const policy = JSON.parse( + readFileSync(path.resolve(webviews, "../cmux-tui/crates/cmux-agent-pane-policy/policy.json"), "utf8"), + ) as { requests: string[]; known_params: Record }; + const refused: string[] = []; + function refusal(method: string, params: Record): string | undefined { + if (method === "initialize") return undefined; + if (!policy.requests.includes(method)) return method; + const known = policy.known_params[method]?.params ?? []; + const unknown = Object.keys(params).filter((key) => key !== "_meta" && !known.includes(key)); + return unknown.length > 0 ? `${method} ${unknown.join(",")}` : undefined; + } + + const COUNT = 1200; + const START = Date.UTC(2026, 9, 1, 9, 0, 0); + const id = (n: number) => `0190a000-0000-7000-8000-${n.toString(16).padStart(12, "0")}`; + // Newest first, as acpmux sorts them. Each summary carries what a real one does, about 600 bytes. + const sessions = Array.from({ length: COUNT }, (_, n) => ({ + sessionId: id(n + 1), + title: `Chat ${n + 1}`, + cwd: `/home/dev/project-${n % 40}`, + branch: `feature/session-${n + 1}`, + harness: "claude", + family: "claude", + status: "ready", + indicator: "done", + turnCount: 12, + createdAt: START - (n + 1) * 60_000, + updatedAt: START - n * 60_000, + lastSeq: 3, + lastPrompt: `Look at the build for change ${n + 1} and tell me which checks failed and why. `.repeat(5), + })); + const NEWEST = sessions[0]!.sessionId; + const LATE = sessions[1100]!.sessionId; + const GONE = sessions[700]!.sessionId; + const MOVED = sessions[900]!.sessionId; + + /// acpmux's keyset pages, here by position: `limit` sessions after the cursor and the next cursor. + function list(params: Record, all = sessions) { + if (params.limit === undefined) return { sessions: all }; + const start = typeof params.cursor === "string" ? Number(params.cursor.slice(1)) : 0; + const page = all.slice(start, start + Number(params.limit)); + const more = start + page.length < all.length; + return { sessions: page, nextCursor: more ? `o${start + page.length}` : null }; + } + const events = (sessionId: string) => [ + { sessionId, seq: 1, at: START, dir: "mux", kind: "user_message", msg: { text: `Open ${sessionId}` } }, + { sessionId, seq: 2, at: START + 1, dir: "mux", kind: "turn_started", msg: {} }, + { sessionId, seq: 3, at: START + 2, dir: "mux", kind: "turn_result", msg: { status: "completed" } }, + ]; + + type Socket = { send(data: string): unknown }; + let socket: Socket | undefined; + let attached = false; + let bytesBeforeAttach = 0; + let release: () => void = () => {}; + let held = Promise.resolve(); + const holdPages = () => { + held = new Promise((resolve) => (release = resolve)); + }; + const requests: { method: string; params: Record }[] = []; + function answer(method: string, params: Record): unknown { + switch (method) { + case "initialize": + return { protocolVersion: 1, _meta: { acpmux: { origin: "remote", extensions: [], operations: [] } } }; + case "_acpmux/watch": + case "_acpmux/sessions": + return list(params); + case "_acpmux/attach": { + const session = sessions.find((item) => item.sessionId === params.sessionId); + return { session, events: events(String(params.sessionId)), hasMore: false, lastSeq: 3 }; + } + case "_acpmux/events": + return { events: [], hasMore: false, lastSeq: 3 }; + default: + return null; + } + } + + let daemon: ReturnType | undefined; + let vite: Awaited> | undefined; + let browser: Browser | undefined; + beforeAll(async () => { + daemon = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, server) { + return server.upgrade(request, { data: undefined }) ? undefined : new Response("acpmux", { status: 426 }); + }, + websocket: { + open(ws) { + socket = ws; + }, + async message(ws, raw) { + const message = JSON.parse(String(raw)) as { id?: number; method: string; params?: Record }; + if (message.id === undefined) return; + const params = message.params ?? {}; + requests.push({ method: message.method, params }); + const refusedAs = refusal(message.method, params); + if (refusedAs) { + refused.push(refusedAs); + const error = { code: -32601, message: "Refused by the cmux host" }; + ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, error })); + return; + } + // The page is read now; a later page waits until the test lets it go, as a slow link would. + const result = answer(message.method, params); + if (message.method === "_acpmux/sessions") await held; + const text = JSON.stringify({ jsonrpc: "2.0", id: message.id, result }); + if (!attached) bytesBeforeAttach += text.length; + if (message.method === "_acpmux/attach") attached = true; + ws.send(text); + }, + }, + }); + vite = await createServer({ + configFile: path.join(webviews, "vite.config.acpmux-pane.mjs"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + await daemon?.stop(true); + }); + + const notify = (method: string, params: Record) => + socket!.send(JSON.stringify({ jsonrpc: "2.0", method, params })); + const laterPagesAsked = async (count: number) => { + for (let waited = 0; requests.filter((r) => r.method === "_acpmux/sessions").length < count; waited += 50) { + if (waited > 10_000) throw new Error(`the pane asked for ${count} later pages within 10s`); + await Bun.sleep(50); + } + }; + + async function open(chat: string): Promise { + attached = false; + bytesBeforeAttach = 0; + requests.length = 0; + const page = await browser!.newPage({ viewport: { width: 900, height: 760 } }); + const { port } = vite!.httpServer!.address() as { port: number }; + const endpoint = `ws://127.0.0.1:${daemon!.port}/`; + await page.goto(`http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${chat}`); + return page; + } + const state = (page: Page) => page.evaluate(() => (window as any).cmuxAcpmuxDebug.chatState() as Record); + + test("a chat opens after the newest page of a long session list, and the rest arrives behind it", async () => { + holdPages(); + const page = await open(NEWEST); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.getByText(`Open ${NEWEST}`, { exact: true }).waitFor({ timeout: 60_000 }); + + // Everything the daemon sent before the chat's own events: the list's first page, not all of it. + expect(bytesBeforeAttach).toBeLessThanOrEqual(160 * 1024); + const watch = requests.find((request) => request.method === "_acpmux/watch")!; + expect(watch.params.limit).toBeLessThanOrEqual(200); + + // While a later page is on its way, a session is purged and another one changes. The page was + // read before both, so it carries the old state; the pane keeps the newer one. + await laterPagesAsked(1); + socket!.send( + JSON.stringify({ + jsonrpc: "2.0", + method: "_acpmux/session_changed", + params: { kind: "purged", session: { sessionId: GONE } }, + }), + ); + socket!.send( + JSON.stringify({ + jsonrpc: "2.0", + method: "_acpmux/session_changed", + params: { kind: "updated", session: { ...sessions[900], title: "Moved to the top", updatedAt: START + 1 } }, + }), + ); + release(); + + await page.waitForFunction( + (count) => (window as any).cmuxAcpmuxDebug.chatState().sessions.length >= count, + COUNT - 1, + { + timeout: 60_000, + }, + ); + await page.waitForTimeout(300); + const final = await state(page); + const ids: string[] = final.sessions.map((session: { sessionId: string }) => session.sessionId); + expect(errors).toEqual([]); + expect(ids.length).toBe(COUNT - 1); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).not.toContain(GONE); + expect(final.sessions.find((session: { sessionId: string }) => session.sessionId === MOVED)?.title).toBe( + "Moved to the top", + ); + // Every later page went by cursor, one after another. + const later = requests.filter((request) => request.method === "_acpmux/sessions"); + expect(later.every((request) => typeof request.params.cursor === "string" && request.params.limit <= 200)).toBe( + true, + ); + await page.close(); + }); + + test("a link to a chat on a late page opens that chat, not a missing one", async () => { + holdPages(); + release(); + const page = await open(LATE); + await page.getByText(`Open ${LATE}`, { exact: true }).waitFor({ timeout: 60_000 }); + const opened = await state(page); + expect(opened.sessionId).toBe(LATE); + expect(opened.missingSession).toBeNull(); + await page.close(); + }); + + test("a lagged watch rereads the list in pages and keeps what changed while they came", async () => { + holdPages(); + release(); + const page = await open(NEWEST); + await page.getByText(`Open ${NEWEST}`, { exact: true }).waitFor({ timeout: 60_000 }); + await page.waitForFunction((count) => (window as any).cmuxAcpmuxDebug.chatState().sessions.length >= count, COUNT, { + timeout: 60_000, + }); + + // The watch dropped notices: the pane rereads. While its later pages are on their way, a session + // on its first page is purged, another one there is renamed, and a new chat starts. + holdPages(); + requests.length = 0; + notify("_acpmux/lagged", { watch: true, dropped: 3 }); + await laterPagesAsked(1); + const created = { ...sessions[0]!, sessionId: id(COUNT + 1), title: "Started meanwhile", updatedAt: START + 5 }; + notify("_acpmux/session_changed", { kind: "purged", session: { sessionId: sessions[5]!.sessionId } }); + notify("_acpmux/session_changed", { kind: "updated", session: { ...sessions[10], title: "Renamed meanwhile" } }); + notify("_acpmux/session_changed", { kind: "created", session: created }); + release(); + + await page.waitForFunction( + (count) => (window as any).cmuxAcpmuxDebug.chatState().sessions.length === count, + COUNT, + { timeout: 60_000 }, + ); + await laterPagesAsked(Math.ceil(COUNT / 200) - 1); + await page.waitForTimeout(300); + const final = await state(page); + const byId = new Map( + final.sessions.map((session: { sessionId: string; title: string }) => [session.sessionId, session]), + ); + expect(byId.size).toBe(COUNT); + expect(byId.has(sessions[5]!.sessionId)).toBe(false); + expect(byId.get(sessions[10]!.sessionId)?.title).toBe("Renamed meanwhile"); + expect(byId.get(created.sessionId)?.title).toBe("Started meanwhile"); + await page.close(); + }); + + test("two lagged notices in a row never leave a chat on a late page judged missing", async () => { + holdPages(); + release(); + const page = await open(LATE); + await page.getByText(`Open ${LATE}`, { exact: true }).waitFor({ timeout: 60_000 }); + + holdPages(); + requests.length = 0; + notify("_acpmux/lagged", { watch: true, dropped: 1 }); + notify("_acpmux/lagged", { watch: true, dropped: 1 }); + await laterPagesAsked(1); + release(); + await page.waitForFunction((count) => (window as any).cmuxAcpmuxDebug.chatState().sessions.length >= count, COUNT, { + timeout: 60_000, + }); + await page.waitForTimeout(500); + const after = await state(page); + expect(after.sessionId).toBe(LATE); + expect(after.missingSession).toBeNull(); + await page.close(); + }); + + test("everything the pane sent while paging passes the host's pane policy", () => { + expect(refused).toEqual([]); + }); +}); From c6b14309a64942808df19a523bd7bd581e9e96fe Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:23:37 -0700 Subject: [PATCH 131/183] cmux-tui: closing a screen, pane or workspace keeps its tab groups for reopen (cx-gzh.31) (#20736) * test(cmux-tui): a closed screen reopens with its tab group (cx-gzh.31) Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): closing a screen, pane or workspace keeps its tab groups for reopen (cx-gzh.31) A close records every live tab group holding a closing tab (directly, or in a closing screen or workspace) on its closed-history group, naming the group's closed tabs. Reopen forms each group again around the tabs it brought back, or adds them to the group if it is still open. Close Group now goes through the same capture, which replaces its separate attach step; records stored with the older single tab_group still reopen. Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): review fixes for captured tab groups (cx-gzh.31) - A group row a screen, pane or workspace close left behind is not live: reopen forms the group again instead of adding to a group whose pane is gone. Every strip edit prunes such rows before it runs, so the id is free again. - Capture the groups before capture_workspace forgets which closing workspaces are ephemeral, so incognito group names are never recorded. - Record only memberships of tabs still in their group's pane. Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): import group_members (clippy unnecessary qualification) Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at 52a293c07154 - cargo fmt in Native/OptChat/optchat-chief Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38652086305 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- .../cmux-tui-core/src/mux/tab_groups.rs | 3 - .../crates/cmux-tui-core/src/mux/tab_strip.rs | 13 +-- cmux-tui/crates/cmux-tui-core/src/state.rs | 1 + .../cmux-tui-core/src/state/closed_history.rs | 25 ++++-- .../src/state/closed_history_store.rs | 82 ++++------------- .../src/state/closed_tab_groups.rs | 87 +++++++++++++++++++ .../reopen_closed_kinds.rs | 60 +++++++++++++ 7 files changed, 188 insertions(+), 83 deletions(-) create mode 100644 cmux-tui/crates/cmux-tui-core/src/state/closed_tab_groups.rs diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/tab_groups.rs b/cmux-tui/crates/cmux-tui-core/src/mux/tab_groups.rs index 7c89419d4dca..93d9ecf4a08a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/tab_groups.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/tab_groups.rs @@ -753,9 +753,6 @@ impl Mux { .iter() .map(|surface| tab_public_id(state, *surface)) .collect::>>()?; - if let Some(record) = edit.groups.groups.get(&group_id) { - edit.closed_group = Some(group_archive(state, &group_id, record, &tabs)); - } let panes = members .iter() .filter_map(|surface| state.pane_of(*surface)) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/tab_strip.rs b/cmux-tui/crates/cmux-tui-core/src/mux/tab_strip.rs index 84bf3665287c..2d3310f77b25 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/tab_strip.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/tab_strip.rs @@ -75,9 +75,6 @@ pub(crate) struct StripEdit { /// recorded as one closed-history group that forms it again /// (RECOVERABLE-BY-DEFAULT). pub(crate) ungrouped: Option, - /// A group that closes with its tabs (Close Group): its archive joins - /// the closed-history group of those tabs, whose reopen forms it again. - pub(crate) closed_group: Option, pub(crate) result: StripResult, } @@ -108,9 +105,11 @@ impl Mux { tab_state: Vec::new(), saved: None, ungrouped: None, - closed_group: None, result: StripResult::None, }; + // A screen, pane or workspace close leaves the rows of the + // groups it emptied: drop them before the edit sees them. + prune_tab_groups(&projected, &mut edit.groups); let result = mutate(&mux, &mut projected, &mut edit)?; Mux::rebuild_split_screen_index(&mut projected); prune_tab_groups(&projected, &mut edit.groups); @@ -200,8 +199,7 @@ fn strip_state_write( before: &crate::workspace_registry::PresentationSnapshot, edit: StripEdit, ) -> crate::resource_mutation::PlanStateWrite { - let StripEdit { groups, pins, tab_state, saved, ungrouped, closed_group, result: result_kind } = - edit; + let StripEdit { groups, pins, tab_state, saved, ungrouped, result: result_kind } = edit; let mut touched_tabs = BTreeSet::new(); for (tab, group) in &before.tab_groups.members { if groups.members.get(tab) != Some(group) { @@ -244,9 +242,6 @@ fn strip_state_write( archive, )?; } - if let Some(archive) = &closed_group { - crate::state::closed_history_store::attach_closed_tab_group(transaction, archive)?; - } write_tab_groups(transaction, &groups)?; for (tab, pinned, url) in &pins { set_tab_pinned(transaction, tab, *pinned, url.as_deref())?; diff --git a/cmux-tui/crates/cmux-tui-core/src/state.rs b/cmux-tui/crates/cmux-tui-core/src/state.rs index 31ee6ccfe820..025f67ff74a0 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state.rs @@ -34,6 +34,7 @@ pub(crate) mod closed_history_store; mod closed_history_tests; #[cfg(test)] mod closed_relaunch_tests; +mod closed_tab_groups; pub(crate) mod command_history_store; pub(crate) mod commit; pub(crate) mod conversation_tabs; diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs index ab0f7321fd25..65a0fe18025f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs @@ -13,7 +13,7 @@ //! request's result, so a retry with the same key replays it. use crate::Actor; -use crate::mux::tab_groups::pane_by_public_id; +use crate::mux::tab_groups::{group_members, pane_by_public_id}; use crate::mux::tab_strip::StripRequest; use crate::mux::*; use crate::state::closed_history_query::{ @@ -291,11 +291,18 @@ impl Mux { for member in &restore { self.reopen_member(&mutation.actor, &closed_id, member, &mut reopened)?; } - if let Some(group) = record.get("tab_group").filter(|group| group.is_object()) { - // A closed group (Close Group) names its closed tabs: it forms - // again around those this reopen brought back. + // An ungrouped group (`tab_group`, no member) forms again whole. The + // groups a close took (`tab_groups`; `tab_group` on a Close Group + // recorded before cx-gzh.31) name their closed tabs: each forms + // again around those this reopen brought back. + let closed = !restore.is_empty(); + let groups = record + .get("tab_group") + .filter(|group| group.is_object()) + .into_iter() + .chain(record["tab_groups"].as_array().into_iter().flatten()); + for group in groups { let mut group = group.clone(); - let closed = !restore.is_empty(); if closed { let tabs = group["tab_ids"] .as_array() @@ -303,6 +310,9 @@ impl Mux { .flatten() .filter_map(|tab| reopened.reopened_as.get(tab.as_str()?).cloned()) .collect::>(); + if tabs.is_empty() { + continue; + } group["tab_ids"] = serde_json::json!(tabs); } self.reopen_tab_group(&mutation.actor, &group, closed, &mut reopened)?; @@ -369,7 +379,10 @@ impl Mux { ) -> anyhow::Result<()> { let Some(id) = archive["id"].as_str().map(str::to_string) else { return Ok(()) }; let presentation = self.presentation_snapshot(); - let live = presentation.tab_groups.groups.contains_key(&id); + // A group row a close left behind (no strip edit pruned it yet) + // holds no open tab: it is not live, and forms again. + let live = self + .with_state(|state| !group_members(state, &presentation.tab_groups, &id).is_empty()); if !live || closed { let wanted = archive["tab_ids"] .as_array() diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_history_store.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_history_store.rs index 6260801481e0..4b5bdc39c4d4 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state/closed_history_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_history_store.rs @@ -382,11 +382,16 @@ fn insert_group( transaction: &Transaction<'_>, members: Vec, window: Option, + tab_groups: Vec, ) -> anyhow::Result<()> { - let (closed_id, extra) = match take_pending_group(transaction)? { + let (closed_id, mut extra) = match take_pending_group(transaction)? { Some((closed_id, extra)) => (closed_id, Some(extra)), None => (new_closed_id(), None), }; + if !tab_groups.is_empty() { + let fields = extra.get_or_insert_with(|| json!({})); + fields["tab_groups"] = Value::Array(tab_groups); + } insert_record(transaction, closed_id, members, window, extra) } @@ -530,68 +535,6 @@ pub(crate) fn record_ungrouped_group( insert_record(transaction, new_closed_id(), Vec::new(), window, Some(extra)) } -/// Close of a whole tab group: `archive` (as [`record_ungrouped_group`] -/// takes it, naming the closed tabs) rides on the group that recorded its -/// tabs, so reopening them forms the group again. Nothing recorded them -/// (an incognito workspace): nothing to attach. -pub(crate) fn attach_closed_tab_group( - transaction: &Transaction<'_>, - archive: &Value, -) -> anyhow::Result<()> { - let Some(first) = archive["tab_ids"].get(0).and_then(Value::as_str) else { return Ok(()) }; - if !super::store::state_tables_ready(transaction)? { - return Ok(()); - } - let found = transaction - .query_row( - "SELECT g.closed_id, g.record_json FROM closed_groups AS g - WHERE EXISTS (SELECT 1 FROM json_tree(g.record_json, '$.members') AS t - WHERE t.key = 'tab_id' AND t.atom = ?1) - ORDER BY g.seq DESC LIMIT 1", - [first], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .optional()?; - let Some((closed_id, record)) = found else { return Ok(()) }; - let mut record: Value = serde_json::from_str(&record)?; - // Only the group this close recorded holds every one of its tabs, as - // tab members or inside the screen the close emptied. - let mut recorded = HashSet::new(); - closed_tab_ids(&record["members"], &mut recorded); - if !archive["tab_ids"] - .as_array() - .into_iter() - .flatten() - .all(|tab| tab.as_str().is_some_and(|tab| recorded.contains(tab))) - { - return Ok(()); - } - record["tab_group"] = archive.clone(); - transaction.execute( - "UPDATE closed_groups SET record_json = ?2 WHERE closed_id = ?1", - params![closed_id, serde_json::to_string(&record)?], - )?; - queue_change(transaction, &state_upsert("closed", &closed_id, public_item(&record))) -} - -/// Every closed tab id in `value`, at any depth. -fn closed_tab_ids<'a>(value: &'a Value, ids: &mut HashSet<&'a str>) { - match value { - Value::Array(items) => items.iter().for_each(|item| closed_tab_ids(item, ids)), - Value::Object(fields) => { - for (key, field) in fields { - match field.as_str() { - Some(id) if key == "tab_id" => { - ids.insert(id); - } - _ => closed_tab_ids(field, ids), - } - } - } - _ => {} - } -} - /// A new closed group id. pub(crate) fn new_closed_id() -> String { format!("closed_{}", new_uuid_v4().replace('-', "")) @@ -628,7 +571,7 @@ pub(crate) fn flush_pending_group(transaction: &Transaction<'_>) -> anyhow::Resu let pending: bool = transaction .query_row("SELECT EXISTS(SELECT 1 FROM closed_group_pending)", [], |row| row.get(0))?; - if pending { insert_group(transaction, Vec::new(), None) } else { Ok(()) } + if pending { insert_group(transaction, Vec::new(), None, Vec::new()) } else { Ok(()) } } /// The highest level among the members: a group with a screen is a screen @@ -741,6 +684,15 @@ pub(crate) fn capture_closed( } let closing_workspaces = closing.workspaces.iter().cloned().collect::>(); let closing_screens = closing.screens.iter().cloned().collect::>(); + // Before `capture_workspace` forgets which closing workspaces are + // ephemeral: their groups are never recorded. + let closing_tabs = closing.tabs.iter().cloned().collect::>(); + let tab_groups = super::closed_tab_groups::closing_tab_groups( + transaction, + &closing_tabs, + &closing_screens, + &closing_workspaces, + )?; let mut capture = Capture::default(); for workspace_id in &closing.workspaces { capture_workspace(transaction, workspace_id, &mut capture)?; @@ -770,7 +722,7 @@ pub(crate) fn capture_closed( if capture.members.is_empty() { return Ok(()); } - insert_group(transaction, capture.members, capture.window) + insert_group(transaction, capture.members, capture.window, tab_groups) } fn capture_screen( diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_tab_groups.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_tab_groups.rs new file mode 100644 index 000000000000..8e64e88a8781 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_tab_groups.rs @@ -0,0 +1,87 @@ +//! The tab groups a close takes with it (cx-gzh.31). Each group with a +//! closing tab rides on the closed-history group as an archive naming its +//! closed tabs, so reopening them forms it again: Close Group, a closed +//! screen, pane or workspace, or the last tab of a group. + +use std::collections::HashSet; + +use rusqlite::{Connection, OptionalExtension}; +use serde_json::{Value, json}; + +/// The archives of the live tab groups holding a tab that closes: one in +/// `tabs`, or in a screen of `screens` or a workspace of `workspaces`. +/// Each names only its closing tabs, in strip order, in the fields +/// `record_ungrouped_group` takes. Groups in an ephemeral workspace are +/// never recorded. +pub(super) fn closing_tab_groups( + connection: &Connection, + tabs: &HashSet, + screens: &HashSet, + workspaces: &HashSet, +) -> anyhow::Result> { + let ready = connection + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tab_group_members'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !ready { + return Ok(Vec::new()); + } + let rows = { + let mut statement = connection.prepare( + "SELECT g.group_id, g.pane_id, g.name, g.color, g.collapsed, g.icon, + m.tab_id, s.public_id, s.workspace_id + FROM tab_group_members AS m + JOIN tab_groups AS g ON g.group_id = m.group_id + JOIN resource_tabs AS t ON t.public_id = m.tab_id AND t.deleted_revision IS NULL + AND t.pane_id = g.pane_id + JOIN resource_panes AS p ON p.public_id = t.pane_id + JOIN resource_screens AS s ON s.public_id = p.screen_id + ORDER BY g.group_id ASC, t.position ASC", + )?; + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + )) + })? + .collect::, _>>()? + }; + let mut archives: Vec = Vec::new(); + for (group, pane, name, color, collapsed, icon, tab, screen, workspace) in rows { + if !(tabs.contains(&tab) || screens.contains(&screen) || workspaces.contains(&workspace)) + || super::closed_history_store::is_ephemeral(connection, &workspace)? + { + continue; + } + match archives.last_mut().filter(|archive| archive["id"] == group.as_str()) { + Some(archive) => { + if let Some(tab_ids) = archive["tab_ids"].as_array_mut() { + tab_ids.push(json!(tab)); + } + } + None => archives.push(json!({ + "id": group, + "pane_id": pane, + "workspace_id": workspace, + "name": name, + "color": color, + "collapsed": collapsed != 0, + "icon": icon, + "tab_ids": [tab], + })), + } + } + Ok(archives) +} diff --git a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs index 8a0fae30d647..b71e2f8263a5 100644 --- a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs +++ b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs @@ -621,6 +621,66 @@ fn a_closed_tab_group_that_emptied_its_screen_reopens_as_its_group() { assert_eq!(groups[0]["tab_ids"], serde_json::Value::Array(back), "{groups:?}"); } +#[test] +fn a_closed_screen_reopens_with_its_tab_group() { + let _exclusive = exclusive_process_test(); + let (harness, mut app, _) = start("reopen-screen-group"); + let workspace = kinds_workspace(&mut app); + let first = workspace["screens"][0]["resource_id"].clone(); + app.send(serde_json::json!({"cmd":"new-screen","workspace":workspace["id"], + "cols":80,"rows":24})); + let screen = kinds_workspace(&mut app)["screens"] + .as_array() + .into_iter() + .flatten() + .find(|screen| screen["resource_id"] != first) + .cloned() + .expect("the new screen is listed"); + let pane = screen["panes"][0]["id"].as_u64().expect("the new screen has a pane"); + for _ in 0..2 { + app.send(serde_json::json!({"cmd":"new-tab","pane":pane})); + } + let tabs = kinds_workspace(&mut app)["screens"] + .as_array() + .into_iter() + .flatten() + .find(|listed| listed["resource_id"] == screen["resource_id"]) + .map(|listed| listed["panes"][0]["tabs"].as_array().cloned().unwrap_or_default()) + .unwrap_or_default() + .iter() + .filter_map(|tab| tab["tab_resource_id"].as_str().map(str::to_string)) + .collect::>(); + assert_eq!(tabs.len(), 3, "{tabs:?}"); + resource_request( + &harness.socket, + "screen-group-create", + "tab_group.create", + serde_json::json!({"machine":"current","session":"current","tabs":tabs[1..], + "name":"Build","color":"blue"}), + Some("screen-group-create"), + ); + let group_id = tab_groups(&harness, "screen-group-list-1")[0]["id"] + .as_str() + .expect("the group has an id") + .to_string(); + + let (item, reopened) = close_records_and_reopens( + &harness, + "screen.close", + serde_json::json!({"screen":screen["resource_id"]}), + "screen-group", + ); + assert_eq!(item["kind"], "screen", "{item}"); + let back = reopened["tab_ids"].as_array().cloned().unwrap_or_default(); + assert_eq!(back.len(), 3, "every tab reopens once: {reopened}"); + let groups = tab_groups(&harness, "screen-group-list-2"); + assert_eq!(groups.len(), 1, "the reopen formed no group: {groups:?}"); + assert_eq!(groups[0]["id"], group_id.as_str()); + assert_eq!(groups[0]["name"], "Build"); + assert_eq!(groups[0]["color"], "blue"); + assert_eq!(groups[0]["tab_ids"], serde_json::json!(back[1..]), "{groups:?}"); +} + fn screen_groups(harness: &RecoveryHarness, request: &str) -> Vec { let reply = resource_request( &harness.socket, From 02cd733a193234f8ebbe88cb7c977d731d928d77 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:28:19 -0700 Subject: [PATCH 132/183] cmux-next: pin CCmuxAppFFI to cmux-app-ffi-4f33baa5d867 (motion landing) Release from app-ffi-release.yml run 38675081315 on 4f33baa5d8670ef9066883ed9ae7fa891a7651b7, prerelease, checksum 42ae442948b6e4944f2c66de523b98f618dcce39e9244e6e8aaa45136d739227; check-app-ffi-pin.sh --verify-release green. Co-Authored-By: Claude Opus 5.5 (1M context) --- Packages/macOS/CmuxNext/Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index 4d97da30beb9..3f2887c11188 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -121,8 +121,8 @@ let daemonSwiftSettings: [SwiftSetting] = [ /// when the FFI sources differ from the pinned source sha. let appFFI: Target = .binaryTarget( name: "CCmuxAppFFI", - url: "https://github.com/manaflow-ai/cmux/releases/download/cmux-app-ffi-579db042d5b697448d83dc7b7e973776cb235183/CCmuxAppFFI.xcframework.zip", - checksum: "2d23efb8b5e47b37295acaa112bcf14b62fce4fdc9fc3c1c5b21441fbd912be4" + url: "https://github.com/manaflow-ai/cmux/releases/download/cmux-app-ffi-4f33baa5d8670ef9066883ed9ae7fa891a7651b7/CCmuxAppFFI.xcframework.zip", + checksum: "42ae442948b6e4944f2c66de523b98f618dcce39e9244e6e8aaa45136d739227" ) let package = Package( From c7f70a324d460f61c51a49410689107cf8fb132b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:29:34 -0700 Subject: [PATCH 133/183] cmux-next: holder v1 spec records D6 (no code-identity check) and the offset/log decisions (cx-t2sqm.12) Co-Authored-By: Claude Opus 5.5 (1M context) --- plans/cmux-next/holder-protocol-v1.md | 16 +++++++++------- plans/cmux-next/pty-holder.md | 23 ++++++++++------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/plans/cmux-next/holder-protocol-v1.md b/plans/cmux-next/holder-protocol-v1.md index 7b7d0f370542..07fe3fa56975 100644 --- a/plans/cmux-next/holder-protocol-v1.md +++ b/plans/cmux-next/holder-protocol-v1.md @@ -117,7 +117,8 @@ Error codes: 1 `UNKNOWN_KIND`, 2 `BAD_TOKEN`, 3 `NO_COMMON_VERSION`, ### 5.3 Output - The holder always reads the PTY (or both pipes) and appends to its log, - with or without a controller. It never blocks the child on the socket or + with or without a controller (decided 2026-10-14: drop oldest, never + block). It never blocks the child on the socket or on ACK. The log is an in-memory ring (`ring_bytes`, default 8 MiB) spilling to `.log` beside its record (`log_bytes`, default 64 MiB); at the cap the oldest bytes are dropped and `oldest` moves up. ACK only @@ -127,7 +128,8 @@ Error codes: 1 `UNKNOWN_KIND`, 2 `BAD_TOKEN`, 3 `NO_COMMON_VERSION`, new controller or the end. A controller that falls behind `oldest` while streaming gets GAP in the stream, then DATA from the new `oldest`. - pipes mode: stdout and stderr share one offset space in arrival order; - DATA flags bit 0 marks stderr bytes. + DATA flags bit 0 marks stderr bytes (decided 2026-10-14: one offset space, + not two offsets per frame). ### 5.4 Input and control @@ -212,11 +214,11 @@ it with SIGKILL from the UI (the only pid use, from the user's action). `pid` fields are absent on purpose: no daemon finds or signals a holder by pid. -Code identity (decision D6, pending): on macOS the holder also checks the -connected peer's audit token (`LOCAL_PEERTOKEN`) against a code requirement -passed at spawn (same Team ID + the cmux daemon's identifier). Ad hoc DEV -builds (`orphan_grace_secs` set) skip it. The holder runs with the hardened -runtime so a same-uid process cannot attach a debugger to it. +Threat model (decision D6, Lawrence 2026-10-14): the holder protects +against other users only. It checks the socket peer uid (section 2) and the +owner token, and nothing else: no code-identity, code-signature or audit +token check. A process of the same user is trusted the same way a shell of +that user is. ## 8. Liveness and discovery diff --git a/plans/cmux-next/pty-holder.md b/plans/cmux-next/pty-holder.md index a347fe08b3fe..0229ef2a42ce 100644 --- a/plans/cmux-next/pty-holder.md +++ b/plans/cmux-next/pty-holder.md @@ -239,15 +239,12 @@ never infers an agent from a process name, argv, foreground group or tree. - The holder checks the peer uid (`getpeereid` / `SO_PEERCRED`) equals its own before HELLO, then the owner token (constant time). A wrong token gets ERROR BAD_TOKEN and the link is closed. -- Threat model: any process of the same uid, including an agent running in - another terminal, can read a 0600 file and connect to a 0600 socket. A - token stored in the readable registry therefore stops only other users. - To stop a same-uid process from typing into another terminal, the owner - token is NOT stored in the registry: the daemon keeps it in its own store - (Keychain on macOS, a 0600 file only the daemon reads on Linux), and the - holder also checks the peer's code identity on the connected socket - (`LOCAL_PEERTOKEN` audit token + code signature on macOS). That is - authentication of one connected peer, not a scan. +- Threat model (D6, Lawrence 2026-10-14): the holder protects against other + users only (socket peer uid + owner token). Any process of the same uid, + including an agent in another terminal, is trusted like a shell of that + user; there is no code-identity check. The owner token stays out of the + registry (the daemon keeps it in its own store) so a stale record never + hands it out. - Per-install directories keep DEV tags, NIGHTLY and Release apart; an install only lists its own directory. Cross-install adoption is an explicit user action (cx-14hn5), never automatic. @@ -283,7 +280,7 @@ never infers an agent from a process name, argv, foreground group or tree. - D5 (chief): peer checks that walk parent processes (control socket, computer-use helper) become a capability token the app gives its children. -- D6 (pending, security policy): the frozen holder checks the macOS peer's - code identity (same Team ID + daemon identifier, audit token) before - HELLO; ad hoc DEV builds skip it. A frozen holder cannot add this later. - Recommend: freeze it now (holder-protocol-v1.md section 7). +- D6 (Lawrence): no code-identity check in the holder; it protects only + against other users (socket peer uid + owner token). +- Pipe mode uses one shared offset space; the holder drops the oldest output + and never blocks the child (chief, confirmed 2026-10-14). From cfc5ef4b2c0ce29492a4811f9167c81b3ba4e4e2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:34:01 -0700 Subject: [PATCH 134/183] cmux-tui: terminal host records may name a per-install socket dir (cx-t2sqm.13) Host records were already per build (`/terminal-hosts-/`, and each build has its own app session), but every build of a user binds its host sockets in one flat `/tmp/cmux-th-/`. Phase 1 of 2, so a rollback never strands a live terminal: - Record validation accepts a socket in the per-install directory `/tmp/cmux-th-//.sock` (about 71 bytes, under the 104-byte sun_path limit; 0700, checked like the parent) or the legacy flat path; cleanup of a proven-dead record removes either. - New hosts still bind flat, which every older build accepts. Debug builds bind per-install with CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT=1. Phase 2 (cx-t2sqm.18) switches the bind once every build in the rollback window has this acceptance, then drops the flat path and collects empty dirs. - Windows keeps its flat per-user directory. - Behavior test (terminal_host_recovery/shared_endpoint_dir.rs): daemons of two sessions bind their hosts in two sibling private install directories under /tmp/cmux-th-; a restarted default (flat-binding) daemon adopts the per-install host and input still works. The same-session test still proves one daemon's cleanup spares the other's host. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../shared/attachment/launch.rs | 6 +- .../terminal_host_runtime/shared/records.rs | 4 +- .../shared/unadoptable.rs | 8 +- .../src/terminal_host_runtime/sys/unix.rs | 69 +++++++++++++++- .../sys/windows/seams.rs | 26 ++++++ .../unix/adopt_launch.rs | 6 +- .../shared_endpoint_dir.rs | 79 ++++++++++++++++++- plans/cmux-next/pty-holder.md | 1 + 8 files changed, 184 insertions(+), 15 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/attachment/launch.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/attachment/launch.rs index d00402c58a20..014692239199 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/attachment/launch.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/attachment/launch.rs @@ -150,9 +150,9 @@ pub(crate) fn launch_terminal_host_seeded( // endpoint short; the private durable record still carries its full // canonical identity and owner capability. let uid = sys::file_owner(root)?; - let endpoint_root = sys::endpoint_dir(uid); - prepare_endpoint_dir(&endpoint_root)?; - let endpoint = endpoint_root.join(format!("{terminal_hex}.sock")); + prepare_endpoint_dir(&sys::endpoint_dir(uid))?; + prepare_endpoint_dir(&sys::host_endpoint_dir(uid, root))?; + let endpoint = sys::host_endpoint(uid, root, &terminal_hex); let record_path = crate::platform::normalize_filesystem_path(root.join(format!("{terminal_hex}.json"))); if record_path.exists() || endpoint.exists() { diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/records.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/records.rs index a71729b00f31..9e7bb524be1e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/records.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/records.rs @@ -103,8 +103,8 @@ pub fn validate_terminal_host_record( anyhow::bail!("terminal-host record filename is not canonical"); } let uid = sys::file_owner(parent)?; - let expected_endpoint = sys::canonical_endpoint(uid, &record.terminal_id); - if Path::new(&record.endpoint) != expected_endpoint { + let expected = sys::canonical_endpoints(uid, parent, &record.terminal_id); + if !expected.iter().any(|endpoint| Path::new(&record.endpoint) == endpoint) { anyhow::bail!("terminal-host endpoint is not canonical"); } if let Ok(metadata) = fs::symlink_metadata(record_path) diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/unadoptable.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/unadoptable.rs index b4cc8087dca1..3c1846408523 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/unadoptable.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/unadoptable.rs @@ -161,11 +161,13 @@ pub fn wait_for_unadoptable_terminal_host_exit( Err(error) => return Err(error.into()), } let Some(parent) = record.record_path.parent() else { return Ok(()) }; - let endpoint = sys::canonical_endpoint(sys::file_owner(parent)?, &record.terminal_id); + let endpoints = sys::canonical_endpoints(sys::file_owner(parent)?, parent, &record.terminal_id); let _ = fs::remove_file(&record.record_path); let _ = fs::remove_file(marker); - if fs::symlink_metadata(&endpoint).is_ok_and(|metadata| sys::is_endpoint_file(&metadata)) { - let _ = fs::remove_file(endpoint); + for endpoint in endpoints { + if fs::symlink_metadata(&endpoint).is_ok_and(|metadata| sys::is_endpoint_file(&metadata)) { + let _ = fs::remove_file(endpoint); + } } Ok(()) } diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/unix.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/unix.rs index c564af6254ce..ac17b2163f7d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/unix.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/unix.rs @@ -80,11 +80,78 @@ pub(crate) fn endpoint_dir(owner: FileOwner) -> PathBuf { PathBuf::from("/tmp").join(format!("cmux-th-{owner}")) } -/// The only endpoint a record of `owner`'s host may name. +/// The legacy flat endpoint of `owner`'s host (hosts from before cx-t2sqm.13). pub(crate) fn canonical_endpoint(owner: FileOwner, terminal_id: &str) -> PathBuf { PathBuf::from("/tmp").join(format!("cmux-th-{owner}")).join(format!("{terminal_id}.sock")) } +/// The per-install socket directory of the records in `record_root` +/// (cx-t2sqm.13): `/tmp/cmux-th-//`, where `` is the +/// 16-hex token of the record directory's session (`terminal-hosts-`, +/// one per build: the app session names the bundle id and tag), else a +/// token of the record directory's path. A build's cleanup of its own socket +/// directory cannot touch another build's sockets. +pub(crate) fn install_endpoint_dir(owner: FileOwner, record_root: &Path) -> PathBuf { + let token = record_root + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix("terminal-hosts-")) + .filter(|token| token.len() == 16 && token.bytes().all(|b| b.is_ascii_hexdigit())) + .map(str::to_owned) + .unwrap_or_else(|| { + // Components, so `/x/root/` and `/x/root` name one directory. + let path: PathBuf = record_root.components().collect(); + super::super::shared::codec::stable_token(&path.to_string_lossy()) + }); + endpoint_dir(owner).join(token) +} + +/// Where a new host of `record_root` binds its socket. +/// +/// Two phases, so a rollback never strands a live terminal: this build +/// ACCEPTS records that name the per-install path but still BINDS new hosts +/// in the flat directory, which every older build accepts. A later build +/// switches the bind (cx-t2sqm.18) once builds without this acceptance are +/// out of the rollback window. Debug builds bind per-install when +/// `CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT=1` (behavior tests). +pub(crate) fn host_endpoint(owner: FileOwner, record_root: &Path, terminal_id: &str) -> PathBuf { + if bind_per_install() { + install_endpoint_dir(owner, record_root).join(format!("{terminal_id}.sock")) + } else { + canonical_endpoint(owner, terminal_id) + } +} + +/// The directory [`host_endpoint`] binds in. +pub(crate) fn host_endpoint_dir(owner: FileOwner, record_root: &Path) -> PathBuf { + if bind_per_install() { install_endpoint_dir(owner, record_root) } else { endpoint_dir(owner) } +} + +fn bind_per_install() -> bool { + #[cfg(debug_assertions)] + { + std::env::var_os("CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT").is_some_and(|value| value == "1") + } + #[cfg(not(debug_assertions))] + { + false + } +} + +/// The endpoints a record in `record_root` may name: its install's +/// directory, or the legacy flat directory (a host an older build spawned +/// keeps working after an update). +pub(crate) fn canonical_endpoints( + owner: FileOwner, + record_root: &Path, + terminal_id: &str, +) -> [PathBuf; 2] { + [ + install_endpoint_dir(owner, record_root).join(format!("{terminal_id}.sock")), + canonical_endpoint(owner, terminal_id), + ] +} + pub(crate) fn is_endpoint_file(metadata: &fs::Metadata) -> bool { metadata.file_type().is_socket() } diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/windows/seams.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/windows/seams.rs index eae684d51bd4..f99ae0fe8c3a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/windows/seams.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/windows/seams.rs @@ -82,6 +82,32 @@ pub(crate) fn canonical_endpoint(_owner: FileOwner, terminal_id: &str) -> PathBu endpoint::endpoint_dir().join(format!("{terminal_id}.sock")) } +/// Windows keeps one flat endpoint directory per user (no per-install +/// directory yet, cx-t2sqm.13). +pub(crate) fn install_endpoint_dir(owner: FileOwner, _record_root: &Path) -> PathBuf { + endpoint_dir(owner) +} + +/// The directory [`host_endpoint`] binds in. +pub(crate) fn host_endpoint_dir(owner: FileOwner, _record_root: &Path) -> PathBuf { + endpoint_dir(owner) +} + +/// Where a new host binds its endpoint. +pub(crate) fn host_endpoint(owner: FileOwner, _record_root: &Path, terminal_id: &str) -> PathBuf { + canonical_endpoint(owner, terminal_id) +} + +/// The endpoints a record may name. +pub(crate) fn canonical_endpoints( + owner: FileOwner, + record_root: &Path, + terminal_id: &str, +) -> [PathBuf; 2] { + let endpoint = host_endpoint(owner, record_root, terminal_id); + [endpoint.clone(), endpoint] +} + /// An AF_UNIX socket file is a reparse point (`IO_REPARSE_TAG_AF_UNIX`); /// std does not expose the tag, so any non-directory reparse point counts. /// Callers only remove such an entry inside our owner-only directory. diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/unix/adopt_launch.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/unix/adopt_launch.rs index 6a0a6d440bac..4c230a1cd7b7 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/unix/adopt_launch.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/unix/adopt_launch.rs @@ -382,9 +382,9 @@ pub(crate) fn launch_terminal_host_adopting_with_program_status( let terminal_hex = terminal_id.to_hex(); let launch_publication_lock = reserve_terminal_host_publication(root)?; let uid = fs::metadata(root)?.uid(); - let endpoint_root = PathBuf::from("/tmp").join(format!("cmux-th-{uid}")); - prepare_endpoint_dir(&endpoint_root)?; - let endpoint = endpoint_root.join(format!("{terminal_hex}.sock")); + prepare_endpoint_dir(&sys::endpoint_dir(uid))?; + prepare_endpoint_dir(&sys::host_endpoint_dir(uid, root))?; + let endpoint = sys::host_endpoint(uid, root, &terminal_hex); let record_path = crate::platform::normalize_filesystem_path(root.join(format!("{terminal_hex}.json"))); let predecessor = predecessor(&record_path, &adoption.identity)?; diff --git a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/shared_endpoint_dir.rs b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/shared_endpoint_dir.rs index e7c4889b767a..2fb2fed7c28b 100644 --- a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/shared_endpoint_dir.rs +++ b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/shared_endpoint_dir.rs @@ -1,13 +1,16 @@ -//! Every daemon of one uid binds its terminal hosts' sockets in the same +//! Every daemon of one uid binds its terminal hosts' sockets under the same //! `/tmp/cmux-th-` directory, whatever its HOME or state directory -//! (`sun_path` is about 100 bytes on macOS, so the endpoint stays short). +//! (`sun_path` is about 100 bytes on macOS, so the endpoint stays short), in +//! the flat directory today; a record may also name a per-install +//! subdirectory (one per session; each build has its own app session, +//! cx-t2sqm.13), where a later build binds (cx-t2sqm.18). //! Records and liveness proofs stay in each daemon's own state directory, and //! a daemon reaches the shared directory only through the endpoint of a //! record it owns. This test proves that one daemon's recovery and cleanup //! leave another daemon's host and socket untouched. use super::*; -use std::os::unix::fs::FileTypeExt; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; #[test] fn a_daemon_prunes_only_its_own_hosts_in_the_shared_endpoint_directory() { @@ -78,3 +81,73 @@ fn a_daemon_prunes_only_its_own_hosts_in_the_shared_endpoint_directory() { ) .expect("daemon A's host stopped accepting renderers"); } + +/// cx-t2sqm.13: daemons of two installs (two sessions: each build has its +/// own app session) bind their hosts' sockets in two per-install +/// directories under the shared `/tmp/cmux-th-`, so one build's socket +/// cleanup never reaches another build's sockets; both endpoints stay short. +/// This build accepts the per-install path but binds flat by default (two +/// phases, cx-t2sqm.18); the debug seam makes it bind per-install here, and +/// a restarted daemon adopts a host at that path. +#[test] +fn hosts_of_two_installs_bind_in_two_install_directories() { + let mut a = RecoveryHarness::start_unstarted("install-dir-a"); + a.extra_env.push(("CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT".into(), "1".into())); + a.restart(); + let mut b = RecoveryHarness::start_unstarted("install-dir-b"); + b.session = "host-recovery-other-install".into(); + b.extra_env.push(("CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT".into(), "1".into())); + b.restart(); + assert_ne!(a.session, b.session); + + run_cat_workspace(&a.socket, 1, "install-a"); + run_cat_workspace(&b.socket, 1, "install-b"); + let (_, a_record) = wait_for_host_records(&a.host_root(), 1).remove(0); + let (_, b_record) = wait_for_host_records(&b.host_root(), 1).remove(0); + let a_endpoint = PathBuf::from(&a_record.endpoint); + let b_endpoint = PathBuf::from(&b_record.endpoint); + let a_dir = a_endpoint.parent().unwrap(); + let b_dir = b_endpoint.parent().unwrap(); + assert_ne!(a_dir, b_dir, "two installs share a socket directory"); + assert_eq!(a_dir.parent(), b_dir.parent(), "the install directories are not siblings"); + // SAFETY: getuid has no preconditions. + let shared = PathBuf::from(format!("/tmp/cmux-th-{}", unsafe { libc::getuid() })); + assert_eq!(a_dir.parent(), Some(shared.as_path())); + for endpoint in [&a_endpoint, &b_endpoint] { + assert!(endpoint.as_os_str().len() < 104, "endpoint too long for sun_path: {endpoint:?}"); + assert!( + fs::symlink_metadata(endpoint).is_ok_and(|metadata| metadata.file_type().is_socket()), + "no host socket at {endpoint:?}" + ); + } + let mode = fs::symlink_metadata(a_dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "the install directory is not private"); + + // A daemon that binds flat (the default) adopts the per-install host + // after a restart: the record validates and the host keeps serving. + let (a_terminal, a_host) = (a_record.terminal_id.clone(), a_record.host_pid); + a.extra_env.clear(); + a.sigkill(); + a.restart(); + let deadline = Instant::now() + test_timeout(Duration::from_secs(15)); + let surface = loop { + let resolved = request( + &a.socket, + serde_json::json!({"id": 5, "cmd": "resolve-terminal", "terminal_id": &a_terminal}), + ); + if resolved["lifecycle"] == "running" + && let Some(surface) = resolved["surface"].as_u64() + { + break surface; + } + assert!(Instant::now() < deadline, "the per-install host was not adopted: {resolved}"); + std::thread::sleep(Duration::from_millis(50)); + }; + let marker = format!("install-dir-adopted-{}", std::process::id()); + request( + &a.socket, + serde_json::json!({"id": 6, "cmd": "send", "surface": surface, "text": format!("{marker}\n")}), + ); + assert!(wait_for_screen(&a.socket, surface, &marker).contains(&marker)); + assert_eq!(wait_for_host_records(&a.host_root(), 1)[0].1.host_pid, a_host); +} diff --git a/plans/cmux-next/pty-holder.md b/plans/cmux-next/pty-holder.md index 0229ef2a42ce..996dca068162 100644 --- a/plans/cmux-next/pty-holder.md +++ b/plans/cmux-next/pty-holder.md @@ -153,6 +153,7 @@ replica). Agent hosts move the same way: new agents start under schema, id, mode, install (12-hex hash of bundle id + tag), session, cwd, argv, start time, protocol min/max, capability bits, socket path, lease nonce. The record has no pid fields: nothing finds or signals a holder by pid. +- Today (cx-t2sqm.13 landed): host records live in `/terminal-hosts-/` (one per build, since each build has its own app session). Records may name a socket in the per-install directory `/tmp/cmux-th-//` or the legacy flat one; new hosts still bind flat so a rollback to an older build never strands a terminal, and a later build switches the bind (cx-t2sqm.18). Target for `cmux-holder`: - Records and sockets live in a per-install runtime directory (0700): `/cmux/holders//`, where the install id is a short hash (12 hex) of BuildIdentity's stable part (bundle id + tag), NOT the From 2660428db568da341fe5774aa64d98f2714aa989 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:48:03 -0700 Subject: [PATCH 135/183] cmux-next: CmuxNextApp cap back to 113692 (frame-strip proof analysis and animation palette toggles leave the App target) (cx-me3xd) The debug.new_tab frame_strip analysis (frame comparisons, PNGs, report fields) moves to WindowFrameStrip in CmuxNextDesign beside WindowFrame and MotionFrameProgress; the drag_new gesture moves to TabStripDebug.dragFirstTab in CmuxNextTabs. DebugFrameStrip keeps only the wiring (record, send key or gesture, spans), so plus, drag_new and every report field stay. The ui.animations. toggles and bounce bind through AnimationKindsSetting.bindActions (CmuxNextSettings) on the shared SettingChoiceActions save, as ChromeStyleSetting does (ported from nx-grow-land-fallback b1e0c989). CmuxNextApp 113810 -> 113692; the cap follows. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxNextApp/Control/DebugFrameStrip.swift | 139 +++--------------- .../Handlers/AppearanceHandlers.swift | 17 +-- .../Windows/WindowFrameStrip.swift | 87 +++++++++++ .../AnimationKindsSetting+Actions.swift | 22 +++ .../TabStripDebug+DragProof.swift | 34 +++++ scripts/cmux-next/app-size-baseline.txt | 2 +- 6 files changed, 163 insertions(+), 138 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WindowFrameStrip.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting+Actions.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripDebug+DragProof.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift index 8ca9f9e6b718..b98038bbea9a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift @@ -6,29 +6,13 @@ import CmuxNextSettings import CmuxNextTabs import CmuxNextWakeups -/// `debug.new_tab {action: "frame_strip"}` (DEBUG builds): the window as the -/// window server composited it, frame after frame, around one key sent -/// through the real key path (`key`, default `n`, with Command: Cmd-N; `t` -/// for Cmd-T). Frames are read off the main actor back to back for -/// `duration_ms` (default 700) after the key, and some before it. -/// -/// A paint-stable opening changes only the content area of the pane that -/// shows the new page (its `contentHost`), and that in one step: every frame -/// outside it equals the frame before the key or the last frame -/// (`intermediate` counts the ones that equal neither), and no frame shows -/// the content area blank, one flat color that was not there before the key (`blank`). PNGs of every frame and -/// one strip (`strip.png`, the frames side by side) go to `dir`. -/// -/// `modifiers` (default `["command"]`) sends another chord. In the content area `settled_ms` is the -/// first frame equal to the last for good, `first_change_ms` the first unlike before the key, and -/// `content_intermediate` counts frames equal to neither. The pane's strip (its tabs slide) is left -/// out of those and of `intermediate`; `strip_intermediate` counts its frames equal to neither, and -/// `desync` those where one of strip and content shows the new tab and the other the old one. -/// Motion proof knobs and fields: ``MotionFrameProgress``. +/// `debug.new_tab {action: "frame_strip"}` (DEBUG builds): the window as the window server +/// composited it, frame after frame, around one key sent through the real key path (`key`, default +/// `n`, with `modifiers`, default `["command"]`: Cmd-N; `t` for Cmd-T) or a `gesture` (`plus`, +/// `drag_new`). Frames are read off the main actor back to back for `duration_ms` (default 700) after +/// the key, and some before it. The report: ``WindowFrameStrip``; motion knobs: ``MotionFrameProgress``. @MainActor enum DebugFrameStrip { - typealias Frame = WindowFrame - static func run(_ params: [String: JSONValue], _ controller: WindowController, _ services: AppServices) async -> JSONValue { guard let window = controller.window, window.windowNumber > 0 else { return .object(["error": .string("no window")]) } let number = CGWindowID(window.windowNumber) @@ -50,7 +34,10 @@ enum DebugFrameStrip { pane.stripModel.send(.newTab(after: nil)) sent = .string("plus") } else if params["gesture"]?.stringValue == "drag_new", let pane = controller.content?.focusedPane { - sent = await dragWhileNewTab(pane, services) + let dragged = await TabStripDebug.dragFirstTab(in: pane.view.stripView) { + _ = services.registry.perform("newSurface", invocation: ActionInvocation(target: ActionTargetRef(kind: .pane, id: pane.paneKey), origin: .cli)) + } + sent = .string(dragged ? "drag_new" : "drag_new: no tab") } else { sent = DebugKey.send(["key": .string(params["key"]?.stringValue ?? "n"), "modifiers": params["modifiers"] ?? .array([.string("command")]), "window": .string(controller.state.id)], services: services) @@ -58,105 +45,15 @@ enum DebugFrameStrip { let sentAt = BenchSpans.ms(.now - start), sentTime = CACurrentMediaTime() let frames = await recording.value let spans = BenchSpans.end() - guard let first = frames.first, let last = frames.last, let before = frames.last(where: { $0.milliseconds < sentAt }) else { - return .object(["error": .string("no frames"), "key": sent]) - } - let content = params["content"]?.stringValue == "none" ? nil - : (controller.content?.focusedPane?.view.contentHost).flatMap { rect(of: $0, window: window, frame: first) } - // The pane's strip, apart: its own motion (tabs sliding over) is not content. - let strip = (controller.content?.focusedPane?.view.stripView) - .flatMap { $0.isHiddenOrHasHiddenAncestor ? nil : rect(of: $0, window: window, frame: first) } - try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - var rows: [JSONValue] = [] - var intermediate = 0, blank = 0, contentIntermediate = 0, desync = 0, stripIntermediate = 0 - var settledAt: Double?, firstChange: Double? - var progress = MotionFrameProgress(jump: params["jump"]?.doubleValue) - let shown = frames.firstIndex { $0.milliseconds >= before.milliseconds } ?? frames.endIndex - // Off the main actor: six comparisons a frame stalled it past the watchdog in a Debug build. - let measures = await WindowFrame.measure(Array(frames[shown...]), before: before, last: last, content: content, strip: strip) - for (offset, m) in measures.enumerated() { - let index = shown + offset, frame = frames[index] - let (insideBefore, insideFinal, stripBefore, stripFinal) = (m.insideBefore, m.insideFinal, m.stripBefore, m.stripFinal) - let isIntermediate = m.outsideBefore > tolerance && m.outsideFinal > tolerance - // One of the strip and the content at the new tab, the other still at the old one (L4). - let isDesync = strip != nil - && ((stripFinal <= tolerance && stripBefore > tolerance && insideBefore <= tolerance && insideFinal > tolerance) - || (insideFinal <= tolerance && insideBefore > tolerance && stripBefore <= tolerance && stripFinal > tolerance)) - // Neither the content before the key nor the settled one; blank: flat too (an empty terminal is flat). - let isBlank = content != nil && m.flatness > 0.995 && insideBefore > tolerance && insideFinal > tolerance - let isContentIntermediate = frame.milliseconds > sentAt && insideBefore > tolerance && insideFinal > tolerance - if isIntermediate { intermediate += 1 } - progress.add(outsideBefore: m.outsideBefore, outsideFinal: m.outsideFinal) - if isBlank { blank += 1 } - if isContentIntermediate { contentIntermediate += 1 } - if frame.milliseconds > sentAt { - if stripBefore > tolerance && stripFinal > tolerance { stripIntermediate += 1 } - if isDesync { desync += 1 } - if firstChange == nil, insideBefore > tolerance { firstChange = frame.milliseconds - sentAt } - if insideFinal > tolerance { settledAt = nil } else if settledAt == nil { settledAt = frame.milliseconds - sentAt } - } - let name = String(format: "frame-%03d.png", index) - frame.write(to: (dir as NSString).appendingPathComponent(name)) - rows.append(.object([ - "ms": .number((frame.milliseconds - sentAt).rounded()), "file": .string(name), - "outside_vs_before": .number(m.outsideBefore), "outside_vs_final": .number(m.outsideFinal), "intermediate": .bool(isIntermediate), - "blank": .bool(isBlank), "desync": .bool(isDesync), "content_intermediate": .bool(isContentIntermediate), - ])) - } - WindowFrame.writeStrip(frames.filter { $0.milliseconds >= before.milliseconds }, to: (dir as NSString).appendingPathComponent("strip.png")) - let motion = progress.fields(moving: intermediate, tabs: MotionTravelSummary("tabs.travel", after: sentTime)).mapValues(JSONValue.number) - let report: [String: JSONValue] = [ - "key": sent, "dir": .string(dir), "frames": .array(rows), "intermediate": .number(Double(intermediate)), - "blank": .number(Double(blank)), "frame_count": .number(Double(rows.count)), - "content_intermediate": .number(Double(contentIntermediate)), "desync": .number(Double(desync)), - "strip_intermediate": .number(Double(stripIntermediate)), - "settled_ms": settledAt.map { .number($0.rounded()) } ?? .null, "first_change_ms": firstChange.map { .number($0.rounded()) } ?? .null, - "spans": .array(spans.map { .object(["name": .string($0.name), "ms": .number(($0.start - sentAt).rounded()), "duration_ms": .number($0.milliseconds)]) }), - "mean_interval_ms": .number(frames.count > 1 ? (last.milliseconds - first.milliseconds) / Double(frames.count - 1) : 0), - ] - return .object(report.merging(motion) { first, _ in first }) - } - - /// `gesture: "drag_new"`: drags the focused strip's first tab right in 12 steps, one per frame - /// (synthetic events into the strip's own handlers), and at step 4 a script opens a terminal tab - /// in the pane (`newSurface` with the CLI's origin): the drag's springs must keep running. - private static func dragWhileNewTab(_ pane: PaneController, _ services: AppServices) async -> JSONValue { - let strip = pane.view.stripView - guard let window = strip.window, let id = strip.displayedOrder.first, let start = strip.tabCenterInWindow(id) else { - return .string("drag_new: no tab") - } - func send(_ type: NSEvent.EventType, _ dx: CGFloat) { - guard let event = NSEvent.mouseEvent(with: type, location: CGPoint(x: start.x + dx, y: start.y), modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, - context: nil, eventNumber: 0, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1) - else { return } - switch type { - case .leftMouseDown: strip.mouseDown(with: event) - case .leftMouseUp: strip.mouseUp(with: event) - default: strip.mouseDragged(with: event) - } - } - send(.leftMouseDown, 0) - for step in 1...12 { - let tick = DemandTimer(owner: "debug.frame_strip.drag") - await withCheckedContinuation { (done: CheckedContinuation) in tick.schedule(after: .milliseconds(16)) { @MainActor in done.resume() } } - send(.leftMouseDragged, CGFloat(step) * 18) - if step == 4 { - _ = services.registry.perform("newSurface", invocation: ActionInvocation(target: ActionTargetRef(kind: .pane, id: pane.paneKey), origin: .cli)) - } - } - send(.leftMouseUp, 12 * 18) - return .string("drag_new") - } - - /// `host`'s bounds in `frame` pixels (top-left origin). - private static func rect(of host: NSView, window: NSWindow, frame: Frame) -> CGRect? { - guard host.window === window, - let windowHeight = window.contentView?.superview?.bounds.height ?? window.contentView?.bounds.height - else { return nil } - let rect = host.convert(host.bounds, to: nil) - let sx = Double(frame.width) / window.frame.width, sy = Double(frame.height) / windowHeight - return CGRect(x: rect.minX * sx, y: (windowHeight - rect.maxY) * sy, width: rect.width * sx, height: rect.height * sy) + let pane = controller.content?.focusedPane + guard let report = await WindowFrameStrip.report(frames, window: window, sentAt: sentAt, sentTime: sentTime, + content: params["content"]?.stringValue == "none" ? nil : pane?.view.contentHost, + strip: pane?.view.stripView, tolerance: tolerance, jump: params["jump"]?.doubleValue, dir: dir), + case .object(var fields)? = JSONValue(foundation: report) + else { return .object(["error": .string("no frames"), "key": sent]) } + fields["key"] = sent + fields["spans"] = .array(spans.map { .object(["name": .string($0.name), "ms": .number(($0.start - sentAt).rounded()), "duration_ms": .number($0.milliseconds)]) }) + return .object(fields) } } #endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift index 151254f0642e..9ae2f4467422 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift @@ -38,13 +38,7 @@ enum AppearanceHandlers { } SettingChoiceActions.bind(MotionSpeed.allCases, into: registry, id: { "appearance.animationSpeed.\($0.rawValue)" }, path: AnimationSpeedSetting.configPath, fileValue: { .string($0.rawValue) }, save: save) { context.design.animationSpeed = $0 } - for kind in MotionKind.allCases { - registry.bind(ActionID(rawValue: "appearance.animations.\(kind.rawValue).toggle"), run: { _ in try toggleAnimation(kind, context) }) - } - // `ui.animations.bounce`: none (the default) removes the key. - SettingChoiceActions.bind(MotionBounce.allCases, into: registry, id: { "appearance.animations.bounce.\($0.rawValue)" }, - path: AnimationKindsSetting.bouncePath, fileValue: { $0 == AnimationKindsSetting.bounceFallback ? nil : .string($0.rawValue) }, - save: save) { context.design.animationBounce = $0 } + AnimationKindsSetting.bindActions(into: registry, design: context.design, save: save) SettingChoiceActions.bind(CenterFocusedColumn.allCases, into: registry, id: { "layout.centerFocusedColumn.\($0 == .onOverflow ? "onOverflow" : $0.rawValue)" }, path: CenterFocusedColumnSetting.configPath, fileValue: { .string($0.rawValue) }, save: save) { context.design.centerFocusedColumn = $0 } ChromeStyleSetting.bindActions(into: registry, design: context.design, save: save) @@ -150,15 +144,6 @@ enum AppearanceHandlers { context.writeSetting("set titlebar", WindowTitlebarSetting.configPath, value, reloadOnFailure: true) } - /// `ui.animations.`: flipped at once, then written (its default removes the key). - private static func toggleAnimation(_ kind: MotionKind, _ context: AppActionContext) throws { - try requireUnmanaged(AnimationKindsSetting.path(kind), context) - let on = !context.design.enabledAnimations.contains(kind) - if on { context.design.enabledAnimations.insert(kind) } else { context.design.enabledAnimations.remove(kind) } - let value: JSONValue? = on == AnimationKindsSetting.defaultEnabled.contains(kind) ? nil : .bool(on) - context.writeSetting("toggle \(kind.rawValue) animations", AnimationKindsSetting.path(kind), value, reloadOnFailure: true) - } - /// Body size in points: the override, else the density default. static func interfaceSize(_ design: DesignSettings = .shared) -> Double { Double(design.overrides[.chromeFontSize] ?? (design.density == .compact ? 12 : 13)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WindowFrameStrip.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WindowFrameStrip.swift new file mode 100644 index 000000000000..5942bfa16a2e --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WindowFrameStrip.swift @@ -0,0 +1,87 @@ +#if DEBUG +public import AppKit +import Foundation + +/// The analysis half of `debug.new_tab {action: "frame_strip"}` (DEBUG builds): the app sends the +/// key or gesture and records the window (``WindowFrame``); this compares the frames and writes them. +/// +/// A paint-stable opening changes only the content area of the pane that shows the new page (its +/// `contentHost`), and that in one step: every frame outside it equals the frame before the key or +/// the last frame (`intermediate` counts the ones that equal neither), and no frame shows the content +/// area blank, one flat color that was not there before the key (`blank`). PNGs of every frame and one +/// strip (`strip.png`, the frames side by side) go to `dir`. +/// +/// In the content area `settled_ms` is the first frame equal to the last for good, `first_change_ms` +/// the first unlike before the key, and `content_intermediate` counts frames equal to neither. The +/// pane's strip (its tabs slide) is left out of those and of `intermediate`; `strip_intermediate` +/// counts its frames equal to neither, and `desync` those where one of strip and content shows the +/// new tab and the other the old one. Motion proof knobs and fields: ``MotionFrameProgress``. +public enum WindowFrameStrip { + /// The report fields (Foundation values) of `frames`, the key sent at `sentAt` ms + /// (`sentTime` in media time); nil when no frame came before the key. + public static func report(_ frames: [WindowFrame], window: NSWindow, sentAt: Double, sentTime: CFTimeInterval, + content contentHost: NSView?, strip stripView: NSView?, tolerance: Double, jump: Double?, + dir: String) async -> [String: Any]? { + guard let first = frames.first, let last = frames.last, let before = frames.last(where: { $0.milliseconds < sentAt }) else { return nil } + let content = contentHost.flatMap { rect(of: $0, window: window, frame: first) } + // The pane's strip, apart: its own motion (tabs sliding over) is not content. + let strip = stripView.flatMap { $0.isHiddenOrHasHiddenAncestor ? nil : rect(of: $0, window: window, frame: first) } + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + var rows: [[String: Any]] = [] + var intermediate = 0, blank = 0, contentIntermediate = 0, desync = 0, stripIntermediate = 0 + var settledAt: Double?, firstChange: Double? + var progress = MotionFrameProgress(jump: jump) + let shown = frames.firstIndex { $0.milliseconds >= before.milliseconds } ?? frames.endIndex + // Off the main actor: six comparisons a frame stalled it past the watchdog in a Debug build. + let measures = await WindowFrame.measure(Array(frames[shown...]), before: before, last: last, content: content, strip: strip) + for (offset, m) in measures.enumerated() { + let index = shown + offset, frame = frames[index] + let (insideBefore, insideFinal, stripBefore, stripFinal) = (m.insideBefore, m.insideFinal, m.stripBefore, m.stripFinal) + let isIntermediate = m.outsideBefore > tolerance && m.outsideFinal > tolerance + // One of the strip and the content at the new tab, the other still at the old one (L4). + let isDesync = strip != nil + && ((stripFinal <= tolerance && stripBefore > tolerance && insideBefore <= tolerance && insideFinal > tolerance) + || (insideFinal <= tolerance && insideBefore > tolerance && stripBefore <= tolerance && stripFinal > tolerance)) + // Neither the content before the key nor the settled one; blank: flat too (an empty terminal is flat). + let isBlank = content != nil && m.flatness > 0.995 && insideBefore > tolerance && insideFinal > tolerance + let isContentIntermediate = frame.milliseconds > sentAt && insideBefore > tolerance && insideFinal > tolerance + if isIntermediate { intermediate += 1 } + progress.add(outsideBefore: m.outsideBefore, outsideFinal: m.outsideFinal) + if isBlank { blank += 1 } + if isContentIntermediate { contentIntermediate += 1 } + if frame.milliseconds > sentAt { + if stripBefore > tolerance && stripFinal > tolerance { stripIntermediate += 1 } + if isDesync { desync += 1 } + if firstChange == nil, insideBefore > tolerance { firstChange = frame.milliseconds - sentAt } + if insideFinal > tolerance { settledAt = nil } else if settledAt == nil { settledAt = frame.milliseconds - sentAt } + } + let name = String(format: "frame-%03d.png", index) + frame.write(to: (dir as NSString).appendingPathComponent(name)) + rows.append([ + "ms": (frame.milliseconds - sentAt).rounded(), "file": name, + "outside_vs_before": m.outsideBefore, "outside_vs_final": m.outsideFinal, "intermediate": isIntermediate, + "blank": isBlank, "desync": isDesync, "content_intermediate": isContentIntermediate, + ]) + } + WindowFrame.writeStrip(frames.filter { $0.milliseconds >= before.milliseconds }, to: (dir as NSString).appendingPathComponent("strip.png")) + let motion = progress.fields(moving: intermediate, tabs: MotionTravelSummary("tabs.travel", after: sentTime)) + let report: [String: Any] = [ + "dir": dir, "frames": rows, "intermediate": Double(intermediate), "blank": Double(blank), "frame_count": Double(rows.count), + "content_intermediate": Double(contentIntermediate), "desync": Double(desync), "strip_intermediate": Double(stripIntermediate), + "settled_ms": settledAt.map { $0.rounded() as Any } ?? NSNull(), "first_change_ms": firstChange.map { $0.rounded() as Any } ?? NSNull(), + "mean_interval_ms": frames.count > 1 ? (last.milliseconds - first.milliseconds) / Double(frames.count - 1) : 0, + ] + return report.merging(motion.mapValues { $0 as Any }) { first, _ in first } + } + + /// `host`'s bounds in `frame` pixels (top-left origin). + private static func rect(of host: NSView, window: NSWindow, frame: WindowFrame) -> CGRect? { + guard host.window === window, + let windowHeight = window.contentView?.superview?.bounds.height ?? window.contentView?.bounds.height + else { return nil } + let rect = host.convert(host.bounds, to: nil) + let sx = Double(frame.width) / window.frame.width, sy = Double(frame.height) / windowHeight + return CGRect(x: rect.minX * sx, y: (windowHeight - rect.maxY) * sy, width: rect.width * sx, height: rect.height * sy) + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting+Actions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting+Actions.swift new file mode 100644 index 000000000000..8a7eb6072e1f --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting+Actions.swift @@ -0,0 +1,22 @@ +public import CmuxNextActions +public import CmuxNextDesign + +extension AnimationKindsSetting { + /// The palette actions of `ui.animations.*`: `appearance.animations..toggle` flips a + /// kind (its default removes the key) and `appearance.animations.bounce.` sets the + /// bounce (none, the default, removes the key). A managed key refuses and changes nothing. + @MainActor public static func bindActions(into registry: ActionRegistry, design: DesignSettings, save: @escaping SettingChoiceActions.Save) { + for kind in MotionKind.allCases { + registry.bind(ActionID(rawValue: "appearance.animations.\(kind.rawValue).toggle"), invoke: { [weak registry] _ in + let on = !design.enabledAnimations.contains(kind) + if let refusal = save(path(kind), on == defaultEnabled.contains(kind) ? nil : .bool(on)) { + registry?.refuse(refusal) + return + } + if on { design.enabledAnimations.insert(kind) } else { design.enabledAnimations.remove(kind) } + }) + } + SettingChoiceActions.bind(MotionBounce.allCases, into: registry, id: { "appearance.animations.bounce.\($0.rawValue)" }, path: bouncePath, + fileValue: { $0 == bounceFallback ? nil : .string($0.rawValue) }, save: save) { design.animationBounce = $0 } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripDebug+DragProof.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripDebug+DragProof.swift new file mode 100644 index 000000000000..0246a522183d --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripDebug+DragProof.swift @@ -0,0 +1,34 @@ +#if DEBUG +public import AppKit +import CmuxNextWakeups + +extension TabStripDebug { + /// `debug.new_tab {action: "frame_strip", gesture: "drag_new"}` (motion proofs): drags `strip`'s + /// first tab right in 12 steps, one per frame (synthetic events into the strip's own handlers), + /// and runs `midway` at step 4 (the app opens a terminal tab there: the drag's springs must keep + /// running). False when the strip shows no tab. + public static func dragFirstTab(in strip: TabStripView, midway: () -> Void) async -> Bool { + guard let window = strip.window, let id = strip.displayedOrder.first, let start = strip.tabCenterInWindow(id) else { return false } + func send(_ type: NSEvent.EventType, _ dx: CGFloat) { + guard let event = NSEvent.mouseEvent(with: type, location: CGPoint(x: start.x + dx, y: start.y), modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1) + else { return } + switch type { + case .leftMouseDown: strip.mouseDown(with: event) + case .leftMouseUp: strip.mouseUp(with: event) + default: strip.mouseDragged(with: event) + } + } + send(.leftMouseDown, 0) + for step in 1...12 { + let tick = DemandTimer(owner: "debug.frame_strip.drag") + await withCheckedContinuation { (done: CheckedContinuation) in tick.schedule(after: .milliseconds(16)) { @MainActor in done.resume() } } + send(.leftMouseDragged, CGFloat(step) * 18) + if step == 4 { midway() } + } + send(.leftMouseUp, 12 * 18) + return true + } +} +#endif diff --git a/scripts/cmux-next/app-size-baseline.txt b/scripts/cmux-next/app-size-baseline.txt index 643c8ac18db7..3faa47f403ca 100644 --- a/scripts/cmux-next/app-size-baseline.txt +++ b/scripts/cmux-next/app-size-baseline.txt @@ -2,4 +2,4 @@ # Packages/macOS/CmuxNext/Sources/CmuxNextApp/**/*.swift. It may only go down # (check-app-size.sh --update-baseline). New features go in their own target # (plans/cmux-next/code-quality.md SW14). -113810 +113692 From c6416c53474fbdd52b85b10f9dc92f1978bcd222 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:53:35 -0700 Subject: [PATCH 136/183] cmux-tui: a tab group closed with its terminals reopens as its group (cx-gzh.31) (#20868) * test(cmux-tui): a closed screen reopens with its tab group (cx-gzh.31) Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): closing a screen, pane or workspace keeps its tab groups for reopen (cx-gzh.31) A close records every live tab group holding a closing tab (directly, or in a closing screen or workspace) on its closed-history group, naming the group's closed tabs. Reopen forms each group again around the tabs it brought back, or adds them to the group if it is still open. Close Group now goes through the same capture, which replaces its separate attach step; records stored with the older single tab_group still reopen. Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): review fixes for captured tab groups (cx-gzh.31) - A group row a screen, pane or workspace close left behind is not live: reopen forms the group again instead of adding to a group whose pane is gone. Every strip edit prunes such rows before it runs, so the id is free again. - Capture the groups before capture_workspace forgets which closing workspaces are ephemeral, so incognito group names are never recorded. - Record only memberships of tabs still in their group's pane. Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): import group_members (clippy unnecessary qualification) Co-Authored-By: Claude Opus 5.5 * test(cmux-tui): a tab group closed with its terminals reopens as its group (cx-gzh.31) Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): a tab group closed with its terminals keeps its group for reopen (cx-gzh.31) The batch close wrote the pruned tab groups before the resource patch, so the patch's closed-history capture no longer saw the group. Write them after the patch instead. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../topology_close_store.rs | 8 +-- .../reopen_closed_kinds.rs | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/topology_close_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/topology_close_store.rs index 4c5a07599f39..fd7e9b7731b5 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/topology_close_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/topology_close_store.rs @@ -96,9 +96,6 @@ impl WorkspaceRegistry { } None => None, }; - if let Some(tab_groups) = tab_groups { - presentation_store::write_tab_group_state(&tx, tab_groups)?; - } let terminal_batch = close_terminals_in_transaction(&tx, mutation, terminals, "topology-closed")?; // A close that is part of another change (a space delete) writes that @@ -115,6 +112,11 @@ impl WorkspaceRegistry { if before_patch.is_some() { crate::state::closed_history_store::flush_pending_group(&tx)?; } + // After the patch: its closed-history capture reads the groups the + // close takes (Close Group) before this write drops them. + if let Some(tab_groups) = tab_groups { + presentation_store::write_tab_group_state(&tx, tab_groups)?; + } tx.execute( "UPDATE meta SET value = ?1 WHERE key = 'resource_revision'", [revision.to_string()], diff --git a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs index b71e2f8263a5..1772bcacbcca 100644 --- a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs +++ b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs @@ -565,6 +565,55 @@ fn a_closed_tab_group_reopens_as_its_group() { assert_eq!(groups[0]["tab_ids"], serde_json::Value::Array(back), "{groups:?}"); } +/// Close Group that also ends its terminals takes the batch close path. +#[test] +fn a_tab_group_closed_with_its_terminals_reopens_as_its_group() { + let _exclusive = exclusive_process_test(); + let (harness, mut app, pane) = start("reopen-group-end"); + for _ in 0..2 { + app.send(serde_json::json!({"cmd":"new-tab","pane":pane})); + } + let tabs = kinds_workspace(&mut app)["screens"][0]["panes"][0]["tabs"] + .as_array() + .into_iter() + .flatten() + .filter_map(|tab| tab["tab_resource_id"].as_str().map(str::to_string)) + .collect::>(); + assert_eq!(tabs.len(), 3, "{tabs:?}"); + resource_request( + &harness.socket, + "group-end-create", + "tab_group.create", + serde_json::json!({"machine":"current","session":"current","tabs":tabs[1..], + "name":"Build","color":"blue"}), + Some("group-end-create"), + ); + let group_id = tab_groups(&harness, "group-end-list-1")[0]["id"] + .as_str() + .expect("the group has an id") + .to_string(); + + let before = closed_list(&harness, "group-end-closed-1").len(); + app.send(serde_json::json!({"cmd":"close-tab-group","group":group_id,"end_terminals":true})); + let closed = closed_list(&harness, "group-end-closed-2"); + assert_eq!(closed.len(), before + 1, "the close was not recorded: {closed:?}"); + let reopened = resource_request( + &harness.socket, + "group-end-reopen", + "closed.reopen", + serde_json::json!({"machine":"current","session":"current","closed":closed[0]["id"]}), + Some("group-end-reopen"), + )["value"] + .clone(); + let back = reopened["tab_ids"].as_array().cloned().unwrap_or_default(); + assert_eq!(back.len(), 2, "both tabs reopen once: {reopened}"); + let groups = tab_groups(&harness, "group-end-list-2"); + assert_eq!(groups.len(), 1, "the reopen formed no group: {groups:?}"); + assert_eq!(groups[0]["id"], group_id.as_str()); + assert_eq!(groups[0]["name"], "Build"); + assert_eq!(groups[0]["tab_ids"], serde_json::Value::Array(back), "{groups:?}"); +} + #[test] fn a_closed_tab_group_that_emptied_its_screen_reopens_as_its_group() { let _exclusive = exclusive_process_test(); From de357d7162893b125c170e145d4fb0617ff179bf Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:54:12 -0700 Subject: [PATCH 137/183] cx-tgf4h red: cmux chief -p under ACPMUX_AGENT_LAUNCH=deny fails at once with a typed refusal --- .../cmux-tui/tests/cli/chief_autostart.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief_autostart.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief_autostart.rs index 814b4042d3ce..dc439a5d8c7a 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/chief_autostart.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief_autostart.rs @@ -151,6 +151,9 @@ impl Sandbox { .env_remove("CMUX_SOCKET_PATH") .env_remove("CMUX_BUNDLE_ID") .env_remove("CMUX_TAG") + // The build-session kill switch only where a test sets it. + .env_remove("ACPMUX_AGENT_LAUNCH") + .env_remove("CMUX_AGENTS_DISABLED") // The test's own variables last, so no removal above drops one. .envs(env.iter().copied()) .stdin(Stdio::null()) @@ -411,3 +414,35 @@ fn chief_shutdown_tells_the_app_until_the_next_chief_starts_it() { assert!(!marker.exists(), "the next cmux chief takes the marker away"); assert_success(&sandbox.chief(&["shutdown", "--chief-home", home_arg])); } + +#[test] +fn chief_says_at_once_that_agents_are_disabled_and_starts_nothing() { + // cx-tgf4h: a GUI build job runs with ACPMUX_AGENT_LAUNCH=deny (or + // CMUX_AGENTS_DISABLED=1). `cmux chief -p` waited 240 s for a reply that + // no turn could give; it must fail at once with the typed refusal and + // start no daemon and no brain. + let sandbox = Sandbox::new("chief-agents-disabled"); + let home = sandbox.isolated(); + let home_arg = home.to_str().unwrap(); + for (key, value) in [("ACPMUX_AGENT_LAUNCH", "deny"), ("CMUX_AGENTS_DISABLED", "1")] { + let started = Instant::now(); + let output = + sandbox.chief_with(&["--chief-home", home_arg, "-p", "hello"], &[(key, value)]); + assert!(started.elapsed() < Duration::from_secs(10), "{key}: not at once"); + assert_eq!(output.status.code(), Some(1), "{key}: {}", stderr_of(&output)); + assert!(output.stdout.is_empty(), "{key}: no reply"); + let stderr = stderr_of(&output); + assert!(stderr.contains("agents are disabled in this build session"), "{key}: {stderr}"); + + let json = sandbox + .chief_with(&["--chief-home", home_arg, "--json", "-p", "hello"], &[(key, value)]); + assert_eq!(json.status.code(), Some(1), "{key}: {}", stderr_of(&json)); + let error: serde_json::Value = serde_json::from_str(stderr_of(&json).trim()) + .unwrap_or_else(|e| panic!("{key}: typed JSON error: {e}: {}", stderr_of(&json))); + assert_eq!(error["code"], "operation.failed", "{key}: {error}"); + assert_eq!(error["details"]["reason"], "agent_launch_disabled", "{key}: {error}"); + assert_eq!(error["retryable"], false, "{key}: {error}"); + } + assert_eq!(brain_starts(&home), 0, "no brain started"); + assert!(!home.join("tui").exists(), "no session daemon started"); +} From d2b482f92c10e6e6903788c6e6baba0f4edf4a70 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:55:29 -0700 Subject: [PATCH 138/183] Chief: gallery variant for selected text; selection follows the Ghostty config (cx-o3l8w) (#20649) * Chief: selected text takes the page accent tint, not the terminal selection blue (cx-o3l8w) Chief's page inherited --cmux-text-selection, the theme's terminal selection-background (#3f638b slate blue in the default theme), so selected text showed a stray blue in an otherwise gray page. The page now selects with a 24% tint of its accent (the text color under the default neutral accent), like its gray selected row. Terminals and other pages keep the theme's selection color. Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at cea44b3159a2 - vp fmt webviews/src/pages/home-channels/AttachmentViews.tsx - vp fmt webviews/src/pages/home-channels/Composer.tsx - vp fmt webviews/src/pages/home-channels/HomeChannelsPage.tsx - vp fmt webviews/src/pages/home-channels/attachmentService.ts - vp fmt webviews/src/pages/home-channels/icons.tsx Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38577835479 * test: gallery variant for selected Chief text Selects message text under the default theme's slate-blue terminal selection, so the per-PR gallery shows the page tint. Co-Authored-By: Claude Opus 5.5 * fix: clear home-channels lint warnings the PR autofix surfaced The autofix reformatted these files, so the changed-file lint now checks them: useMemo reads the open id it depends on, and the hidden file picker, the shared video and the tray bar carry reasoned disables. Co-Authored-By: Claude Opus 5.5 * test: a page selection color needs its reason on the line The caret-selection guard (cx-wqn5) kept every selection on the Ghostty config's color. The Chief page's accent tint (cx-o3l8w) is a deliberate exception, so it takes the same reasoned-comment escape the caret rule has, and any other override still fails. Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at 51f26402a6df - vp fmt webviews/src/pages/home-channels/cloudHome.ts Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38645432485 * fix: Chief selection follows the Ghostty selection color like every page (Leo, cx-o3l8w) Drops the Chief-only accent tint and the caret-selection test's escape hatch: highlights stay the same throughout the app (cx-wqn5). The gallery keeps a selected-text variant under the default theme. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- webviews/src/pages/home-channels/Composer.tsx | 1 + .../home-channels/home-channels.gallery.tsx | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/webviews/src/pages/home-channels/Composer.tsx b/webviews/src/pages/home-channels/Composer.tsx index 0ed1dfbf5dfc..5b89865fd6a6 100644 --- a/webviews/src/pages/home-channels/Composer.tsx +++ b/webviews/src/pages/home-channels/Composer.tsx @@ -340,6 +340,7 @@ export function Composer({ )} {attachments && ( + // oxlint-disable-next-line jsx-a11y/control-has-associated-label -- a hidden picker; the labeled attach button opens it createStrings(table)); const [root, setRoot] = useState(null); return ( -
+
{root ? ( @@ -208,6 +214,14 @@ export default componentEntry({ await ctx.waitFor(() => ctx.document.querySelector(".lg-palette-none")); }, }, + "selected-text": { + note: "Selected message text under the default theme: it takes the theme's terminal selection color (slate blue), like every page (cx-wqn5).", + props: { select: "c-release", terminalSelection: "#3f638b" }, + play: async (ctx) => { + await ctx.waitFor(() => ctx.document.querySelector(".lg-main .hc-msg.head .hc-md")); + await ctx.selectText({ selector: ".lg-main .hc-msg.head .hc-md" }); + }, + }, offline: { note: "No host answers: the page is offline and empty.", props: { offline: true } }, attachments: { note: "A message with three images (a grid), a video with its poster and a PDF row.", From 153b13c918faecdde2625dc51d48b7808575cf01 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:55:40 -0700 Subject: [PATCH 139/183] fix: a new workspace's sidebar row lands with its content (#20709) * test: a shown new workspace's sidebar row lists and selects in the same turn Red: WindowController.show swaps the new workspace's content in on the window's observation hop, but SidebarBridge updates SidebarModel on its own ObservationStream hop and SidebarView renders a hop after that, so right after showWorkspace the sidebar view still lists only the old row and keeps it selected. The test also checks the settled state after 300 ms, so a fixture that never lists the new row fails there instead. Co-Authored-By: Claude Opus 5.5 * fix: show a workspace with its sidebar row listed and selected in the same turn WindowController.show swapped the new workspace's content in on the window's observation hop, while SidebarBridge reached SidebarModel on its own hop and SidebarView drew it a hop later, so Cmd-N's first frame showed the new content beside the old sidebar (old row selected, no new row). The bridge's rows and selection passes now go through SidebarSameTurn, which observes each one as before and can also run them on demand; the window runs syncNow before root.show, which applies both passes and has SidebarView render now (renderNow). The streams' later values are equal and skipped by the model setters, the selection hold and the view's render. Co-Authored-By: Claude Opus 5.5 * perf: a switch to a shown workspace skips the sidebar's rows pass A parked workspace's row is listed already, so only a new workspace pays the same-turn walk of every workspace; the selection still lands with the content. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../Sources/CmuxNextApp/SidebarBridge.swift | 87 +++++++++---------- .../Sources/CmuxNextApp/SidebarSameTurn.swift | 42 +++++++++ .../Windows/WindowController.swift | 12 +-- .../CmuxNextSidebar/Views/SidebarView.swift | 48 +++++----- .../SidebarSameTurnTests.swift | 72 +++++++++++++++ 5 files changed, 185 insertions(+), 76 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarSameTurn.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSameTurnTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift index c9de4c18bb6a..203a4cfad0f0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift @@ -16,8 +16,8 @@ final class SidebarBridge { unowned let services: AppServices /// Weak: a daemon command's `Task` can outlive the window. weak var state: WindowState? - private var observation: Task? - private var selectionObservation: Task? + /// The rows and selection observations, also run in the turn the window shows a workspace. + private(set) lazy var sameTurn = SidebarSameTurn(view: container.sidebarView) private var widthObservation: Task? /// Item presentation for sidebar sections (SidebarBridge+Sections). var sectionsObservation: Task? @@ -91,9 +91,8 @@ final class SidebarBridge { } func teardown() { - observation?.cancel() + sameTurn.cancel() rowFeed.reset() - selectionObservation?.cancel() widthObservation?.cancel() sectionsObservation?.cancel() appSections?.releaseAll() @@ -118,39 +117,38 @@ final class SidebarBridge { let decorations = customization.rowDecorations self?.rows.patch(changed.map { $0.decorated(by: decorations) }) } - observation = Task { [weak self] in - // `state.id` is read inside: the launch window adopts a saved id. - // The layout too: removing the Home item lists the home workspace. - // And the New Tab pages (a chat lists as a chat) and the muted set. - // The spaces and the current space come from the same observation - // as the rows (cx-5k3r): a space switch reaches the sidebar as one - // change (new space and its rows together), so the sidebar runs - // one slide, not a slide of the old rows and then a row reload. - // The band's layout comes with the rows it projects too (cx-odqn): - // a pinned workspace leaves the list in the turn it joins the band. - for await ((sections, decorations), launching, failed, profiles, active, shown, creating) in ObservationStream({ - // Rows come from the feed: this pass reads no row's status; a row reads its tab selection itself. - let pages = Self.newTabPages(agentTabs, in: machines.local.store), muted = notifications.preferences.mutedWorkspaces - feed.beginPass(inputs: [pages, muted, DesignSettings.shared.attention.showsOnSidebar] as [AnyHashable]) - let (sections, launching, failed) = Self.liveSections( - machines, registry: registry, window: windowState, creations: creations, hidesHome: Self.hidesHome(layout.document), - newTabPages: pages, muted: muted, - top: .make(layout, machines: machines, room: windowState.profileID.rawValue), pageFace: services.sidebarPageFace, - tabTitle: Self.tabTitle(services), rows: feed.rowSource) - return ((sections, services.sidebarCustomization.rowDecorations), launching, failed, SidebarSpaceBar.profiles(machines.local), SidebarProfileKey(windowState.profileID.rawValue), - SidebarRows.visibleLayout(layout.document), PendingWorkspaceRows.shared.rows(in: windowState.id)) - }) { - guard let self else { return } - self.showProfiles(profiles, active: active, launching: launching) - self.show(SidebarSection.decorated(feed.fill(sections), decorations), layout: shown, launching: launching, failed: failed, creating: creating) - // A workspace the window lists shows its own row from here on (cx-9a7wx). - let membership = WindowProfiles.membership(machines) - PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue))) { id in - if let owner = registry.value.owner(of: id), owner != windowState.id { return true } - guard let workspace = WindowProfiles.qualified(id, machines: machines), - let pin = membership?.pins[workspace] else { return false } - return pin != windowState.profileID - } + // `state.id` is read inside: the launch window adopts a saved id. + // The layout too: removing the Home item lists the home workspace. + // And the New Tab pages (a chat lists as a chat) and the muted set. + // The spaces and the current space come from the same observation + // as the rows (cx-5k3r): a space switch reaches the sidebar as one + // change (new space and its rows together), so the sidebar runs + // one slide, not a slide of the old rows and then a row reload. + // The band's layout comes with the rows it projects too (cx-odqn): + // a pinned workspace leaves the list in the turn it joins the band. + sameTurn.observe(listsRows: true, { + // Rows come from the feed: this pass reads no row's status; a row reads its tab selection itself. + let pages = Self.newTabPages(agentTabs, in: machines.local.store), muted = notifications.preferences.mutedWorkspaces + feed.beginPass(inputs: [pages, muted, DesignSettings.shared.attention.showsOnSidebar] as [AnyHashable]) + let (sections, launching, failed) = Self.liveSections( + machines, registry: registry, window: windowState, creations: creations, hidesHome: Self.hidesHome(layout.document), + newTabPages: pages, muted: muted, + top: .make(layout, machines: machines, room: windowState.profileID.rawValue), pageFace: services.sidebarPageFace, + tabTitle: Self.tabTitle(services), rows: feed.rowSource) + return ((sections, services.sidebarCustomization.rowDecorations), launching, failed, SidebarSpaceBar.profiles(machines.local), SidebarProfileKey(windowState.profileID.rawValue), + SidebarRows.visibleLayout(layout.document), PendingWorkspaceRows.shared.rows(in: windowState.id)) + }) { [weak self] value in + let ((sections, decorations), launching, failed, profiles, active, shown, creating) = value + guard let self else { return } + self.showProfiles(profiles, active: active, launching: launching) + self.show(SidebarSection.decorated(feed.fill(sections), decorations), layout: shown, launching: launching, failed: failed, creating: creating) + // A workspace the window lists shows its own row from here on (cx-9a7wx). + let membership = WindowProfiles.membership(machines) + PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue))) { id in + if let owner = registry.value.owner(of: id), owner != windowState.id { return true } + guard let workspace = WindowProfiles.qualified(id, machines: machines), + let pin = membership?.pins[workspace] else { return false } + return pin != windowState.profileID } } // R99: the rows of another space, for the page beside the current one during a swipe. @@ -174,15 +172,12 @@ final class SidebarBridge { self.services.windows.recordSaver.stateDidChange(state) } } - selectionObservation = Task { [weak self] in - // One selection: the shown page's top item, else the shown workspace. - for await selected in ObservationStream({ SidebarNavigation.selectedItem(page: state.page, workspace: state.workspaceID, - creationRow: state.cloudCreation.flatMap { creations.creation($0)?.rowID }, - layout: layout.document, room: state.profileID.rawValue, - refs: WorkspaceLayoutRefs(machines: machines), - home: services.home.dockedHomeID) }) { - self?.selectionHold.offer(selected) // a new workspace's waits for its row (cx-8g3sn) - } + // One selection: the shown page's top item, else the shown workspace. + sameTurn.observe({ SidebarNavigation.selectedItem(page: state.page, workspace: state.workspaceID, + creationRow: state.cloudCreation.flatMap { creations.creation($0)?.rowID }, + layout: layout.document, room: state.profileID.rawValue, + refs: WorkspaceLayoutRefs(machines: machines), home: services.home.dockedHomeID) }) { [weak self] selected in + self?.selectionHold.offer(selected) // a new workspace's waits for its row (cx-8g3sn) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarSameTurn.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarSameTurn.swift new file mode 100644 index 000000000000..4fdf5ef22860 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarSameTurn.swift @@ -0,0 +1,42 @@ +import CmuxNextCompat +import CmuxNextSidebar + +/// The sidebar's rows and selection passes, each observed and also run on +/// demand. The window swaps a workspace's content in on its own observation +/// hop; the bridge reached `SidebarModel` a hop later and the view drew it a +/// hop after that, so the first frame of a new workspace (Cmd-N) showed its +/// content beside the old row selected and no new row. The window runs +/// `syncNow` before the swap: rows, selection and the drawn list land in the +/// same turn. The streams' next values are then equal and skipped (the +/// model's setters, the selection hold, the view's render). A helper beside +/// SidebarBridge (its type is at its size limit). +@MainActor +final class SidebarSameTurn { + private let view: SidebarView + private var passes: [(listsRows: Bool, run: @MainActor () -> Void)] = [] + private var tasks: [Task] = [] + + init(view: SidebarView) { + self.view = view + } + + /// Observes `compute`, handing each value to `apply`; `syncNow` runs it too. `listsRows` + /// marks the rows pass, which walks every workspace. + func observe(listsRows: Bool = false, _ compute: @escaping @MainActor @Sendable () -> Value, + apply: @escaping @MainActor @Sendable (Value) -> Void) { + passes.append((listsRows, { apply(compute()) })) + tasks.append(Task { for await value in ObservationStream(compute) { apply(value) } }) + } + + /// Runs the passes in order (rows, then selection) and draws the sidebar now. `rows` false + /// skips the rows pass: a workspace shown before is listed already, and a switch to it + /// should not pay a walk of every workspace. + func syncNow(rows: Bool) { + for pass in passes where rows || !pass.listsRows { pass.run() } + view.renderNow() + } + + func cancel() { + for task in tasks { task.cancel() } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift index 2f4e6bc6358f..e42e8f625136 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift @@ -205,16 +205,17 @@ final class WindowController: NSWindowController, NSWindowDelegate { if state.machineID != daemon.machineID { state.machineID = daemon.machineID } guard content?.workspace !== workspace else { return } if let current = content { park(current) } - let controller: WorkspaceContentController - if let index = parked.firstIndex(where: { $0.workspace === workspace && $0.daemon === daemon }) { + let controller: WorkspaceContentController, index = parked.firstIndex { $0.workspace === workspace && $0.daemon === daemon } + if let index { controller = parked.remove(at: index) controller.unpark() } else { controller = WorkspaceContentController(workspace: workspace, daemon: daemon, services: services, state: state) } content = controller - // One synchronous swap: the old view leaves (its panes stay mounted, - // paused) and the new one draws in the same frame. + // One synchronous swap: the old view leaves (its panes stay mounted, paused) and the new + // one draws in the same frame, its sidebar row already listed (a parked one's is) and selected. + sidebar.sameTurn.syncNow(rows: index == nil) root.show(controller.contentView) // The workspace's theme first, so the chrome repaints once; no // crossfade, which would fade the swap itself. @@ -228,8 +229,7 @@ final class WindowController: NSWindowController, NSWindowDelegate { titleObservation = Task { [weak self] in for await title in ObservationStream({ workspace.displayName }) { self?.root.titlebar.title = title } } - // The new workspace's panes: the coordinator restores its pane and, - // now that the content is installed, re-applies it. + // The new workspace's panes: the coordinator restores its pane and, now that the content is installed, re-applies it. controller.sendTopology() if let pane = focus.state.pane { focus.send(.contentPresented(pane: pane)) } services.windows.recordSaver.stateDidChange(state) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView.swift index 80d49a2e9d4d..a74ea9c9f4de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView.swift @@ -338,38 +338,38 @@ public final class SidebarView: NSView { var fontSize: CGFloat var titlebarHeight: CGFloat var cards: SidebarBottomCards + init(_ model: SidebarModel, _ design: DesignSettings) { + sectionsRevision = model.observedSectionsRevision + selection = model.selection + selected = model.selectedItem + profiles = model.profiles + activeProfile = model.activeProfileID + filter = model.filterText + layout = model.layout + itemInfo = model.resolvedItemInfo + transientTopItems = model.transientTopItems + collapsedSections = model.collapsedLayoutSections + look = SidebarSectionTunables.currentLook + drawsLines = Borders.drawsLines + preferences = design.sidebarSections + suppressedApps = model.suppressedApps + metrics = .standard + fontSize = Typography.body.pointSize + titlebarHeight = Metrics.titlebarHeight + cards = SidebarBottomCards(update: model.updateCard, nowPlaying: model.nowPlaying, updated: model.updatedCard, notice: model.noticeCard) + } } private func observe() { let model = model, design = design observation = Task { [weak self] in - for await state in ObservationStream({ - RenderState( - sectionsRevision: model.observedSectionsRevision, - selection: model.selection, - selected: model.selectedItem, - profiles: model.profiles, - activeProfile: model.activeProfileID, - filter: model.filterText, - layout: model.layout, - itemInfo: model.resolvedItemInfo, - transientTopItems: model.transientTopItems, - collapsedSections: model.collapsedLayoutSections, - look: SidebarSectionTunables.currentLook, - drawsLines: Borders.drawsLines, - preferences: design.sidebarSections, - suppressedApps: model.suppressedApps, - metrics: .standard, - fontSize: Typography.body.pointSize, - titlebarHeight: Metrics.titlebarHeight, - cards: SidebarBottomCards(update: model.updateCard, nowPlaying: model.nowPlaying, updated: model.updatedCard, notice: model.noticeCard) - ) - }) { - self?.render(state) - } + for await state in ObservationStream({ RenderState(model, design) }) { self?.render(state) } } } + /// Draws the model now, in the caller's turn (SidebarSameTurn); the observation's next, equal state is skipped. + public func renderNow() { render(RenderState(model, design)) } + private func render(_ state: RenderState) { guard state != lastState else { return } let chromeChanged = lastState?.metrics != state.metrics || lastState?.fontSize != state.fontSize diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSameTurnTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSameTurnTests.swift new file mode 100644 index 000000000000..55a00ad3a517 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSameTurnTests.swift @@ -0,0 +1,72 @@ +import AppKit +import CmuxNextBridge +@testable import CmuxNextApp +@testable import CmuxNextDaemon +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// Perceived speed: on Cmd-N the window swapped the new workspace's content in on its +/// observation hop (`WindowController.show`), but the sidebar's rows and selection reached +/// `SidebarModel` on the bridge's own hop and the view drew them a hop after that. The first +/// frame of a new workspace showed its content beside the old sidebar: the old row selected and +/// no new row. The window now shows a workspace with its sidebar row listed and selected. +@MainActor struct SidebarSameTurnTests { + private static func tree(_ keys: [String]) -> DaemonTree { + var tree = DaemonTree(registryID: "s-local", workspaces: keys.enumerated().map { index, key in + WorkspaceSnapshot(id: WorkspaceHandle(rawValue: UInt64(index + 1)), key: WorkspaceKey(rawValue: key), name: key) + }) + tree.personal = PersonalState(workspaces: keys.enumerated().map { index, key in + PersonalWorkspace(sessionID: "s-local", workspaceKey: WorkspaceKey(rawValue: key), index: index) + }) + return tree + } + + private static func connected() throws -> DaemonIdentity { + let json = #"{"app":"cmux-tui","protocol":1,"session":"s-local","generation":"g1"}"# + return try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8)) + } + + private static func listed(_ list: SidebarListView) -> [SidebarRowKey] { list.displayed.rows.map(\.key) } + + @Test func aShownNewWorkspaceListsAndSelectsItsRowInTheSameTurn() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let identity = try Self.connected() + store.noteHandshake(identity) // capability checks read the handshake's identity + store.connectionState = .connected(identity) + store.apply(snapshot: Self.tree(["k1"])) + let record = try JSONDecoder().decode(WindowRecord.self, from: Data(#"{"id":"same-turn","workspace_key":"k1"}"#.utf8)) + let state = WindowState(record: record) + services.windows.registry.provisional[state.id] = ["k1"] + let controller = WindowController(state: state, services: services, frame: NSRect(x: 200, y: 200, width: 1100, height: 720)) + defer { + controller.teardown() + controller.window?.close() + } + controller.showWorkspace(requested: state.workspaceID) + try await Task.sleep(for: .milliseconds(300)) + let list = controller.sidebar.container.sidebarView.list + let old = SidebarRowKey.workspace(WorkspaceID("k1")), new = SidebarRowKey.workspace(WorkspaceID("k2")) + try #require(list.selectedRowKey == old, "the fixture's first workspace is not the sidebar's selected row") + + // Cmd-N: the daemon lists the new workspace in the window, the window selects it, and its + // workspace observation shows it (`WindowController.showWorkspace`). + store.apply(snapshot: Self.tree(["k1", "k2"])) + services.windows.registry.provisional[state.id] = ["k1", "k2"] + state.showWorkspace("k2") + controller.showWorkspace(requested: state.workspaceID) + // What the frame that shows the new content commits: no observation turn has run. + let firstListed = Self.listed(list) + let firstSelected = list.selectedRowKey + + // The settled sidebar, after the observations ran: the fixture does list and select the row. + try await Task.sleep(for: .milliseconds(300)) + #expect(Self.listed(list).contains(new)) + #expect(list.selectedRowKey == new) + // The first frame already showed it. + #expect(firstListed.contains(new)) + #expect(firstSelected == new) + } +} From b9432a556f9cf5204b85c63d2a1c1dab923cf150 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:55:58 -0700 Subject: [PATCH 140/183] cmux NEXT: agent chat Update button updates the agent CLI in place (cx-1mbnu) (#20569) * test: agent chat Update runs the harness update in place (cx-1mbnu) Red: acpmux has no `_acpmux/harness/update`, and the card's Update opens a terminal tab the chat refuses, so the button does nothing. Co-Authored-By: Claude Opus 5.5 * fix: agent chat Update button updates the agent CLI, then restarts the chat (cx-1mbnu) The "Claude Code X is available" card's Update sent tab.open, which a normal chat refuses and the page swallowed. Update now runs acpmux's `_acpmux/harness/update` on this Mac (local chats only, behind a click), shows progress, then offers Restart Chat (`restart_harness` for this chat, held until the turn ends). Errors show inline with Try Again. An updater that exits 0 but leaves the old version is reported as a failure. Co-Authored-By: Claude Opus 5.5 * test: Restart Chat refuses a chat the pane does not show (cx-1mbnu) Red: a page that adopts another chat's id with chat.persistSession can restart that chat's agent. Co-Authored-By: Claude Opus 5.5 * fix: Restart Chat restarts only this pane's own chat (cx-1mbnu) Co-Authored-By: Claude Opus 5.5 * test: Restart Chat survives a health re-read and reports a failed restart (cx-1mbnu) Red: a reconnect that reads health again after the update hides the Restart Chat card, Not Now can drop it mid-update, and a restart that failed reads as done. Co-Authored-By: Claude Opus 5.5 * fix: Restart Chat survives a health re-read, reports failed restarts and names old or slow agent services (cx-1mbnu) The card holds an update it started even once health reads current, Not Now waits for the update, a restart acpmux reports as failed shows its error with Restart Chat kept, and an agent service older than the app or an update past its deadline say so instead of "not running". Co-Authored-By: Claude Opus 5.5 * test: Restart Chat reports a harness that cannot restart and restarts a named Chief chat (cx-1mbnu) Co-Authored-By: Claude Opus 5.5 * fix: Restart Chat reports chats whose harness cannot restart (cx-1mbnu) _acpmux/sessions/restart_harness counted a failed restart in neither bucket, so the reply read as success. It now returns failed and errors [{session, error}], and a Home Chief chat named in sessions restarts; only an unnamed sweep skips the Chief. Co-Authored-By: Claude Opus 5.5 * webviews: keep base's catalog order after the merge; regenerate agent pane strings (cx-1mbnu) Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at 6c025521f750 - vp fmt webviews/test/ime-composition-keys.test.ts - vp fmt webviews/test/new-tab-omnibar-pointer.test.ts - cargo fmt in Native/OptChat/optchat-chief Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38567413643 * chore(next): PR autofix at 31e3548fca59 - cargo fmt in Native/OptChat/optchat-chief Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38570097520 * chore(next): PR autofix at 6a38d278ad99 - regenerated Packages/macOS/CmuxNext/ci-target-graph.json Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38592307504 * acpmux: drop the HarnessRestarts re-export; hub/mod.rs is at its size limit (cx-1mbnu) Co-Authored-By: Claude Opus 5.5 * acpmux: re-export HarnessRestarts now that hub/mod.rs is split; keep base catalog order (cx-1mbnu) Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at 78c93ff55d76 - regenerated Packages/macOS/CmuxNext/ci-target-graph.json Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38615224641 * webviews: keep base catalog order after the base merge (cx-1mbnu) Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at 70cad9350624 - vp fmt webviews/src/pages/home-channels/HomeChannelsPage.tsx Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38635266431 * chore(next): PR autofix at 740634d95719 - vp fmt webviews/src/pages/home-channels/cloudHome.ts - cargo fmt in Native/OptChat/optchat-chief Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38650813603 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- .../CmuxNextAgentPane/AgentPageProvider.swift | 2 +- .../AgentPaneHandshakeReply.swift | 1 + .../AgentPaneHarnessCard.swift | 68 + .../CmuxNextAgentPane/AgentPaneModel.swift | 2 +- .../AgentPaneRequest+Decoding.swift | 10 + .../CmuxNextAgentPane/AgentPaneRequest.swift | 6 + .../AgentPaneTransport.swift | 2 + .../AgentPaneTransportReply.swift | 4 + .../Resources/Localizable.xcstrings | 393 ++++++ .../AgentPaneHarnessCardTests.swift | 38 + .../crates/acpmux/src/harness_health/auto.rs | 123 +- cmux-tui/crates/acpmux/src/hub/health_view.rs | 68 + cmux-tui/crates/acpmux/src/hub/hosts.rs | 8 + cmux-tui/crates/acpmux/src/hub/mod.rs | 1 + .../crates/acpmux/src/hub/route_switch.rs | 80 ++ cmux-tui/crates/acpmux/src/rpc.rs | 7 + .../crates/acpmux/src/server/harness_admin.rs | 48 +- .../crates/acpmux/tests/harness_health.rs | 145 ++ .../crates/acpmux/tests/harness_restart.rs | 179 +++ webviews/src/agent-session/acpmux/App.tsx | 7 +- .../acpmux/HarnessHealthNotice.gallery.tsx | 67 +- .../acpmux/HarnessHealthNotice.test.tsx | 127 +- .../acpmux/HarnessHealthNotice.tsx | 206 ++- .../acpmux/Localizable.xcstrings | 1188 +++++++++++++++++ .../acpmux/generated/strings.json | 189 +++ webviews/src/agent-session/acpmux/styles.css | 2 + 26 files changed, 2893 insertions(+), 78 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHarnessCard.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneHarnessCardTests.swift create mode 100644 cmux-tui/crates/acpmux/tests/harness_restart.rs diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift index 6eb710fd711a..bfe9c1717c75 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift @@ -32,7 +32,7 @@ public nonisolated struct AgentPageOps { "newTab.remember", "newTab.setTemplate", "newTab.inputReady", "newTab.touched", "newTab.context", "shortcut.edit", "action.run", "file.open", "browser.open", "project.list", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "app.action", "chats.open", "chats.openInTerminal", "chats.page", "quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.openTab", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", - "shell.run", "shell.read", "shell.stop", + "shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart", "git.diff", "git.status", "git.commit", "git.push", "git.worktree.create", "git.worktree.apply", "git.worktree.remove", "git.githubRepository", "file.search", "git.checkpoint.diff", "turn.undo", "git.stage", "git.unstage", "git.stageHunk", "git.unstageHunk", "git.pullRequest", "project.instructions", "project.addFiles", "composer.capture", "dictation.toggle", "dictation.start", "dictation.stop", "dictation.cancel", "dictation.openSettings", "transport.open", "transport.send", "transport.close", "transport.gesture", "transport.gesture.release", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift index 6b2238c1a158..24c291e6f154 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift @@ -71,6 +71,7 @@ enum AgentPaneHandshakeReply { if handshake.connection?.remote != nil { model.remoteChat = true } // The connection stays here; the reply never encodes it. model.pendingConnection = handshake.connection + model.transport.acpmuxSocket = handshake.connection?.remote == nil ? handshake.connection?.socketPath : nil handshake.connection = nil return AgentPaneReply.handshake(handshake) } catch { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHarnessCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHarnessCard.swift new file mode 100644 index 000000000000..f41a4de75d53 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHarnessCard.swift @@ -0,0 +1,68 @@ +import Foundation + +/// The agent CLI card's requests (cx-1mbnu): `harness.update` runs the CLI's own update on this +/// Mac's acpmux (`_acpmux/harness/update`) and waits for it; `harness.restart` restarts this +/// chat's agent on the installed version (`_acpmux/sessions/restart_harness`, after its turn). +/// The page names only a harness id; acpmux builds the command and refuses one needing sudo. +@MainActor +enum AgentPaneHarnessCard { + static func reply(to request: AgentPaneRequest, transport: AgentPaneTransport, session sessionId: String?) async -> [String: Any] { + // Page script cannot make a gesture: only the person's click updates or restarts. + guard transport.gestures.consume() else { + return AgentPaneReply.failure(code: "harness.gesture_required", message: AgentPaneModel.shellGestureMessage) + } + guard let socket = transport.acpmuxSocket else { return AgentPaneReply.failure(code: "harness.unavailable", message: unavailableMessage) } + do { + switch request { + case .harnessUpdate(let id): + // Each updater may run 15 minutes (acpmux `probe.rs`); an auto-update may hold the turn. + let result = try await AcpmuxStatusClient.call(socketPath: socket, method: "_acpmux/harness/update", params: ["ids": [id]], + deadline: .seconds(35 * 60), detailed: true) + let row = (result["results"] as? [[String: Any]])?.first { $0["id"] as? String == id } + var value: [String: Any] = ["ok": row?["ok"] as? Bool ?? false] + if let version = row?["version"] as? String { value["version"] = version } + if let error = row?["error"] as? String { value["error"] = error } + return AgentPaneReply.success(value) + case .harnessRestart: + // Only a chat this pane started or shows: the page can adopt any id (`chat.persistSession`). + guard let session = sessionId, transport.sessions.contains(session) else { + return AgentPaneReply.failure(code: "harness.not_this_chat", message: unavailableMessage) + } + let result = try await AcpmuxStatusClient.call(socketPath: socket, method: "_acpmux/sessions/restart_harness", + params: ["sessions": [session]], deadline: .seconds(300), detailed: true) + var value: [String: Any] = ["restarted": result["restarted"] as? Int ?? 0, "after_turn": result["after_turn"] as? Int ?? 0, + "failed": result["failed"] as? Int ?? 0] + if let error = (result["errors"] as? [[String: Any]])?.first?["error"] as? String { value["error"] = error } + return AgentPaneReply.success(value) + default: + return AgentPaneModel.unsupported("harness") + } + } catch AcpmuxStatusClient.Failure.rpc(let message) { + return AgentPaneReply.failure(code: "harness.failed", message: message) + } catch let error as AcpmuxRPCError { + // An agent service from before this app's update does not know the method yet. + return AgentPaneReply.failure(code: "harness.failed", message: error.isMethodMissing ? oldServiceMessage : error.isUnreachable ? unavailableMessage : error.message) + } catch is AgentPaneDeadlineExceeded { + return AgentPaneReply.failure(code: "harness.failed", message: slowMessage) + } catch { + return AgentPaneReply.failure(code: "harness.failed", message: unavailableMessage) + } + } + + /// The agent service still running is older than the app (it lacks the method). + static var oldServiceMessage: String { + String(localized: "agentPane.harness.oldService", + defaultValue: "The agent service on this Mac is out of date. Quit and reopen cmux, then try again.", bundle: .module) + } + + /// The update or restart outlived its deadline; it may still finish. + static var slowMessage: String { + String(localized: "agentPane.harness.slow", + defaultValue: "This is taking longer than expected. It may still finish; check Settings > Agents.", bundle: .module) + } + + /// The update or restart could not reach this Mac's agent service. + static var unavailableMessage: String { + String(localized: "agentPane.harness.unavailable", defaultValue: "The agent service is not running. Try again in a moment.", bundle: .module) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index d4e915d3f88c..5f616b331435 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -296,7 +296,7 @@ public final class AgentPaneModel { } return AgentPaneReply.success() case .gitWrite(let write): return await AgentPaneGitWriteReply.reply(to: write, in: self) - case .git, .invalidGit, .githubRepository, .gitStage, .gitPullRequest, .transportOpen, .transportSend, .transportGesture, .transportGestureRelease, .transportClose: + case .git, .invalidGit, .githubRepository, .gitStage, .gitPullRequest, .transportOpen, .transportSend, .transportGesture, .transportGestureRelease, .transportClose, .harnessUpdate, .harnessRestart: return await AgentPaneTransportReply.reply(to: request, in: self) case .turnUndo(let undo): return await AgentPaneTurnUndo.reply(to: undo, in: self) case .invalidTurnUndo: return await AgentPaneTurnUndo.reply(to: nil, in: self) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 74decf380fae..a0f17704c43e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -102,6 +102,16 @@ extension AgentPaneRequest { } else { self = .unsupported(method) } + case "harness.update": + // A harness id (profile file name), never a command. + if let id = params?["id"] as? String, !id.isEmpty, id.utf8.count <= 128, + id.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || "-_.".contains($0)) }) { + self = .harnessUpdate(id: id) + } else { + self = .unsupported(method) + } + case "harness.restart": + self = .harnessRestart case "shell.run": if let command = params?["command"] as? String, !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index 9abad9ecf086..17838d477d22 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -32,6 +32,12 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable { /// `command` in `cwd` (``AgentPaneShell``); answers `{id}`. Only with a /// real gesture in the pane. case shellRun(command: String, cwd: String?) + /// `harness.update {id}`: the chat card's Update (cx-1mbnu): this Mac's acpmux runs that agent + /// CLI's own update now and answers `{ok, version?, error?}`. Only with a real gesture. + case harnessUpdate(id: String) + /// `harness.restart {}`: the card's Restart Chat: this chat's agent restarts on the installed + /// version and resumes (after its turn); answers `{restarted, after_turn}`. Only with a gesture. + case harnessRestart /// `shell.read {id, after}`: the run's output from byte `after`. case shellRead(id: String, after: Int) /// `shell.stop {id}`: interrupt the run's process group. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift index 8028622f8f33..d256cc910fb2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift @@ -55,6 +55,8 @@ import CmuxNextCompat var draining = false /// The user's gestures in this pane; a granting frame consumes one. public let gestures: AgentPaneUserGestures + /// This Mac's acpmux socket from the last handshake (the agent CLI card's update and restart). + var acpmuxSocket: String? /// The permission options the daemon sent, to tell an allow from a deny. public let permissionOptions = AcpmuxPermissionOptions() /// The sessions this pane started or shows. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransportReply.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransportReply.swift index d7a1f8759183..4d5b000f0b70 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransportReply.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransportReply.swift @@ -41,6 +41,10 @@ enum AgentPaneTransportReply { return AgentPaneReply.success(["ticket": ticket]) case .transportGestureRelease: return model.endTransport(closing: nil) case .transportClose(let connection): return model.endTransport(closing: connection) + // This Mac's agent CLIs (cx-1mbnu): never for a chat on another machine. + case .harnessUpdate, .harnessRestart: + if model.remoteChat { return AgentPaneModel.unsupported("harness") } + return await AgentPaneHarnessCard.reply(to: request, transport: model.transport, session: model.sessionId) default: return AgentPaneModel.unsupported(String(describing: request)) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index 8de8813ee1a8..60abe5a6adbe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -24367,6 +24367,399 @@ } } } + }, + "agentPane.harness.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The agent service is not running. Try again in a moment." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خدمة الوكيل لا تعمل. حاول مرة أخرى بعد قليل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Usluga agenta nije pokrenuta. Pokušajte ponovo za trenutak." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agenttjenesten kører ikke. Prøv igen om lidt." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Agentdienst läuft nicht. Versuche es gleich noch einmal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El servicio de agentes no se está ejecutando. Inténtalo de nuevo en un momento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le service d'agents n'est pas en cours d'exécution. Réessayez dans un instant." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il servizio agenti non è in esecuzione. Riprova tra un momento." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントサービスが実行されていません。しばらくしてからもう一度お試しください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សេវាភ្នាក់ងារមិនកំពុងដំណើរការទេ។ សូមព្យាយាមម្ដងទៀតបន្តិចទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 서비스가 실행 중이 아닙니다. 잠시 후 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Agenttjenesten kjører ikke. Prøv igjen om et øyeblikk." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usługa agentów nie działa. Spróbuj ponownie za chwilę." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O serviço de agentes não está em execução. Tente novamente em instantes." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Служба агентов не запущена. Повторите попытку через мгновение." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บริการเอเจนต์ไม่ได้ทำงานอยู่ ลองอีกครั้งในอีกสักครู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ajan hizmeti çalışmıyor. Birazdan yeniden deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Служба агентів не запущена. Спробуйте ще раз за мить." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Dịch vụ tác tử không chạy. Hãy thử lại sau giây lát." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理服务未运行。请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理程式服務未在執行。請稍後再試。" + } + } + } + }, + "agentPane.harness.oldService": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The agent service on this Mac is out of date. Quit and reopen cmux, then try again." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خدمة الوكيل على هذا الـ Mac قديمة. أغلق cmux وأعد فتحه، ثم حاول مرة أخرى." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Usluga agenta na ovom Macu je zastarjela. Zatvorite i ponovo otvorite cmux, pa pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agenttjenesten på denne Mac er forældet. Afslut og åbn cmux igen, og prøv så igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Agentdienst auf diesem Mac ist veraltet. Beende cmux, öffne es erneut und versuche es dann noch einmal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El servicio de agentes de este Mac está desactualizado. Cierra y vuelve a abrir cmux, y luego inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le service d'agents de ce Mac n'est pas à jour. Quittez puis rouvrez cmux, puis réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il servizio agenti su questo Mac non è aggiornato. Chiudi e riapri cmux, poi riprova." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac のエージェントサービスが古くなっています。cmux を終了して再度開いてから、もう一度お試しください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សេវាភ្នាក់ងារនៅលើ Mac នេះហួសសម័យ។ សូមបិទ ហើយបើក cmux ឡើងវិញ រួចព្យាយាមម្ដងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac의 에이전트 서비스가 오래되었습니다. cmux를 종료했다가 다시 연 다음 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Agenttjenesten på denne Macen er utdatert. Avslutt og åpne cmux på nytt, og prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usługa agentów na tym Macu jest nieaktualna. Zamknij i ponownie otwórz cmux, a następnie spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O serviço de agentes deste Mac está desatualizado. Encerre e reabra o cmux e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Служба агентов на этом Mac устарела. Закройте и снова откройте cmux, затем повторите попытку." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บริการเอเจนต์บน Mac เครื่องนี้ล้าสมัย ปิดแล้วเปิด cmux ใหม่ จากนั้นลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu Mac'teki ajan hizmeti güncel değil. cmux'tan çıkıp yeniden açın, sonra tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Служба агентів на цьому Mac застаріла. Закрийте й знову відкрийте cmux, а потім спробуйте ще раз." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Dịch vụ tác tử trên máy Mac này đã cũ. Hãy thoát và mở lại cmux, rồi thử lại." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此 Mac 上的代理服务已过时。请退出并重新打开 cmux,然后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這台 Mac 上的代理程式服務已過時。請結束並重新開啟 cmux,然後再試一次。" + } + } + } + }, + "agentPane.harness.slow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This is taking longer than expected. It may still finish; check Settings > Agents." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستغرق هذا وقتًا أطول من المتوقع. قد يكتمل لاحقًا؛ تحقق من الإعدادات > الوكلاء." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo traje duže nego što je očekivano. Možda će se ipak završiti; provjerite Postavke > Agenti." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Det tager længere tid end forventet. Det kan stadig blive færdigt; se Indstillinger > Agenter." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das dauert länger als erwartet. Es wird vielleicht noch fertig; sieh unter Einstellungen > Agenten nach." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto está tardando más de lo esperado. Puede que aún termine; consulta Ajustes > Agentes." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela prend plus de temps que prévu. L'opération peut encore aboutir ; consultez Réglages > Agents." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ci vuole più tempo del previsto. Potrebbe comunque terminare; controlla Impostazioni > Agenti." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "予想より時間がかかっています。まだ完了する可能性があります。設定 > エージェント を確認してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "វាចំណាយពេលយូរជាងការរំពឹងទុក។ វាអាចនៅតែបញ្ចប់បាន សូមពិនិត្យ ការកំណត់ > ភ្នាក់ងារ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예상보다 오래 걸리고 있습니다. 아직 완료될 수 있으니 설정 > 에이전트를 확인하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette tar lengre tid enn forventet. Det kan fortsatt bli ferdig; sjekk Innstillinger > Agenter." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Trwa to dłużej, niż oczekiwano. Może się jeszcze zakończyć; sprawdź Ustawienia > Agenci." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isso está demorando mais do que o esperado. Ainda pode terminar; verifique Ajustes > Agentes." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это занимает больше времени, чем ожидалось. Процесс ещё может завершиться; проверьте Настройки > Агенты." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้เวลานานกว่าที่คาดไว้ อาจยังเสร็จได้ ตรวจสอบที่ การตั้งค่า > เอเจนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu beklenenden uzun sürüyor. Yine de tamamlanabilir; Ayarlar > Ajanlar'a bakın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Це триває довше, ніж очікувалося. Процес ще може завершитися; перевірте Налаштування > Агенти." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Việc này mất nhiều thời gian hơn dự kiến. Nó vẫn có thể hoàn tất; hãy kiểm tra Cài đặt > Tác tử." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所需时间比预期长。它可能仍会完成;请查看“设置”>“代理”。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所需時間比預期長。它可能仍會完成;請查看「設定」>「代理程式」。" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneHarnessCardTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneHarnessCardTests.swift new file mode 100644 index 000000000000..4fdbf94b9906 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneHarnessCardTests.swift @@ -0,0 +1,38 @@ +@testable import CmuxNextAgentPane +import Foundation +import Testing + +/// The agent CLI card's Restart Chat (cx-1mbnu) restarts only a chat this pane started or shows: +/// a page that adopts another chat's id (`chat.persistSession`) and then asks for a restart is +/// refused before anything reaches acpmux, so it cannot restart other chats' agents. +@MainActor +struct AgentPaneHarnessCardTests { + private actor NoHost: AgentPaneHostProviding { + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } + } + + /// A pane with an acpmux socket that does not answer: a request that reaches it fails as + /// `harness.failed`, one refused first never gets there. + private static func pane() -> AgentPaneModel { + let model = AgentPaneModel(host: NoHost()) + model.transport.acpmuxSocket = FileManager.default.temporaryDirectory.appending(path: "no-acpmux-\(UUID().uuidString).sock").path + return model + } + + private static func restart(_ model: AgentPaneModel, adopting id: String) async -> String? { + _ = await model.respond(to: AgentPaneRequest(body: ["method": "chat.persistSession", "params": ["sessionId": id]])) + model.transport.gestures.record() + let reply = await model.respond(to: AgentPaneRequest(body: ["method": "harness.restart", "params": [:]])) + return (reply["error"] as? [String: Any])?["code"] as? String + } + + @Test func restartOfAChatThePaneDoesNotShowIsRefused() async { + #expect(await Self.restart(Self.pane(), adopting: "someone-elses-chat") == "harness.not_this_chat") + } + + @Test func restartOfThePanesOwnChatGoesToAcpmux() async { + let model = Self.pane() + model.transport.sessions.add("this-chat") + #expect(await Self.restart(model, adopting: "this-chat") == "harness.failed") + } +} diff --git a/cmux-tui/crates/acpmux/src/harness_health/auto.rs b/cmux-tui/crates/acpmux/src/harness_health/auto.rs index 2d3613ce67a5..a1ca12e83ea8 100644 --- a/cmux-tui/crates/acpmux/src/harness_health/auto.rs +++ b/cmux-tui/crates/acpmux/src/harness_health/auto.rs @@ -9,6 +9,10 @@ //! //! The choice is a list of harness ids in `/harness-auto-update.json`, //! set with `_acpmux/harness/health {autoUpdate: {id, on}}`. +//! +//! Update Everything (cx-1mbnu, `_acpmux/harness/update`) runs the same +//! updaters on request (`run_updates`), whatever the choice, under the same +//! no-sudo rule. use super::{HarnessHealth, HealthService, Settings, now_ms, probe}; use serde::{Deserialize, Serialize}; @@ -51,12 +55,45 @@ fn read(path: Option<&Path>) -> BTreeSet { .unwrap_or_default() } -/// The updater a row runs on its own, if any. -fn runnable(row: &HarnessHealth) -> Option> { +/// The updater cmux may run for an outdated row, if any: a plan that needs +/// no elevated rights and whose program does not raise them. +pub fn updatable(row: &HarnessHealth) -> Option> { let plan = row.update.as_ref().filter(|p| !p.elevated)?; let program = plan.argv.first()?; let name = Path::new(program).file_name().and_then(|n| n.to_str()).unwrap_or(program); - (row.auto_update && row.outdated && !ELEVATING.contains(&name)).then(|| plan.argv.clone()) + (row.outdated && !ELEVATING.contains(&name)).then(|| plan.argv.clone()) +} + +/// The updater a row runs on its own, if any. +fn runnable(row: &HarnessHealth) -> Option> { + row.auto_update.then(|| updatable(row)).flatten() +} + +/// Runs one updater: whether it succeeded, and its last output line (or why +/// it did not finish). +async fn run_updater(argv: &[String]) -> (bool, String) { + let run = argv.to_vec(); + let outcome = + tokio::task::spawn_blocking(move || probe::run(&run, &BTreeMap::new(), UPDATE_LIMIT)) + .await + .unwrap_or_else(|e| Err(e.to_string())); + match outcome { + Ok((ok, text)) => { + (ok, text.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_owned()) + } + Err(e) => (false, e), + } +} + +/// What a requested update did for one harness (`run_updates`). +#[derive(Clone, Debug, PartialEq)] +pub enum Requested { + /// The updater ran: whether it succeeded, and its last output line. + Ran { ok: bool, detail: String }, + /// Nothing to run: the installed version is the newest known. + Current, + /// Not run, and why (an update that needs sudo, none known, no such harness). + Refused(String), } impl HealthService { @@ -128,24 +165,7 @@ impl HealthService { let result = match done.get(&argv) { Some(result) => result.clone(), None => { - let run = argv.clone(); - let outcome = tokio::task::spawn_blocking(move || { - probe::run(&run, &BTreeMap::new(), UPDATE_LIMIT) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())); - let (ok, detail) = match outcome { - Ok((ok, text)) => ( - ok, - text.lines() - .rev() - .find(|l| !l.trim().is_empty()) - .unwrap_or("") - .trim() - .to_owned(), - ), - Err(e) => (false, e), - }; + let (ok, detail) = run_updater(&argv).await; tracing::info!(harness = %row.id, ok, "auto-update ran"); let result = AutoUpdateResult { at: now_ms(), @@ -163,4 +183,65 @@ impl HealthService { } ran } + + /// Runs now, for the person (Update Everything, cx-1mbnu), the updater of + /// each row named in `ids` (every outdated row when None), whatever its + /// auto-update choice and earlier runs, under the same rule: never one + /// that needs sudo or whose program raises rights. One at a time, shared + /// with auto-update, and each command once. `check` runs once the + /// updates are this caller's (after any auto-update ends), so a version + /// an auto-update just installed is not installed again; its rows are + /// returned with the outcomes. + pub async fn run_updates( + &self, + check: impl std::future::Future>, + ids: Option<&BTreeSet>, + ) -> (Vec, Vec<(String, Requested)>) { + let _one = self.updating.lock().await; + let rows = check.await; + let rows = &rows[..]; + let asked: Vec = match ids { + Some(ids) => ids.iter().cloned().collect(), + None => rows.iter().filter(|r| r.outdated).map(|r| r.id.clone()).collect(), + }; + let mut done: BTreeMap, (bool, String)> = BTreeMap::new(); + let mut out = Vec::new(); + for id in asked { + let Some(row) = rows.iter().find(|r| r.id == id) else { + out.push((id, Requested::Refused("no such harness on this machine".into()))); + continue; + }; + if !row.outdated { + let unknown = || row.problem.clone().unwrap_or("its version is unknown".into()); + let answer = if row.version.is_some() { + Requested::Current + } else { + Requested::Refused(unknown()) + }; + out.push((id, answer)); + continue; + } + let Some(argv) = updatable(row) else { + let why = match &row.update { + Some(plan) => { + format!("its update needs elevated rights: run `{}`", plan.command) + } + None => "cmux cannot update it: update it the way it was installed".to_owned(), + }; + out.push((id, Requested::Refused(why))); + continue; + }; + let (ok, detail) = match done.get(&argv) { + Some(result) => result.clone(), + None => { + let result = run_updater(&argv).await; + tracing::info!(harness = %id, ok = result.0, "requested update ran"); + done.insert(argv, result.clone()); + result + } + }; + out.push((id, Requested::Ran { ok, detail })); + } + (rows.to_vec(), out) + } } diff --git a/cmux-tui/crates/acpmux/src/hub/health_view.rs b/cmux-tui/crates/acpmux/src/hub/health_view.rs index 336338e473e0..ae9d0b89f945 100644 --- a/cmux-tui/crates/acpmux/src/hub/health_view.rs +++ b/cmux-tui/crates/acpmux/src/hub/health_view.rs @@ -80,6 +80,74 @@ impl Hub { Ok(json!({"harnesses": rows})) } + /// `_acpmux/harness/update {ids?}` (Update Everything, cx-1mbnu): checks + /// this machine's harnesses again (newest versions as last read), runs + /// now the updater of each named one (every outdated one without `ids`) + /// that needs no sudo (`harness_health::auto::run_updates`), waits for + /// them and checks again. Answers `{results: [{id, ok, version?, + /// error?}]}`; a harness already current is `ok` without a run. The + /// caller names only harness ids, never a command. Sessions keep their + /// process (`restart_harnesses` moves them); pooled ones end, so new + /// chats start the new version. + pub async fn update_harnesses(&self, params: &Value) -> Result { + let ids = match params.get("ids") { + None | Some(Value::Null) => None, + Some(Value::Array(list)) => Some( + list.iter() + .map(|v| v.as_str().map(str::to_owned)) + .collect::>>() + .ok_or_else(|| RpcError::invalid_params("ids must be harness ids"))?, + ), + Some(_) => return Err(RpcError::invalid_params("ids must be harness ids")), + }; + let check = async { self.health.check(self.health_subjects().await, false).await }; + let (rows, ran) = self.health.run_updates(check, ids.as_ref()).await; + use crate::harness_health::auto::Requested; + let any_ran = ran.iter().any(|(_, r)| matches!(r, Requested::Ran { .. })); + let rows = if any_ran { + if ran.iter().any(|(_, r)| matches!(r, Requested::Ran { ok: true, .. })) { + self.drain_pool(); + } + self.health.check(self.health_subjects().await, false).await + } else { + rows + }; + let row = |id: &str| rows.iter().find(|r| r.id == id); + let version = |id: &str| row(id).and_then(|r| r.version.clone()); + let results: Vec = ran + .into_iter() + .map(|(id, requested)| { + let (ok, error) = match requested { + // An updater that exits 0 but leaves the old version on PATH (an npm + // prefix other than the one PATH finds) did not update it. + Requested::Ran { ok: true, .. } if row(&id).is_some_and(|r| r.outdated) => ( + false, + Some(format!( + "the update ran, but {} still reports {}", + row(&id).map_or(id.as_str(), |r| r.program.as_str()), + version(&id).unwrap_or_default() + )), + ), + Requested::Ran { ok: true, .. } | Requested::Current => (true, None), + Requested::Ran { ok: false, detail } if detail.is_empty() => { + (false, Some("the update failed".to_owned())) + } + Requested::Ran { ok: false, detail } => (false, Some(detail)), + Requested::Refused(why) => (false, Some(why)), + }; + let mut out = json!({"id": id, "ok": ok}); + if let Some(v) = version(&id) { + out["version"] = json!(v); + } + if let Some(e) = error { + out["error"] = json!(e); + } + out + }) + .collect(); + Ok(json!({"results": results})) + } + /// Runs auto-update for the harnesses that want it, and checks again after any ran. pub async fn auto_update_harnesses(&self) -> Vec { let ran = self.health.run_auto_updates().await; diff --git a/cmux-tui/crates/acpmux/src/hub/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index f2c5d37e274f..9d55907ebfe6 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -548,6 +548,14 @@ impl Hub { }; let _ = hub.finish_turn(&s, &c, result, &prompt_id, &turn_id, turn_seq).await; + // A route switch or an update asked for a restart + // during the turn (route_switch.rs): the process ends + // now and the next prompt starts it again, resuming + // the chat. Not started here: this task runs inside + // the adoption a start itself may run. + if s.route_switch.swap(false, Ordering::SeqCst) { + hub.detach_child(&s).await; + } drop(guard); }); } diff --git a/cmux-tui/crates/acpmux/src/hub/mod.rs b/cmux-tui/crates/acpmux/src/hub/mod.rs index e081cb2aa4f8..afffd1ee80c3 100644 --- a/cmux-tui/crates/acpmux/src/hub/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/mod.rs @@ -36,6 +36,7 @@ pub use queue::QueuedPrompt; mod resolve; mod route_switch; pub use pool::{PrewarmRequest, RssProbe, tree_rss_bytes}; +pub use route_switch::HarnessRestarts; pub(crate) use route_switch::route_error; mod offers; mod secrets; diff --git a/cmux-tui/crates/acpmux/src/hub/route_switch.rs b/cmux-tui/crates/acpmux/src/hub/route_switch.rs index 1133e2d2726d..0ddb117959c3 100644 --- a/cmux-tui/crates/acpmux/src/hub/route_switch.rs +++ b/cmux-tui/crates/acpmux/src/hub/route_switch.rs @@ -118,6 +118,86 @@ impl Hub { self.detach_child(session).await; self.child_for(session).await.map(|_| ()) } + + /// `_acpmux/sessions/restart_harness {harnesses?, sessions?}` (Update + /// Everything, cx-1mbnu; a chat's "Restart chat"): every live session of + /// this daemon (none mirrored from a peer; none of the Home Chief's unless + /// named in `sessions`), or those of the named harnesses and/or the named + /// sessions, restarts its harness process and resumes the same agent + /// session, as a route switch does but with no binding written: so it runs + /// the version an update just installed. A session between turns restarts + /// now; one in a turn when the turn ends (never cancelled). Returns how + /// many restarted now, how many restart after their turn, and each one + /// whose harness could not start again. + pub async fn restart_harnesses( + self: &Arc, + harnesses: Option<&std::collections::BTreeSet>, + sessions: Option<&std::collections::BTreeSet>, + ) -> HarnessRestarts { + let live = self.sessions().into_iter().filter(|session| { + let meta = session.meta(); + matches!( + meta.status, + SessionStatus::Ready | SessionStatus::Running | SessionStatus::Waiting + ) && (sessions.is_some() || !is_chief(&meta)) + && harnesses.is_none_or(|ids| ids.contains(&meta.harness)) + && sessions.is_none_or(|ids| ids.contains(session.id.as_str())) + }); + // Each restart waits for its new process (spawn, initialize, load): + // all at once, so many chats take as long as the slowest one. + let outcomes = futures::future::join_all(live.map(|session| async move { + // The turn lock is held through a turn and its route-switch + // restart (`turns.rs`, `hosts.rs`); holding it, no turn starts + // meanwhile. + let idle = || { + if session.turn().is_none() { session.turn_lock.try_lock().ok() } else { None } + }; + let mut turn = idle(); + if turn.is_none() { + session.route_switch.store(true, Ordering::SeqCst); + // The turn may have ended before the flag was seen: then now. + turn = idle().filter(|_| session.route_switch.load(Ordering::SeqCst)); + } + let Some(_turn) = turn else { return Ok(false) }; + match self.restart_on_route(&session).await { + Ok(()) => Ok(true), + Err(e) => { + tracing::warn!(session = %session.id, error = %e.message, "harness restart"); + Err((session.id.clone(), e.message)) + } + } + })) + .await; + let mut out = HarnessRestarts::default(); + for outcome in outcomes { + match outcome { + Ok(true) => out.now += 1, + Ok(false) => out.after_turn += 1, + Err(failed) => out.failed.push(failed), + } + } + out + } +} + +/// What `restart_harnesses` did: how many sessions restarted now, how many +/// restart when their turn ends, and `(session, error)` for each whose +/// harness could not start again. +#[derive(Debug, Default)] +pub struct HarnessRestarts { + pub now: usize, + pub after_turn: usize, + pub failed: Vec<(String, String)>, +} + +/// A Home Chief session (its turn, compactor or subagent): tagged +/// `cmux.chief`, or named `optchat-…`, or of an `optchat-compact-…` preset +/// harness (the app's `AcpmuxSessionCensus.isChief`). Update Everything +/// never counts or restarts one; only a caller naming it in `sessions` does. +fn is_chief(meta: &SessionMeta) -> bool { + live_tags(meta).get("cmux.chief").is_some() + || meta.name.starts_with("optchat-") + || meta.harness.starts_with("optchat-compact-") } impl Hub { diff --git a/cmux-tui/crates/acpmux/src/rpc.rs b/cmux-tui/crates/acpmux/src/rpc.rs index 2341d94c49ec..1bb1c70962ce 100644 --- a/cmux-tui/crates/acpmux/src/rpc.rs +++ b/cmux-tui/crates/acpmux/src/rpc.rs @@ -281,6 +281,13 @@ pub mod method { /// Agent CLI health (hq #1875; `hub/health_view.rs`): the unix socket /// and the local app. pub const MUX_HARNESS_HEALTH: &str = "_acpmux/harness/health"; + /// Update Everything (cx-1mbnu; `hub/health_view.rs`): run now the + /// no-sudo updater of the named outdated harnesses. The unix socket only. + pub const MUX_HARNESS_UPDATE: &str = "_acpmux/harness/update"; + /// Update Everything: restart the live sessions' harness processes on + /// the installed version and resume them (`hub/route_switch.rs`). The + /// unix socket only. + pub const MUX_SESSIONS_RESTART_HARNESS: &str = "_acpmux/sessions/restart_harness"; // Routes: how a harness reaches its model provider (ROUTES R1-R3; // server/routes.rs). add, edit, remove, restore: the unix socket only; // the rest also the local app. Never Web or peer. diff --git a/cmux-tui/crates/acpmux/src/server/harness_admin.rs b/cmux-tui/crates/acpmux/src/server/harness_admin.rs index 5f20c790f93c..055fe5d1a74e 100644 --- a/cmux-tui/crates/acpmux/src/server/harness_admin.rs +++ b/cmux-tui/crates/acpmux/src/server/harness_admin.rs @@ -1,7 +1,10 @@ //! `_acpmux/harness/add|remove|restore|doctor` and `_acpmux/registry` //! (BRING-YOUR-OWN-HARNESS H2): the app's Settings, palette and MCP tools add //! and check a user's own harness through the daemon. The work is -//! `crate::harness_admin`, shared with the CLI. +//! `crate::harness_admin`, shared with the CLI. Also Update Everything's +//! `_acpmux/harness/update` and `_acpmux/sessions/restart_harness` +//! (cx-1mbnu), which the machine's cmux-tui daemon calls: like `add` and +//! `doctor` they run programs, so only the unix socket may. //! //! Who may call them (coordinator decision 2026-10-08): `add` writes a //! profile that runs a program and `doctor` starts a harness, so only the @@ -53,6 +56,8 @@ pub(super) const METHODS: &[&str] = &[ method::MUX_HARNESS_DOCTOR, method::MUX_REGISTRY, method::MUX_HARNESS_HEALTH, + method::MUX_HARNESS_UPDATE, + method::MUX_SESSIONS_RESTART_HARNESS, ]; pub(super) async fn handle( @@ -61,10 +66,16 @@ pub(super) async fn handle( m: &str, params: &Value, ) -> Result { - let spawns = matches!(m, method::MUX_HARNESS_ADD | method::MUX_HARNESS_DOCTOR); + let spawns = matches!( + m, + method::MUX_HARNESS_ADD + | method::MUX_HARNESS_DOCTOR + | method::MUX_HARNESS_UPDATE + | method::MUX_SESSIONS_RESTART_HARNESS + ); if spawns && origin != Origin::Local { return Err(RpcError::invalid_params( - "adding or checking a harness runs a program: only the unix socket may ask for it", + "adding, checking or updating a harness runs a program: only the unix socket may ask for it", )); } // A plain browser page on the listener (Web, never a peer) may read the @@ -168,7 +179,38 @@ pub(super) async fn handle( admin::registry_value(®, &crate::config::which) } method::MUX_HARNESS_HEALTH => return hub.harness_health(params).await, + method::MUX_HARNESS_UPDATE => return hub.update_harnesses(params).await, + method::MUX_SESSIONS_RESTART_HARNESS => { + let harnesses = id_set(params, "harnesses", "harnesses must be harness ids")?; + let sessions = id_set(params, "sessions", "sessions must be session ids")?; + let done = hub.restart_harnesses(harnesses.as_ref(), sessions.as_ref()).await; + let errors: Vec = done + .failed + .iter() + .map(|(session, error)| json!({"session": session, "error": error})) + .collect(); + json!({"restarted": done.now, "after_turn": done.after_turn, + "failed": errors.len(), "errors": errors}) + } other => return Err(RpcError::method_not_found(other)), }; Ok(reply) } + +/// An optional list of ids in `params[key]` (`restart_harness`'s filters): None when absent. +fn id_set( + params: &Value, + key: &str, + error: &str, +) -> Result>, RpcError> { + match params.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(list)) => list + .iter() + .map(|v| v.as_str().map(str::to_owned)) + .collect::>>() + .map(Some) + .ok_or_else(|| RpcError::invalid_params(error)), + Some(_) => Err(RpcError::invalid_params(error)), + } +} diff --git a/cmux-tui/crates/acpmux/tests/harness_health.rs b/cmux-tui/crates/acpmux/tests/harness_health.rs index 281b74e0b928..ba5ab9d71cc0 100644 --- a/cmux-tui/crates/acpmux/tests/harness_health.rs +++ b/cmux-tui/crates/acpmux/tests/harness_health.rs @@ -621,6 +621,151 @@ async fn auto_update_runs_each_opted_in_harnesses_own_updater_then_checks_again( let _ = std::fs::remove_dir_all(&root); } +/// Update Everything (cx-1mbnu): `_acpmux/harness/update {ids}` runs now the updater of each named +/// outdated harness, whatever its auto-update choice, waits for it and checks again; a harness +/// whose update needs sudo is never run (its answer names the command), one already current is ok +/// without a run, and an unknown id is refused. Only the unix socket may ask, for the update and +/// for `_acpmux/sessions/restart_harness` alike: the local app, a WebSocket page and a peer may not. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn harness_update_runs_named_updaters_now_and_never_one_that_needs_sudo() { + let root = scratch("update"); + let home = root.join("home"); + let user = root.join("config").join("harnesses"); + + // Claude's native installer: `claude update` installs 2.0.9; each run is logged to `runs`. + let claude_file = home.join(".local/share/claude/versions/current"); + let claude = home.join(".local/bin/claude"); + let runs = root.join("claude-update-runs"); + std::fs::create_dir_all(claude_file.parent().unwrap()).unwrap(); + std::fs::write( + &claude_file, + format!( + "#!/bin/sh\nif [ \"$1\" = update ]; then echo run >> '{1}'; printf '#!/bin/sh\\necho \"2.0.9 (Claude Code)\"\\n' > '{0}.new' && chmod +x '{0}.new' && mv '{0}.new' '{0}'; echo updated; exit 0; fi\necho '2.0.5 (Claude Code)'\n", + claude_file.display(), + runs.display(), + ), + ) + .unwrap(); + std::fs::set_permissions(&claude_file, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::create_dir_all(claude.parent().unwrap()).unwrap(); + symlink(&claude_file, &claude).unwrap(); + profile( + &user, + "claude", + &format!("protocol = \"claude-stdio\"\ncommand = \"{}\"\n", claude.display()), + ); + // npm, a prefix this user cannot write: its update needs sudo. + let npm_ro = root.join("npm-ro"); + let opencode = npm_ro.join("bin/opencode"); + program(&npm_ro.join("lib/node_modules/opencode-ai/bin/opencode"), &opencode, "0.15.0"); + std::fs::set_permissions( + npm_ro.join("lib/node_modules"), + std::fs::Permissions::from_mode(0o555), + ) + .unwrap(); + profile(&user, "opencode", &format!("command = \"{}\"\n", opencode.display())); + + let (port, _asked) = latest_server(vec![ + ("/npm/@anthropic-ai%2Fclaude-code/latest", json!({"version": "2.0.9"})), + ("/npm/opencode-ai/latest", json!({"version": "0.16.0"})), + ]); + let hub = hub_for(&root, &user, &home); + hub.health.attach(Settings { + sources: Some(Sources { + npm: format!("http://127.0.0.1:{port}/npm"), + brew: format!("http://127.0.0.1:{port}/brew"), + }), + cache: None, + home: home.clone(), + // Auto-update is off for every harness: the request runs anyway. + auto_update: Some(root.join("acpmux/harness-auto-update.json")), + }); + let mut local = Client::open(&hub, Origin::Local); + let first = local.call(method::MUX_HARNESS_HEALTH, json!({})).await.expect("health"); + assert_eq!( + ( + row(&first, "claude")["outdated"].as_bool(), + row(&first, "claude")["autoUpdate"].as_bool() + ), + (Some(true), Some(false)), + "{first}" + ); + + // Nobody but the unix socket may run an update or restart chats. + for origin in [Origin::LocalApp, Origin::Web, Origin::Peer] { + let mut other = Client::open(&hub, origin); + other + .call(method::MUX_HARNESS_UPDATE, json!({"ids": ["claude"]})) + .await + .expect_err("only the unix socket updates a harness"); + other + .call(method::MUX_SESSIONS_RESTART_HARNESS, json!({})) + .await + .expect_err("only the unix socket restarts chats"); + } + assert!(!runs.exists(), "an update ran for another origin"); + + // SAFETY: geteuid has no preconditions. Root writes any folder, so the sudo case is not one. + let rooted = unsafe { libc::geteuid() } == 0; + let ids = + if rooted { json!(["claude", "nope"]) } else { json!(["claude", "opencode", "nope"]) }; + let reply = local.call(method::MUX_HARNESS_UPDATE, json!({"ids": ids})).await.expect("update"); + let result = |id: &str| { + reply["results"] + .as_array() + .and_then(|all| all.iter().find(|r| r["id"] == id)) + .cloned() + .unwrap_or_else(|| panic!("no {id} result in {reply}")) + }; + let r = result("claude"); + assert_eq!((r["ok"].as_bool(), r["version"].as_str()), (Some(true), Some("2.0.9")), "{r}"); + assert_eq!(std::fs::read_to_string(&runs).unwrap().lines().count(), 1, "one run"); + if !rooted { + let r = result("opencode"); + assert_eq!( + (r["ok"].as_bool(), r["version"].as_str()), + (Some(false), Some("0.15.0")), + "{r}" + ); + assert!( + r["error"] + .as_str() + .is_some_and(|e| e.contains("sudo npm install -g opencode-ai@latest")), + "the sudo command is named, never run: {r}" + ); + } + let r = result("nope"); + assert_eq!(r["ok"], false, "{r}"); + // The health rows the answer came from are the new check's. + let after = local.call(method::MUX_HARNESS_HEALTH, json!({})).await.expect("health"); + assert_eq!(row(&after, "claude")["outdated"], false, "{after}"); + if !rooted { + assert_eq!(row(&after, "opencode")["version"], "0.15.0", "{after}"); + } + + // Already current: ok, nothing runs again. + let again = local + .call(method::MUX_HARNESS_UPDATE, json!({"ids": ["claude"]})) + .await + .expect("update again"); + assert_eq!(again["results"], json!([{"id": "claude", "ok": true, "version": "2.0.9"}])); + assert_eq!(std::fs::read_to_string(&runs).unwrap().lines().count(), 1, "ran again"); + // Never a command from the caller. + local + .call(method::MUX_HARNESS_UPDATE, json!({"ids": "claude"})) + .await + .expect_err("ids must be a list of ids"); + // No chat runs here: nothing restarts. + let moved = local + .call(method::MUX_SESSIONS_RESTART_HARNESS, json!({"harnesses": ["claude"]})) + .await + .expect("restart"); + assert_eq!(moved, json!({"restarted": 0, "after_turn": 0, "failed": 0, "errors": []})); + + let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&root).status(); + let _ = std::fs::remove_dir_all(&root); +} + /// A fake `codex` at `dir/codex`: `codex login status` prints `says` and exits /// `code`, as Codex does (0 "Logged in using ChatGPT", 1 "Not logged in", and 1 /// for a check it could not make). diff --git a/cmux-tui/crates/acpmux/tests/harness_restart.rs b/cmux-tui/crates/acpmux/tests/harness_restart.rs new file mode 100644 index 000000000000..4e5522504666 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/harness_restart.rs @@ -0,0 +1,179 @@ +//! Restart Chat / Update Everything (cx-1mbnu): over the daemon's unix +//! socket, `_acpmux/sessions/restart_harness {sessions?, harnesses?}` restarts +//! each named live chat's harness process and resumes the same agent session. +//! A restart that cannot spawn the harness again is counted as failed and +//! named in `errors` (never reported as success), a Home Chief chat restarts +//! when the caller names it, and a chat nobody named is left alone. + +// Unix only until the Windows port runs the daemon (cmux::local_socket). +#![cfg(unix)] + +use acpmux::config::{Config, StoreMode}; +use acpmux::hub::Hub; +use acpmux::rpc::{Message, method}; +use acpmux::server::{Origin, serve_connection_with}; +use serde_json::{Value, json}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +const FAKE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); + +fn scratch() -> PathBuf { + let root = std::env::temp_dir().join(format!("acpmux-restart-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("home")).unwrap(); + std::fs::create_dir_all(root.join("config").join("harnesses")).unwrap(); + std::fs::canonicalize(&root).unwrap() +} + +/// The harness launcher at `path`: the fake agent, with its own pid in `RUN_PID`. +fn launcher(path: &Path) { + std::fs::write(path, format!("#!/bin/sh\nexport RUN_PID=$$\nexec python3 '{FAKE}' \"$@\"\n")) + .unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +struct Client(mpsc::Sender, mpsc::Receiver, i64); + +impl Client { + fn open(hub: &Arc) -> Self { + let (in_tx, in_rx) = mpsc::channel(64); + let (out_tx, out_rx) = mpsc::channel(4096); + tokio::spawn(serve_connection_with(hub.clone(), in_rx, out_tx, Origin::Local)); + Client(in_tx, out_rx, 0) + } + + async fn call_text(&mut self, m: &str, params: Value) -> (Value, String) { + self.2 += 1; + let id = self.2; + self.0.send(Message::request(id, m, params).to_line()).await.unwrap(); + let mut text = String::new(); + loop { + let line = tokio::time::timeout(Duration::from_secs(60), self.1.recv()) + .await + .expect("no reply in 60 s") + .unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + if v.get("id") == Some(&json!(id)) { + return (v, text); + } + if let Some(t) = v.pointer("/params/update/content/text").and_then(Value::as_str) { + text.push_str(t); + } + } + } + + async fn ok(&mut self, m: &str, params: Value) -> Value { + let (reply, _) = self.call_text(m, params).await; + assert!(reply.get("error").is_none(), "{m}: {reply}"); + reply["result"].clone() + } + + async fn new_chat(&mut self, harness: &str) -> String { + let new = self + .ok( + "session/new", + json!({"cwd": std::env::temp_dir(), "mcpServers": [], + "_meta": {"acpmux": {"harness": harness}}}), + ) + .await; + new["sessionId"].as_str().unwrap().to_owned() + } + + /// The pid of the harness process that answers a turn in `session`. + async fn harness_pid(&mut self, session: &str) -> String { + let prompt = + json!({"sessionId": session, "prompt": [{"type": "text", "text": "env: RUN_PID"}]}); + let (reply, mut text) = self.call_text("session/prompt", prompt).await; + assert!(reply.get("error").is_none(), "{reply}"); + // The reply can overtake the turn's last streamed update. + while !text.contains("RUN_PID=") { + let line = tokio::time::timeout(Duration::from_secs(10), self.1.recv()) + .await + .unwrap_or_else(|_| panic!("no pid text: reply {reply}, text {text:?}")) + .unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + if let Some(t) = v.pointer("/params/update/content/text").and_then(Value::as_str) { + text.push_str(t); + } + } + let at = text.rfind("RUN_PID=").unwrap_or(0); + text[at + "RUN_PID=".len()..].trim().to_owned() + } + + async fn agent_session(&mut self, session: &str) -> Value { + self.ok("_acpmux/info", json!({"sessionId": session})).await["agentSessionId"].clone() + } +} + +fn hub(root: &Path, good: &Path, bad: &Path) -> Arc { + let mut cfg: Config = serde_json::from_value(json!({ + "harnesses": { + "good": {"argv": ["/bin/sh", good], "family": "claude"}, + "bad": {"argv": ["/bin/sh", bad], "family": "claude"}, + }, + "defaultHarness": "good", + "permissionPolicy": "approve-all", + })) + .unwrap(); + cfg.path = Some(root.join("home").join("config.json")); + cfg.profile_sources.user_dir = Some(root.join("config").join("harnesses")); + cfg.store.mode = StoreMode::Memory; + let store = acpmux::store::open(&cfg.store, Path::new("/nonexistent")).unwrap(); + Hub::new(cfg, store) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restart_harness_restarts_named_chats_and_reports_one_that_cannot_spawn() { + let root = scratch(); + let (good, bad) = (root.join("good.sh"), root.join("bad.sh")); + launcher(&good); + launcher(&bad); + let hub = hub(&root, &good, &bad); + let mut local = Client::open(&hub); + + // A: a Home Chief chat, named by the caller. B: its harness can no longer + // start. C: a live chat nobody names. + let a = local.new_chat("good").await; + let b = local.new_chat("bad").await; + let c = local.new_chat("good").await; + local.ok(method::MUX_TAG, json!({"sessionId": a, "set": {"cmux.chief": "1"}})).await; + let (a_pid, b_pid, c_pid) = + (local.harness_pid(&a).await, local.harness_pid(&b).await, local.harness_pid(&c).await); + assert!(!a_pid.is_empty() && !b_pid.is_empty() && !c_pid.is_empty()); + let a_agent = local.agent_session(&a).await; + assert!(a_agent.is_string(), "{a_agent}"); + std::fs::write(&bad, "#!/bin/sh\nexit 1\n").unwrap(); + + let reply = local.ok(method::MUX_SESSIONS_RESTART_HARNESS, json!({"sessions": [&a, &b]})).await; + assert_eq!( + (&reply["restarted"], &reply["after_turn"], &reply["failed"]), + (&json!(1), &json!(0), &json!(1)), + "A restarts now, B fails: {reply}" + ); + let errors = reply["errors"].as_array().unwrap_or_else(|| panic!("no errors list: {reply}")); + assert_eq!(errors.len(), 1, "{reply}"); + assert_eq!(errors[0]["session"], json!(&b), "{reply}"); + assert!(errors[0]["error"].as_str().is_some_and(|e| !e.is_empty()), "{reply}"); + + // A runs a new process on the same agent session; C still runs its first. + let a_new = local.harness_pid(&a).await; + assert_ne!(a_new, a_pid, "the named chat's harness restarted"); + assert_eq!(local.agent_session(&a).await, a_agent, "the same agent session resumed"); + assert_eq!(local.harness_pid(&c).await, c_pid, "an unnamed chat is untouched"); + + // Unnamed (Update Everything): every live chat of the harness but the Chief's. + let all = local.ok(method::MUX_SESSIONS_RESTART_HARNESS, json!({"harnesses": ["good"]})).await; + assert_eq!( + all, + json!({"restarted": 1, "after_turn": 0, "failed": 0, "errors": []}), + "only C restarts" + ); + assert_ne!(local.harness_pid(&c).await, c_pid, "C restarted"); + assert_eq!(local.harness_pid(&a).await, a_new, "the Chief's chat is never swept"); + + let _ = std::fs::remove_dir_all(&root); +} diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index a49584c68642..95b0e894de3c 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -2395,9 +2395,11 @@ function AcpmuxPane() { method === "harness.health" ? readHarnessHealth(params) : callNative(method, params), [readHarnessHealth], ); - // After its Log In or Update ran in a terminal tab, coming back to the chat checks again. + // After its Log In ran in a terminal tab, coming back to the chat checks again; an update the + // host ran (`harness.update`) checks again once the chat restarts on it. const healthStale = useRef(false); const [healthRecheck, setHealthRecheck] = useState(0); + const recheckHealth = useCallback(() => setHealthRecheck((count) => count + 1), []); const healthRechecked = useRef(0); useEffect(() => { const recheck = () => { @@ -4725,6 +4727,9 @@ function AcpmuxPane() { healthStale.current = true; void callNative("tab.open", params).catch(() => undefined); }} + native={callNative} + turnRunning={snapshot.isWorking} + onUpdated={recheckHealth} /> )} {folderNeeded && !snapshot.sessionId && ( diff --git a/webviews/src/agent-session/acpmux/HarnessHealthNotice.gallery.tsx b/webviews/src/agent-session/acpmux/HarnessHealthNotice.gallery.tsx index f4fe09ac16cb..375142291f0f 100644 --- a/webviews/src/agent-session/acpmux/HarnessHealthNotice.gallery.tsx +++ b/webviews/src/agent-session/acpmux/HarnessHealthNotice.gallery.tsx @@ -6,15 +6,30 @@ import { HarnessHealthNotice, type HarnessHealth } from "./HarnessHealthNotice"; type Props = { health: HarnessHealth; + /// How the host's updater answers `harness.update`: it finishes, fails, or is still running. + updater?: "ok" | "fails" | "pending"; + /// A turn is running, so `harness.restart` holds the restart until it ends. + midTurn?: boolean; }; -function GalleryHarnessHealthNotice({ health }: Props) { +function GalleryHarnessHealthNotice({ health, updater = "ok", midTurn = false }: Props) { const [opened, setOpened] = useState(); + const [calls, setCalls] = useState([]); + const native = (method: string) => { + setCalls((list) => [...list, method]); + if (method === "harness.restart") + return Promise.resolve(midTurn ? { restarted: 0, after_turn: 1 } : { restarted: 1, after_turn: 0 }); + if (updater === "pending") return new Promise(() => undefined); + return Promise.resolve( + updater === "fails" ? { ok: false, error: "npm exited with status 1" } : { ok: true, version: health.latest }, + ); + }; return (
setOpened(text)} + native={native} + turnRunning={midTurn} />
); } +const phase = (ctx: Parameters[0], name: string) => + ctx.document.querySelector(`[data-harness-phase="${name}"]`) !== null; + const runLogin: Play = async (ctx) => { await ctx.click({ selector: '[data-notice-action="login"]' }); await ctx.waitFor( @@ -38,11 +58,23 @@ const runLogin: Play = async (ctx) => { const runUpdate: Play = async (ctx) => { await ctx.click({ selector: '[data-notice-action="update"]' }); - await ctx.waitFor( - () => - ctx.document.querySelector('[data-harness-opened="true"]')?.getAttribute("data-harness-command") === - "npm install -g @openai/codex@latest", - ); + await ctx.waitFor(() => phase(ctx, "updated")); +}; + +const runUpdating: Play = async (ctx) => { + await ctx.click({ selector: '[data-notice-action="update"]' }); + await ctx.waitFor(() => phase(ctx, "updating")); +}; + +const runFailed: Play = async (ctx) => { + await ctx.click({ selector: '[data-notice-action="update"]' }); + await ctx.waitFor(() => phase(ctx, "failed")); +}; + +const runRestartAfterTurn: Play = async (ctx) => { + await runUpdate(ctx); + await ctx.click({ selector: '[data-notice-action="restart"]' }); + await ctx.waitFor(() => phase(ctx, "waiting")); }; const dismiss: Play = async (ctx) => { @@ -73,7 +105,7 @@ export default componentEntry({ checks: { anchorMovePx: { value: 0, - reason: "Running an agent login or update command must leave the inline notice anchored above the composer.", + reason: "Running an agent login or an in-place update must leave the inline notice anchored above the composer.", }, layoutShiftMax: { value: 0, @@ -96,10 +128,29 @@ export default componentEntry({ play: runLogin, }, outdated: { - note: "An outdated Codex CLI offers its own update command without hiding the current chat.", + note: "An outdated Codex CLI offers Update, Skip This Version and Not Now without hiding the current chat.", + props: { health: outdated }, + }, + updating: { + note: "Update runs the host's updater in place; the card says so quietly and holds Update and Skip.", + props: { health: outdated, updater: "pending" }, + play: runUpdating, + }, + updated: { + note: "A finished update says the new version is installed and offers Restart Chat.", props: { health: outdated }, play: runUpdate, }, + "restart-after-turn": { + note: "A chat mid-turn restarts on the new version when the turn ends; the card says so.", + props: { health: outdated, midTurn: true }, + play: runRestartAfterTurn, + }, + "update-failed": { + note: "A failed update shows the updater's error inline with Try Again and Not Now.", + props: { health: outdated, updater: "fails" }, + play: runFailed, + }, dismissed: { note: "Not Now hides the notice for this chat while leaving the composer in place.", props: { health: outdated }, diff --git a/webviews/src/agent-session/acpmux/HarnessHealthNotice.test.tsx b/webviews/src/agent-session/acpmux/HarnessHealthNotice.test.tsx index dad7045d81e4..d3a376778100 100644 --- a/webviews/src/agent-session/acpmux/HarnessHealthNotice.test.tsx +++ b/webviews/src/agent-session/acpmux/HarnessHealthNotice.test.tsx @@ -1,7 +1,8 @@ // Agent CLI health in a chat (cx-ny85): a chat whose agent CLI is signed out says so with a Log In -// that runs the agent's login in a terminal tab; an outdated one offers its own update the same -// way, Not Now for this chat and Skip This Version for good. An update that needs sudo, an -// up-to-date or unknown one shows nothing (Settings > Agents has the details). +// that runs the agent's login in a terminal tab; an outdated one updates in place through the host +// (`harness.update`, cx-1mbnu), then offers Restart Chat (`harness.restart`); Not Now for this chat +// and Skip This Version for good. An update that needs sudo, an up-to-date or unknown one shows +// nothing (Settings > Agents has the details). import { afterEach, expect, test } from "bun:test"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -52,12 +53,28 @@ afterEach(async () => { }); type Opened = { kind: string; text: string; cwd?: string; run: boolean }; +type Call = { + method: string; + params: Record; + resolve: (value: unknown) => void; + reject: (error: unknown) => void; +}; + +/// The host's calls, each answered by the test (`resolve`/`reject`), the way the native updater takes minutes. +function host() { + const calls: Call[] = []; + const native = (method: string, params: Record) => + new Promise((resolve, reject) => calls.push({ method, params, resolve, reject })); + return { calls, native }; +} async function render( container: HTMLElement, health: HarnessHealth | undefined, opened: Opened[] = [], harness = "codex", + native: (method: string, params: Record) => Promise = () => new Promise(() => undefined), + extra: { turnRunning?: boolean; onUpdated?: () => void } = {}, ) { await act(async () => { root ??= createRoot(container); @@ -68,12 +85,22 @@ async function render( name="Codex" cwd="/work" open={(params) => opened.push(params)} + native={native} + {...extra} />, ); }); return opened; } +const text = (container: HTMLElement) => notice(container)?.textContent ?? ""; +const button = (container: HTMLElement, action: string) => + container.querySelector(`[data-notice-action="${action}"]`); +async function answer(call: Call | undefined, value: unknown, failed = false) { + expect(call).toBeDefined(); + await act(async () => (failed ? call!.reject(value) : call!.resolve(value))); +} + const notice = (container: HTMLElement) => container.querySelector("[data-harness-notice]"); async function press(container: HTMLElement, action: string) { await act(async () => container.querySelector(`[data-notice-action="${action}"]`)!.click()); @@ -104,10 +131,8 @@ test("a signed-out agent CLI says so, and Log In runs its login in a terminal ta test("an outdated agent CLI offers its own update; Skip This Version stays skipped until a newer one", async () => { const container = installDom(); - const opened = await render(container, outdated); + await render(container, outdated); expect(notice(container)?.dataset.harnessNotice).toBe("outdated"); - await press(container, "update"); - expect(opened).toEqual([{ kind: "terminal", text: "npm install -g @openai/codex@latest", cwd: "/work", run: true }]); await press(container, "skip"); expect(notice(container)).toBeNull(); // Another chat, same version: still skipped. A newer version asks again. @@ -143,3 +168,93 @@ test("a chat that switched agent never shows the previous agent's notice", async expect(notice(container)).toBeNull(); expect(opened).toEqual([]); }); + +test("Update runs the host's updater for this agent and says so while it works, without a terminal tab", async () => { + const container = installDom(); + const { calls, native } = host(); + const opened = await render(container, outdated, [], "codex", native); + await press(container, "update"); + expect(calls.map(({ method, params }) => ({ method, params }))).toEqual([ + { method: "harness.update", params: { id: "codex" } }, + ]); + expect(opened).toEqual([]); + expect(text(container)).toContain("Updating Codex…"); + // Every button waits for it: Not Now would lose the Restart Chat the update ends with. + expect(button(container, "update")?.disabled ?? true).toBe(true); + expect(button(container, "skip")?.disabled ?? true).toBe(true); + expect(button(container, "dismiss")?.disabled).toBe(true); +}); + +test("Restart Chat stays after the update even when the agent's health, read again, is current", async () => { + const container = installDom(); + const { calls, native } = host(); + await render(container, outdated, [], "codex", native); + await press(container, "update"); + await answer(calls[0], { ok: true, version: "0.4.0" }); + // A reconnect reads health again: the CLI is current now, but this chat still runs 0.3.1. + await render(container, { ...outdated, version: "0.4.0", outdated: false, update: null }, [], "codex", native); + expect(text(container)).toContain("Codex 0.4.0 is installed. Restart the chat to use it."); + expect(button(container, "restart")).not.toBeNull(); +}); + +test("a restart that failed says so and keeps Restart Chat", async () => { + const container = installDom(); + const { calls, native } = host(); + await render(container, outdated, [], "codex", native); + await press(container, "update"); + await answer(calls[0], { ok: true, version: "0.4.0" }); + await press(container, "restart"); + await answer(calls[1], { restarted: 0, after_turn: 0, failed: 1, error: "the agent did not start" }); + expect(text(container)).toContain("Couldn't restart the chat: the agent did not start"); + expect(button(container, "restart")?.disabled).toBe(false); +}); + +test("a finished update offers Restart Chat, which restarts the chat on the new version", async () => { + const container = installDom(); + const { calls, native } = host(); + let rechecks = 0; + await render(container, outdated, [], "codex", native, { onUpdated: () => rechecks++ }); + await press(container, "update"); + await answer(calls[0], { ok: true, version: "0.4.0" }); + expect(text(container)).toContain("Codex 0.4.0 is installed. Restart the chat to use it."); + await press(container, "restart"); + expect(calls[1]?.method).toBe("harness.restart"); + await answer(calls[1], { restarted: 1, after_turn: 0 }); + expect(notice(container)).toBeNull(); + expect(rechecks).toBe(1); +}); + +test("a chat mid-turn restarts when the turn ends; the card says so, then goes away", async () => { + const container = installDom(); + const { calls, native } = host(); + let rechecks = 0; + const onUpdated = () => rechecks++; + await render(container, outdated, [], "codex", native, { turnRunning: true, onUpdated }); + await press(container, "update"); + await answer(calls[0], { ok: true, version: "0.4.0" }); + await press(container, "restart"); + await answer(calls[1], { restarted: 0, after_turn: 1 }); + expect(text(container)).toContain("The chat restarts on 0.4.0 when this turn ends."); + await render(container, outdated, [], "codex", native, { turnRunning: false, onUpdated }); + expect(notice(container)).toBeNull(); + expect(rechecks).toBe(1); +}); + +test("a failed update says why inline, and Try Again runs it again", async () => { + const container = installDom(); + const { calls, native } = host(); + await render(container, outdated, [], "codex", native); + await press(container, "update"); + await answer(calls[0], { ok: false, error: "npm exited with status 1" }); + expect(text(container)).toContain("Couldn't update Codex: npm exited with status 1"); + await press(container, "retry"); + expect(calls[1]?.method).toBe("harness.update"); + expect(text(container)).toContain("Updating Codex…"); + // A refused call (an old host) says what it can. + await answer(calls[1], new Error(""), true); + expect(text(container)).toContain("Couldn't update Codex: The update didn't finish."); + await press(container, "retry"); + await answer(calls[2], new Error("Not supported here."), true); + expect(text(container)).toContain("Couldn't update Codex: Not supported here."); + expect(button(container, "dismiss")).not.toBeNull(); +}); diff --git a/webviews/src/agent-session/acpmux/HarnessHealthNotice.tsx b/webviews/src/agent-session/acpmux/HarnessHealthNotice.tsx index 0b3606b0d341..265d09fbf6db 100644 --- a/webviews/src/agent-session/acpmux/HarnessHealthNotice.tsx +++ b/webviews/src/agent-session/acpmux/HarnessHealthNotice.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; +import { Spinner } from "./conversation/icons"; import { useT } from "./i18n"; /// One row of acpmux's `_acpmux/harness/health` (cx-ny85), as the host hands it to the pane @@ -37,17 +38,37 @@ function skip(id: string, version: string): void { } type Open = (params: { kind: "terminal"; text: string; cwd?: string; run: true }) => void; +/// The host's calls (`callNative`): `harness.update {id}` -> `{ok, version?, error?}` (it waits for +/// the real updater, which can take minutes) and `harness.restart {}` -> `{restarted, after_turn}`. +type Native = (method: string, params: Record) => Promise; + +/// Where an update started from this card is (cx-1mbnu), for the card it was started from. +type Progress = + | { key: string; phase: "updating" } + | { key: string; phase: "updated"; version: string; restarting?: boolean; error?: string } + | { key: string; phase: "waiting"; version: string } + | { key: string; phase: "failed"; error: string }; + +function record(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +const message = (error: unknown) => (error instanceof Error ? error.message.trim() : ""); /// Agent CLI health above the composer, quiet and inline: a signed-out agent says so with Log In, -/// an outdated one offers its own update with Not Now (this chat) and Skip This Version. Both run -/// in a terminal tab of the chat's folder (`open`, the host's `tab.open`), where the person sees -/// them. An update that needs sudo is left to Settings > Agents, which shows its command. +/// which runs its login in a terminal tab of the chat's folder (`open`, the host's `tab.open`). An +/// outdated one offers Update, which the host runs in place (`harness.update`) while the card says +/// so, then Restart Chat (`harness.restart`) to use the new version; Not Now (this chat) and Skip +/// This Version. An update that needs sudo is left to Settings > Agents, which shows its command. export function HarnessHealthNotice({ health, harness, name, cwd, open, + native, + turnRunning = false, + onUpdated, }: { health: HarnessHealth | undefined; /// The chat's agent now; a row for another (fetched before a switch) shows nothing. @@ -55,13 +76,18 @@ export function HarnessHealthNotice({ name: string; cwd?: string; open: Open; + native: Native; + /// Whether the chat's turn is running: a restart the host holds for it happens when it ends. + turnRunning?: boolean; + /// The update is in use (or the restart is waiting on the turn): read the agent's health again. + onUpdated?: () => void; }) { const t = useT(); const [dismissed, setDismissed] = useState(); - if (!health || health.id !== harness) return null; - const run = (text: string) => open({ kind: "terminal", text, ...(cwd ? { cwd } : {}), run: true }); - const signedOut = health.auth === "signedOut" && health.login ? health.login : undefined; + const [progress, setProgress] = useState(); + const signedOut = health && health.auth === "signedOut" && health.login ? health.login : undefined; const update = + health && health.outdated && health.version && health.latest && @@ -72,41 +98,147 @@ export function HarnessHealthNotice({ : undefined; const kind = signedOut ? "signedOut" : update ? "outdated" : undefined; // Not Now holds for what it was said to (a newer version or a sign-out asks again). - const key = kind && `${health.id}:${kind}:${health.latest ?? ""}`; - if (!kind || key === dismissed) return null; + // An update started here holds its card (Restart Chat) even when health, read again, is current. + const started = progress && health?.id === harness && progress.key.startsWith(`${harness}:outdated:`); + const key = started ? progress.key : health && kind && `${health.id}:${kind}:${health.latest ?? ""}`; + const state = started ? progress : undefined; + const waiting = state?.phase === "waiting"; + // A restart held for the turn happened when it ended: the card is done. + useEffect(() => { + if (!waiting || turnRunning) return; + setDismissed(key); + setProgress(undefined); + onUpdated?.(); + }, [waiting, turnRunning, key, onUpdated]); + if (!health || health.id !== harness) return null; + if (!(kind || state) || !key || key === dismissed) return null; + const run = (text: string) => open({ kind: "terminal", text, ...(cwd ? { cwd } : {}), run: true }); + const id = health.id; + const latest = health.latest ?? ""; + const failed = (error: string) => + setProgress({ key, phase: "failed", error: error || t("harness.updateFailedGeneric") }); + const startUpdate = () => { + setProgress({ key, phase: "updating" }); + native("harness.update", { id }).then( + (value) => { + const reply = record(value); + if (reply.ok === true) + setProgress({ + key, + phase: "updated", + version: typeof reply.version === "string" && reply.version ? reply.version : latest, + }); + else failed(typeof reply.error === "string" ? reply.error.trim() : ""); + }, + (error: unknown) => failed(message(error)), + ); + }; + const restart = (version: string) => { + setProgress({ key, phase: "updated", version, restarting: true }); + native("harness.restart", {}).then( + (value) => { + const reply = record(value); + const later = typeof reply.after_turn === "number" ? reply.after_turn : 0; + if (typeof reply.failed === "number" && reply.failed > 0) { + const error = typeof reply.error === "string" ? reply.error.trim() : ""; + setProgress({ key, phase: "updated", version, error: error || t("harness.restartFailedGeneric") }); + return; + } + if (later > 0) { + setProgress({ key, phase: "waiting", version }); + return; + } + setDismissed(key); + setProgress(undefined); + onUpdated?.(); + }, + (error: unknown) => + setProgress({ + key, + phase: "updated", + version, + error: message(error) || t("harness.restartFailedGeneric"), + }), + ); + }; + const updating = state?.phase === "updating"; + const notNow = ( + + ); + let text: string; + let actions: ReactNode; + if (kind === "signedOut") { + text = t("harness.signedOut", { agent: name }); + actions = ( + + ); + } else if (state?.phase === "updated") { + text = state.error + ? t("harness.restartFailed", { error: state.error }) + : t("harness.updated", { agent: name, version: state.version }); + actions = ( + + ); + } else if (state?.phase === "waiting") { + text = t("harness.restartAfterTurn", { version: state.version }); + actions = null; + } else if (state?.phase === "failed") { + text = t("harness.updateFailed", { agent: name, error: state.error }); + actions = ( + + ); + } else { + text = updating + ? t("harness.updating", { agent: name }) + : t("harness.outdated", { agent: name, version: health.version ?? "", latest }); + actions = ( + <> + + + + ); + } + const busy = state?.phase === "updating" || (state?.phase === "updated" && state.restarting === true); return ( - +

- {kind === "signedOut" - ? t("harness.signedOut", { agent: name }) - : t("harness.outdated", { agent: name, version: health.version ?? "", latest: health.latest ?? "" })} + {busy && } + {text}

- {kind === "signedOut" ? ( - - ) : ( - <> - - - - )} - + {actions} + {notNow}
diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index fd5d1fa3d363..416dde977ba3 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -1,6 +1,1194 @@ { "sourceLanguage": "en", "strings": { + "harness.updating": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحديث {agent}…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranje {agent}…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdaterer {agent}…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "{agent} wird aktualisiert …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Updating {agent}…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizando {agent}…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour de {agent}…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiornamento di {agent}…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "{agent} をアップデート中…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងធ្វើបច្ចុប្បន្នភាព {agent}…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "{agent} 업데이트 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdaterer {agent} …" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aktualizowanie {agent}…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualizando {agent}…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновление {agent}…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังอัปเดต {agent}…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "{agent} güncelleniyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Оновлення {agent}…" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang cập nhật {agent}…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在更新 {agent}…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在更新 {agent}…" + } + } + } + }, + "harness.updated": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تثبيت {agent} {version}. أعد تشغيل الدردشة لاستخدامه." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} je instaliran. Ponovo pokrenite chat da biste ga koristili." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} er installeret. Genstart chatten for at bruge den." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} ist installiert. Starte den Chat neu, um die Version zu verwenden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} is installed. Restart the chat to use it." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} está instalado. Reinicia el chat para usarlo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} est installé. Redémarrez la conversation pour l’utiliser." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} è installato. Riavvia la chat per usarlo." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} をインストールしました。使用するにはチャットを再起動してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} ត្រូវបានដំឡើងហើយ។ ចាប់ផ្ដើមការជជែកឡើងវិញដើម្បីប្រើវា។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version}이(가) 설치되었습니다. 사용하려면 채팅을 다시 시작하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} er installert. Start chatten på nytt for å bruke den." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zainstalowano {agent} {version}. Uruchom ponownie czat, aby go używać." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} está instalado. Reinicie o chat para usá-lo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} установлен. Перезапустите чат, чтобы использовать его." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ติดตั้ง {agent} {version} แล้ว เริ่มแชทใหม่เพื่อใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} yüklendi. Kullanmak için sohbeti yeniden başlatın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "{agent} {version} встановлено. Перезапустіть чат, щоб використовувати його." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đã cài đặt {agent} {version}. Khởi động lại cuộc trò chuyện để sử dụng." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装 {agent} {version}。重新启动聊天即可使用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已安裝 {agent} {version}。重新啟動聊天即可使用。" + } + } + } + }, + "harness.restartChat": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تشغيل الدردشة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokreni chat" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart chat" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chat neu starten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart Chat" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar chat" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer la conversation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia chat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャットを再起動" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចាប់ផ្ដើមការជជែកឡើងវិញ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "채팅 다시 시작" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start chatten på nytt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie czat" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar chat" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить чат" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เริ่มแชทใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sohbeti Yeniden Başlat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перезапустити чат" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Khởi động lại cuộc trò chuyện" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新启动聊天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動聊天" + } + } + } + }, + "harness.restartAfterTurn": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ستُعاد تشغيل الدردشة على {version} عند انتهاء هذا الدور." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Chat se ponovo pokreće na {version} kada se ovaj krug završi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Chatten genstarter med {version}, når denne tur slutter." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Chat startet mit {version} neu, sobald dieser Durchgang endet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The chat restarts on {version} when this turn ends." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El chat se reiniciará con {version} cuando termine este turno." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La conversation redémarrera avec {version} à la fin de ce tour." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La chat si riavvierà con {version} al termine di questo turno." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このターンが終わると、チャットは {version} で再起動します。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការជជែកនឹងចាប់ផ្ដើមឡើងវិញលើ {version} នៅពេលវេននេះបញ្ចប់។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번 턴이 끝나면 채팅이 {version}(으)로 다시 시작됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Chatten starter på nytt med {version} når denne runden er ferdig." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Czat uruchomi się ponownie z wersją {version} po zakończeniu tej tury." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O chat será reiniciado com {version} quando este turno terminar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чат перезапустится на {version}, когда этот ход завершится." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แชทจะเริ่มใหม่ด้วย {version} เมื่อรอบนี้จบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu tur bittiğinde sohbet {version} ile yeniden başlar." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Чат перезапуститься на {version}, коли цей хід завершиться." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cuộc trò chuyện sẽ khởi động lại với {version} khi lượt này kết thúc." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本轮结束后,聊天将以 {version} 重新启动。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本輪結束後,聊天將以 {version} 重新啟動。" + } + } + } + }, + "harness.updateFailed": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر تحديث {agent}: {error}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranje {agent} nije uspjelo: {error}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke opdatere {agent}: {error}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "{agent} konnte nicht aktualisiert werden: {error}" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't update {agent}: {error}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo actualizar {agent}: {error}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de mettre à jour {agent} : {error}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile aggiornare {agent}: {error}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "{agent} をアップデートできませんでした: {error}" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចធ្វើបច្ចុប្បន្នភាព {agent} បានទេ៖ {error}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "{agent}을(를) 업데이트할 수 없습니다: {error}" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke oppdatere {agent}: {error}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się zaktualizować {agent}: {error}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível atualizar {agent}: {error}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось обновить {agent}: {error}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อัปเดต {agent} ไม่ได้: {error}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "{agent} güncellenemedi: {error}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося оновити {agent}: {error}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Không thể cập nhật {agent}: {error}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法更新 {agent}:{error}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法更新 {agent}:{error}" + } + } + } + }, + "harness.updateFailedGeneric": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يكتمل التحديث." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranje nije završeno." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateringen blev ikke fuldført." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Aktualisierung wurde nicht abgeschlossen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The update didn't finish." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La actualización no terminó." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La mise à jour n’a pas abouti." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L’aggiornamento non è stato completato." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートが完了しませんでした。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការធ្វើបច្ចុប្បន្នភាពមិនបានបញ្ចប់ទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트가 완료되지 않았습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdateringen ble ikke fullført." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aktualizacja nie została ukończona." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A atualização não terminou." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновление не завершилось." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การอัปเดตไม่เสร็จสมบูรณ์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme tamamlanmadı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Оновлення не завершилося." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cập nhật chưa hoàn tất." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新未完成。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新未完成。" + } + } + } + }, + "harness.restartFailed": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت إعادة تشغيل الدردشة: {error}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovno pokretanje chata nije uspjelo: {error}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke genstarte chatten: {error}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Chat konnte nicht neu gestartet werden: {error}" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't restart the chat: {error}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo reiniciar el chat: {error}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de redémarrer la conversation : {error}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile riavviare la chat: {error}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャットを再起動できませんでした: {error}" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចចាប់ផ្ដើមការជជែកឡើងវិញបានទេ៖ {error}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "채팅을 다시 시작할 수 없습니다: {error}" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke starte chatten på nytt: {error}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się ponownie uruchomić czatu: {error}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível reiniciar o chat: {error}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось перезапустить чат: {error}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เริ่มแชทใหม่ไม่ได้: {error}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sohbet yeniden başlatılamadı: {error}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося перезапустити чат: {error}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Không thể khởi động lại cuộc trò chuyện: {error}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法重新启动聊天:{error}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法重新啟動聊天:{error}" + } + } + } + }, + "harness.restartFailedGeneric": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم تُعد الدردشة التشغيل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Chat nije ponovo pokrenut." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Chatten blev ikke genstartet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Chat wurde nicht neu gestartet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The chat didn't restart." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El chat no se reinició." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La conversation n’a pas redémarré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La chat non si è riavviata." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャットは再起動されませんでした。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការជជែកមិនបានចាប់ផ្ដើមឡើងវិញទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "채팅이 다시 시작되지 않았습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Chatten ble ikke startet på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Czat nie został ponownie uruchomiony." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O chat não foi reiniciado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чат не перезапустился." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แชทไม่ได้เริ่มใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sohbet yeniden başlamadı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Чат не перезапустився." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cuộc trò chuyện chưa được khởi động lại." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天未重新启动。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天未重新啟動。" + } + } + } + }, + "harness.tryAgain": { + "comment": "Agent pane: agent CLI update card (cx-1mbnu)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة المحاولة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokušaj ponovo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Prøv igen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut versuchen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Try Again" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reintentar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réessayer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riprova" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もう一度試す" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ព្យាយាមម្ដងទៀត" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 시도" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Prøv igjen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spróbuj ponownie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentar Novamente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tekrar Dene" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Спробувати ще раз" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thử lại" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "再試一次" + } + } + } + }, "newTabPage.openFailed": { "comment": "Agent pane: under the New Tab page's field when the terminal or browser tab it asked for did not open", "extractionState": "manual", diff --git a/webviews/src/agent-session/acpmux/generated/strings.json b/webviews/src/agent-session/acpmux/generated/strings.json index 48918eec6c27..9e9d41866f7e 100644 --- a/webviews/src/agent-session/acpmux/generated/strings.json +++ b/webviews/src/agent-session/acpmux/generated/strings.json @@ -482,9 +482,18 @@ "harness.needsTrust": "{agent} comes from {folder}. Answer the folder's Trust question first.", "harness.notNow": "Not Now", "harness.outdated": "{agent} {latest} is available (you have {version}).", + "harness.restartAfterTurn": "The chat restarts on {version} when this turn ends.", + "harness.restartChat": "Restart Chat", + "harness.restartFailed": "Couldn't restart the chat: {error}", + "harness.restartFailedGeneric": "The chat didn't restart.", "harness.signedOut": "{agent} isn't signed in.", "harness.skipVersion": "Skip This Version", + "harness.tryAgain": "Try Again", "harness.update": "Update", + "harness.updateFailed": "Couldn't update {agent}: {error}", + "harness.updateFailedGeneric": "The update didn't finish.", + "harness.updated": "{agent} {version} is installed. Restart the chat to use it.", + "harness.updating": "Updating {agent}…", "harnesses.checkAgain": "Check again", "harnesses.checking": "Checking…", "harnesses.close": "Close", @@ -1633,9 +1642,18 @@ "harness.needsTrust": "{agent} مصدره {folder}. أجب أولًا عن سؤال الثقة لهذا المجلد.", "harness.notNow": "ليس الآن", "harness.outdated": "يتوفر {agent} {latest} (لديك {version}).", + "harness.restartAfterTurn": "ستُعاد تشغيل الدردشة على {version} عند انتهاء هذا الدور.", + "harness.restartChat": "إعادة تشغيل الدردشة", + "harness.restartFailed": "تعذّرت إعادة تشغيل الدردشة: {error}", + "harness.restartFailedGeneric": "لم تُعد الدردشة التشغيل.", "harness.signedOut": "{agent} غير مسجّل الدخول.", "harness.skipVersion": "تخطي هذا الإصدار", + "harness.tryAgain": "إعادة المحاولة", "harness.update": "تحديث", + "harness.updateFailed": "تعذّر تحديث {agent}: {error}", + "harness.updateFailedGeneric": "لم يكتمل التحديث.", + "harness.updated": "تم تثبيت {agent} {version}. أعد تشغيل الدردشة لاستخدامه.", + "harness.updating": "جارٍ تحديث {agent}…", "harnesses.checkAgain": "تحقق مرة أخرى", "harnesses.checking": "جارٍ التحقق…", "harnesses.close": "إغلاق", @@ -2784,9 +2802,18 @@ "harness.needsTrust": "{agent} dolazi iz {folder}. Prvo odgovori na pitanje o povjerenju za folder.", "harness.notNow": "Ne sada", "harness.outdated": "Dostupan je {agent} {latest} (imate {version}).", + "harness.restartAfterTurn": "Chat se ponovo pokreće na {version} kada se ovaj krug završi.", + "harness.restartChat": "Ponovo pokreni chat", + "harness.restartFailed": "Ponovno pokretanje chata nije uspjelo: {error}", + "harness.restartFailedGeneric": "Chat nije ponovo pokrenut.", "harness.signedOut": "{agent} nije prijavljen.", "harness.skipVersion": "Preskoči ovu verziju", + "harness.tryAgain": "Pokušaj ponovo", "harness.update": "Ažuriraj", + "harness.updateFailed": "Ažuriranje {agent} nije uspjelo: {error}", + "harness.updateFailedGeneric": "Ažuriranje nije završeno.", + "harness.updated": "{agent} {version} je instaliran. Ponovo pokrenite chat da biste ga koristili.", + "harness.updating": "Ažuriranje {agent}…", "harnesses.checkAgain": "Provjeri ponovo", "harnesses.checking": "Provjera…", "harnesses.close": "Zatvori", @@ -3935,9 +3962,18 @@ "harness.needsTrust": "{agent} kommer fra {folder}. Besvar først mappens spørgsmål om tillid.", "harness.notNow": "Ikke nu", "harness.outdated": "{agent} {latest} er tilgængelig (du har {version}).", + "harness.restartAfterTurn": "Chatten genstarter med {version}, når denne tur slutter.", + "harness.restartChat": "Genstart chat", + "harness.restartFailed": "Kunne ikke genstarte chatten: {error}", + "harness.restartFailedGeneric": "Chatten blev ikke genstartet.", "harness.signedOut": "{agent} er ikke logget ind.", "harness.skipVersion": "Spring denne version over", + "harness.tryAgain": "Prøv igen", "harness.update": "Opdater", + "harness.updateFailed": "Kunne ikke opdatere {agent}: {error}", + "harness.updateFailedGeneric": "Opdateringen blev ikke fuldført.", + "harness.updated": "{agent} {version} er installeret. Genstart chatten for at bruge den.", + "harness.updating": "Opdaterer {agent}…", "harnesses.checkAgain": "Tjek igen", "harnesses.checking": "Tjekker…", "harnesses.close": "Luk", @@ -5086,9 +5122,18 @@ "harness.needsTrust": "{agent} stammt aus {folder}. Beantworte zuerst die Vertrauensfrage des Ordners.", "harness.notNow": "Nicht jetzt", "harness.outdated": "{agent} {latest} ist verfügbar (installiert: {version}).", + "harness.restartAfterTurn": "Der Chat startet mit {version} neu, sobald dieser Durchgang endet.", + "harness.restartChat": "Chat neu starten", + "harness.restartFailed": "Der Chat konnte nicht neu gestartet werden: {error}", + "harness.restartFailedGeneric": "Der Chat wurde nicht neu gestartet.", "harness.signedOut": "{agent} ist nicht angemeldet.", "harness.skipVersion": "Diese Version überspringen", + "harness.tryAgain": "Erneut versuchen", "harness.update": "Aktualisieren", + "harness.updateFailed": "{agent} konnte nicht aktualisiert werden: {error}", + "harness.updateFailedGeneric": "Die Aktualisierung wurde nicht abgeschlossen.", + "harness.updated": "{agent} {version} ist installiert. Starte den Chat neu, um die Version zu verwenden.", + "harness.updating": "{agent} wird aktualisiert …", "harnesses.checkAgain": "Erneut prüfen", "harnesses.checking": "Wird geprüft…", "harnesses.close": "Schließen", @@ -6237,9 +6282,18 @@ "harness.needsTrust": "{agent} viene de {folder}. Primero responde a la pregunta de confianza de la carpeta.", "harness.notNow": "Ahora no", "harness.outdated": "{agent} {latest} está disponible (tienes {version}).", + "harness.restartAfterTurn": "El chat se reiniciará con {version} cuando termine este turno.", + "harness.restartChat": "Reiniciar chat", + "harness.restartFailed": "No se pudo reiniciar el chat: {error}", + "harness.restartFailedGeneric": "El chat no se reinició.", "harness.signedOut": "{agent} no tiene la sesión iniciada.", "harness.skipVersion": "Omitir esta versión", + "harness.tryAgain": "Reintentar", "harness.update": "Actualizar", + "harness.updateFailed": "No se pudo actualizar {agent}: {error}", + "harness.updateFailedGeneric": "La actualización no terminó.", + "harness.updated": "{agent} {version} está instalado. Reinicia el chat para usarlo.", + "harness.updating": "Actualizando {agent}…", "harnesses.checkAgain": "Volver a comprobar", "harnesses.checking": "Comprobando…", "harnesses.close": "Cerrar", @@ -7388,9 +7442,18 @@ "harness.needsTrust": "{agent} provient de {folder}. Répondez d'abord à la question de confiance du dossier.", "harness.notNow": "Plus tard", "harness.outdated": "{agent} {latest} est disponible (vous avez {version}).", + "harness.restartAfterTurn": "La conversation redémarrera avec {version} à la fin de ce tour.", + "harness.restartChat": "Redémarrer la conversation", + "harness.restartFailed": "Impossible de redémarrer la conversation : {error}", + "harness.restartFailedGeneric": "La conversation n’a pas redémarré.", "harness.signedOut": "{agent} n’est pas connecté.", "harness.skipVersion": "Ignorer cette version", + "harness.tryAgain": "Réessayer", "harness.update": "Mettre à jour", + "harness.updateFailed": "Impossible de mettre à jour {agent} : {error}", + "harness.updateFailedGeneric": "La mise à jour n’a pas abouti.", + "harness.updated": "{agent} {version} est installé. Redémarrez la conversation pour l’utiliser.", + "harness.updating": "Mise à jour de {agent}…", "harnesses.checkAgain": "Vérifier à nouveau", "harnesses.checking": "Vérification…", "harnesses.close": "Fermer", @@ -8539,9 +8602,18 @@ "harness.needsTrust": "{agent} proviene da {folder}. Rispondi prima alla domanda di fiducia della cartella.", "harness.notNow": "Non ora", "harness.outdated": "{agent} {latest} è disponibile (hai {version}).", + "harness.restartAfterTurn": "La chat si riavvierà con {version} al termine di questo turno.", + "harness.restartChat": "Riavvia chat", + "harness.restartFailed": "Impossibile riavviare la chat: {error}", + "harness.restartFailedGeneric": "La chat non si è riavviata.", "harness.signedOut": "{agent} non ha eseguito l’accesso.", "harness.skipVersion": "Salta questa versione", + "harness.tryAgain": "Riprova", "harness.update": "Aggiorna", + "harness.updateFailed": "Impossibile aggiornare {agent}: {error}", + "harness.updateFailedGeneric": "L’aggiornamento non è stato completato.", + "harness.updated": "{agent} {version} è installato. Riavvia la chat per usarlo.", + "harness.updating": "Aggiornamento di {agent}…", "harnesses.checkAgain": "Verifica di nuovo", "harnesses.checking": "Verifica…", "harnesses.close": "Chiudi", @@ -9690,9 +9762,18 @@ "harness.needsTrust": "{agent} は {folder} のハーネスです。先にこのフォルダの「信頼」の確認に答えてください。", "harness.notNow": "後で", "harness.outdated": "{agent} {latest} を利用できます(現在 {version})。", + "harness.restartAfterTurn": "このターンが終わると、チャットは {version} で再起動します。", + "harness.restartChat": "チャットを再起動", + "harness.restartFailed": "チャットを再起動できませんでした: {error}", + "harness.restartFailedGeneric": "チャットは再起動されませんでした。", "harness.signedOut": "{agent} にサインインしていません。", "harness.skipVersion": "このバージョンをスキップ", + "harness.tryAgain": "もう一度試す", "harness.update": "アップデート", + "harness.updateFailed": "{agent} をアップデートできませんでした: {error}", + "harness.updateFailedGeneric": "アップデートが完了しませんでした。", + "harness.updated": "{agent} {version} をインストールしました。使用するにはチャットを再起動してください。", + "harness.updating": "{agent} をアップデート中…", "harnesses.checkAgain": "再確認", "harnesses.checking": "確認中…", "harnesses.close": "閉じる", @@ -10841,9 +10922,18 @@ "harness.needsTrust": "{agent} មកពី {folder}។ សូមឆ្លើយសំណួរទុកចិត្តរបស់ថតជាមុនសិន។", "harness.notNow": "មិនមែនឥឡូវ", "harness.outdated": "មាន {agent} {latest} (អ្នកមាន {version})។", + "harness.restartAfterTurn": "ការជជែកនឹងចាប់ផ្ដើមឡើងវិញលើ {version} នៅពេលវេននេះបញ្ចប់។", + "harness.restartChat": "ចាប់ផ្ដើមការជជែកឡើងវិញ", + "harness.restartFailed": "មិនអាចចាប់ផ្ដើមការជជែកឡើងវិញបានទេ៖ {error}", + "harness.restartFailedGeneric": "ការជជែកមិនបានចាប់ផ្ដើមឡើងវិញទេ។", "harness.signedOut": "{agent} មិនទាន់បានចូលទេ។", "harness.skipVersion": "រំលងកំណែនេះ", + "harness.tryAgain": "ព្យាយាមម្ដងទៀត", "harness.update": "ធ្វើបច្ចុប្បន្នភាព", + "harness.updateFailed": "មិនអាចធ្វើបច្ចុប្បន្នភាព {agent} បានទេ៖ {error}", + "harness.updateFailedGeneric": "ការធ្វើបច្ចុប្បន្នភាពមិនបានបញ្ចប់ទេ។", + "harness.updated": "{agent} {version} ត្រូវបានដំឡើងហើយ។ ចាប់ផ្ដើមការជជែកឡើងវិញដើម្បីប្រើវា។", + "harness.updating": "កំពុងធ្វើបច្ចុប្បន្នភាព {agent}…", "harnesses.checkAgain": "ពិនិត្យម្តងទៀត", "harnesses.checking": "កំពុងពិនិត្យ…", "harnesses.close": "បិទ", @@ -11992,9 +12082,18 @@ "harness.needsTrust": "{agent}은(는) {folder}에서 왔습니다. 먼저 폴더의 신뢰 질문에 답하세요.", "harness.notNow": "나중에", "harness.outdated": "{agent} {latest}을(를) 사용할 수 있습니다(현재 {version}).", + "harness.restartAfterTurn": "이번 턴이 끝나면 채팅이 {version}(으)로 다시 시작됩니다.", + "harness.restartChat": "채팅 다시 시작", + "harness.restartFailed": "채팅을 다시 시작할 수 없습니다: {error}", + "harness.restartFailedGeneric": "채팅이 다시 시작되지 않았습니다.", "harness.signedOut": "{agent}에 로그인되어 있지 않습니다.", "harness.skipVersion": "이 버전 건너뛰기", + "harness.tryAgain": "다시 시도", "harness.update": "업데이트", + "harness.updateFailed": "{agent}을(를) 업데이트할 수 없습니다: {error}", + "harness.updateFailedGeneric": "업데이트가 완료되지 않았습니다.", + "harness.updated": "{agent} {version}이(가) 설치되었습니다. 사용하려면 채팅을 다시 시작하세요.", + "harness.updating": "{agent} 업데이트 중…", "harnesses.checkAgain": "다시 확인", "harnesses.checking": "확인 중…", "harnesses.close": "닫기", @@ -13143,9 +13242,18 @@ "harness.needsTrust": "{agent} kommer fra {folder}. Svar først på mappens spørsmål om tillit.", "harness.notNow": "Ikke nå", "harness.outdated": "{agent} {latest} er tilgjengelig (du har {version}).", + "harness.restartAfterTurn": "Chatten starter på nytt med {version} når denne runden er ferdig.", + "harness.restartChat": "Start chatten på nytt", + "harness.restartFailed": "Kunne ikke starte chatten på nytt: {error}", + "harness.restartFailedGeneric": "Chatten ble ikke startet på nytt.", "harness.signedOut": "{agent} er ikke logget på.", "harness.skipVersion": "Hopp over denne versjonen", + "harness.tryAgain": "Prøv igjen", "harness.update": "Oppdater", + "harness.updateFailed": "Kunne ikke oppdatere {agent}: {error}", + "harness.updateFailedGeneric": "Oppdateringen ble ikke fullført.", + "harness.updated": "{agent} {version} er installert. Start chatten på nytt for å bruke den.", + "harness.updating": "Oppdaterer {agent} …", "harnesses.checkAgain": "Sjekk på nytt", "harnesses.checking": "Sjekker…", "harnesses.close": "Lukk", @@ -14294,9 +14402,18 @@ "harness.needsTrust": "{agent} pochodzi z {folder}. Najpierw odpowiedz na pytanie o zaufanie do folderu.", "harness.notNow": "Nie teraz", "harness.outdated": "Dostępny jest {agent} {latest} (masz {version}).", + "harness.restartAfterTurn": "Czat uruchomi się ponownie z wersją {version} po zakończeniu tej tury.", + "harness.restartChat": "Uruchom ponownie czat", + "harness.restartFailed": "Nie udało się ponownie uruchomić czatu: {error}", + "harness.restartFailedGeneric": "Czat nie został ponownie uruchomiony.", "harness.signedOut": "{agent} nie jest zalogowany.", "harness.skipVersion": "Pomiń tę wersję", + "harness.tryAgain": "Spróbuj ponownie", "harness.update": "Aktualizuj", + "harness.updateFailed": "Nie udało się zaktualizować {agent}: {error}", + "harness.updateFailedGeneric": "Aktualizacja nie została ukończona.", + "harness.updated": "Zainstalowano {agent} {version}. Uruchom ponownie czat, aby go używać.", + "harness.updating": "Aktualizowanie {agent}…", "harnesses.checkAgain": "Sprawdź ponownie", "harnesses.checking": "Sprawdzanie…", "harnesses.close": "Zamknij", @@ -15445,9 +15562,18 @@ "harness.needsTrust": "{agent} vem de {folder}. Responda primeiro à pergunta de confiança da pasta.", "harness.notNow": "Agora não", "harness.outdated": "{agent} {latest} está disponível (você tem {version}).", + "harness.restartAfterTurn": "O chat será reiniciado com {version} quando este turno terminar.", + "harness.restartChat": "Reiniciar chat", + "harness.restartFailed": "Não foi possível reiniciar o chat: {error}", + "harness.restartFailedGeneric": "O chat não foi reiniciado.", "harness.signedOut": "{agent} não está conectado.", "harness.skipVersion": "Pular esta versão", + "harness.tryAgain": "Tentar Novamente", "harness.update": "Atualizar", + "harness.updateFailed": "Não foi possível atualizar {agent}: {error}", + "harness.updateFailedGeneric": "A atualização não terminou.", + "harness.updated": "{agent} {version} está instalado. Reinicie o chat para usá-lo.", + "harness.updating": "Atualizando {agent}…", "harnesses.checkAgain": "Verificar novamente", "harnesses.checking": "Verificando…", "harnesses.close": "Fechar", @@ -16596,9 +16722,18 @@ "harness.needsTrust": "{agent} взят из {folder}. Сначала ответьте на вопрос о доверии к папке.", "harness.notNow": "Не сейчас", "harness.outdated": "Доступен {agent} {latest} (установлен {version}).", + "harness.restartAfterTurn": "Чат перезапустится на {version}, когда этот ход завершится.", + "harness.restartChat": "Перезапустить чат", + "harness.restartFailed": "Не удалось перезапустить чат: {error}", + "harness.restartFailedGeneric": "Чат не перезапустился.", "harness.signedOut": "Вход в {agent} не выполнен.", "harness.skipVersion": "Пропустить эту версию", + "harness.tryAgain": "Повторить", "harness.update": "Обновить", + "harness.updateFailed": "Не удалось обновить {agent}: {error}", + "harness.updateFailedGeneric": "Обновление не завершилось.", + "harness.updated": "{agent} {version} установлен. Перезапустите чат, чтобы использовать его.", + "harness.updating": "Обновление {agent}…", "harnesses.checkAgain": "Проверить снова", "harnesses.checking": "Проверка…", "harnesses.close": "Закрыть", @@ -17747,9 +17882,18 @@ "harness.needsTrust": "{agent} มาจาก {folder} ตอบคำถามเรื่องความเชื่อถือของโฟลเดอร์ก่อน", "harness.notNow": "ไม่ใช่ตอนนี้", "harness.outdated": "มี {agent} {latest} แล้ว (คุณมี {version})", + "harness.restartAfterTurn": "แชทจะเริ่มใหม่ด้วย {version} เมื่อรอบนี้จบ", + "harness.restartChat": "เริ่มแชทใหม่", + "harness.restartFailed": "เริ่มแชทใหม่ไม่ได้: {error}", + "harness.restartFailedGeneric": "แชทไม่ได้เริ่มใหม่", "harness.signedOut": "{agent} ยังไม่ได้ลงชื่อเข้าใช้", "harness.skipVersion": "ข้ามเวอร์ชันนี้", + "harness.tryAgain": "ลองอีกครั้ง", "harness.update": "อัปเดต", + "harness.updateFailed": "อัปเดต {agent} ไม่ได้: {error}", + "harness.updateFailedGeneric": "การอัปเดตไม่เสร็จสมบูรณ์", + "harness.updated": "ติดตั้ง {agent} {version} แล้ว เริ่มแชทใหม่เพื่อใช้งาน", + "harness.updating": "กำลังอัปเดต {agent}…", "harnesses.checkAgain": "ตรวจสอบอีกครั้ง", "harnesses.checking": "กำลังตรวจสอบ…", "harnesses.close": "ปิด", @@ -18898,9 +19042,18 @@ "harness.needsTrust": "{agent}, {folder} klasöründen geliyor. Önce klasörün güven sorusunu yanıtlayın.", "harness.notNow": "Şimdi Değil", "harness.outdated": "{agent} {latest} kullanılabilir (sizdeki {version}).", + "harness.restartAfterTurn": "Bu tur bittiğinde sohbet {version} ile yeniden başlar.", + "harness.restartChat": "Sohbeti Yeniden Başlat", + "harness.restartFailed": "Sohbet yeniden başlatılamadı: {error}", + "harness.restartFailedGeneric": "Sohbet yeniden başlamadı.", "harness.signedOut": "{agent} oturumu açık değil.", "harness.skipVersion": "Bu Sürümü Atla", + "harness.tryAgain": "Tekrar Dene", "harness.update": "Güncelle", + "harness.updateFailed": "{agent} güncellenemedi: {error}", + "harness.updateFailedGeneric": "Güncelleme tamamlanmadı.", + "harness.updated": "{agent} {version} yüklendi. Kullanmak için sohbeti yeniden başlatın.", + "harness.updating": "{agent} güncelleniyor…", "harnesses.checkAgain": "Yeniden denetle", "harnesses.checking": "Denetleniyor…", "harnesses.close": "Kapat", @@ -20049,9 +20202,18 @@ "harness.needsTrust": "{agent} походить із {folder}. Спершу дайте відповідь на запитання про довіру до папки.", "harness.notNow": "Не зараз", "harness.outdated": "Доступний {agent} {latest} (у вас {version}).", + "harness.restartAfterTurn": "Чат перезапуститься на {version}, коли цей хід завершиться.", + "harness.restartChat": "Перезапустити чат", + "harness.restartFailed": "Не вдалося перезапустити чат: {error}", + "harness.restartFailedGeneric": "Чат не перезапустився.", "harness.signedOut": "Вхід у {agent} не виконано.", "harness.skipVersion": "Пропустити цю версію", + "harness.tryAgain": "Спробувати ще раз", "harness.update": "Оновити", + "harness.updateFailed": "Не вдалося оновити {agent}: {error}", + "harness.updateFailedGeneric": "Оновлення не завершилося.", + "harness.updated": "{agent} {version} встановлено. Перезапустіть чат, щоб використовувати його.", + "harness.updating": "Оновлення {agent}…", "harnesses.checkAgain": "Перевірити знову", "harnesses.checking": "Перевірка…", "harnesses.close": "Закрити", @@ -21200,9 +21362,18 @@ "harness.needsTrust": "{agent} đến từ {folder}. Hãy trả lời câu hỏi tin cậy của thư mục trước.", "harness.notNow": "Để sau", "harness.outdated": "Đã có {agent} {latest} (bạn đang dùng {version}).", + "harness.restartAfterTurn": "Cuộc trò chuyện sẽ khởi động lại với {version} khi lượt này kết thúc.", + "harness.restartChat": "Khởi động lại cuộc trò chuyện", + "harness.restartFailed": "Không thể khởi động lại cuộc trò chuyện: {error}", + "harness.restartFailedGeneric": "Cuộc trò chuyện chưa được khởi động lại.", "harness.signedOut": "{agent} chưa đăng nhập.", "harness.skipVersion": "Bỏ qua phiên bản này", + "harness.tryAgain": "Thử lại", "harness.update": "Cập nhật", + "harness.updateFailed": "Không thể cập nhật {agent}: {error}", + "harness.updateFailedGeneric": "Cập nhật chưa hoàn tất.", + "harness.updated": "Đã cài đặt {agent} {version}. Khởi động lại cuộc trò chuyện để sử dụng.", + "harness.updating": "Đang cập nhật {agent}…", "harnesses.checkAgain": "Kiểm tra lại", "harnesses.checking": "Đang kiểm tra…", "harnesses.close": "Đóng", @@ -22351,9 +22522,18 @@ "harness.needsTrust": "{agent} 来自 {folder}。请先回答该文件夹的信任问题。", "harness.notNow": "以后再说", "harness.outdated": "{agent} {latest} 可用(当前为 {version})。", + "harness.restartAfterTurn": "本轮结束后,聊天将以 {version} 重新启动。", + "harness.restartChat": "重新启动聊天", + "harness.restartFailed": "无法重新启动聊天:{error}", + "harness.restartFailedGeneric": "聊天未重新启动。", "harness.signedOut": "{agent} 尚未登录。", "harness.skipVersion": "跳过此版本", + "harness.tryAgain": "重试", "harness.update": "更新", + "harness.updateFailed": "无法更新 {agent}:{error}", + "harness.updateFailedGeneric": "更新未完成。", + "harness.updated": "已安装 {agent} {version}。重新启动聊天即可使用。", + "harness.updating": "正在更新 {agent}…", "harnesses.checkAgain": "再次检查", "harnesses.checking": "正在检查…", "harnesses.close": "关闭", @@ -23502,9 +23682,18 @@ "harness.needsTrust": "{agent} 來自 {folder}。請先回答此資料夾的信任問題。", "harness.notNow": "稍後再說", "harness.outdated": "{agent} {latest} 可用(目前為 {version})。", + "harness.restartAfterTurn": "本輪結束後,聊天將以 {version} 重新啟動。", + "harness.restartChat": "重新啟動聊天", + "harness.restartFailed": "無法重新啟動聊天:{error}", + "harness.restartFailedGeneric": "聊天未重新啟動。", "harness.signedOut": "{agent} 尚未登入。", "harness.skipVersion": "略過此版本", + "harness.tryAgain": "再試一次", "harness.update": "更新", + "harness.updateFailed": "無法更新 {agent}:{error}", + "harness.updateFailedGeneric": "更新未完成。", + "harness.updated": "已安裝 {agent} {version}。重新啟動聊天即可使用。", + "harness.updating": "正在更新 {agent}…", "harnesses.checkAgain": "再次檢查", "harnesses.checking": "正在檢查…", "harnesses.close": "關閉", diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index bf238e997598..66430df79086 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -444,6 +444,8 @@ button.acpmux-edited-file:hover{background:color-mix(in srgb,var(--agent-text) 4 .acpmux-host-error{padding:0 var(--cv-inset,max(18px,calc((100% - 760px)/2))) 8px}.acpmux-host-error-card{padding:11px 12px;border:1px solid color-mix(in srgb,var(--agent-danger) 46%,transparent);border-radius:12px;background:color-mix(in srgb,var(--agent-danger) 8%,var(--agent-page-bg))}.acpmux-host-error-card p{margin:0;line-height:18px;overflow-wrap:anywhere;user-select:text;-webkit-user-select:text}.acpmux-host-error-message{color:var(--agent-text)}.acpmux-host-error-hint{margin-top:4px!important;color:var(--agent-muted);font-size:12px}.acpmux-host-error-card button{margin-top:9px;border:0;border-radius:6px;padding:6px 12px;background:var(--agent-accent);color:var(--agent-accent-text,#fff);font:inherit;cursor:default}.acpmux-host-error-card button:focus-visible{outline:2px solid color-mix(in srgb,var(--agent-text) 40%,transparent);outline-offset:2px} .acpmux-host-error-card button:disabled{opacity:.6} .acpmux-harness-notice{display:block}.acpmux-harness-notice .acpmux-host-error-card{border-color:color-mix(in srgb,var(--agent-text) 14%,transparent);background:color-mix(in srgb,var(--agent-text) 4%,var(--agent-page-bg))}.acpmux-host-error-actions{display:flex;flex-wrap:wrap;gap:6px}.acpmux-harness-notice button[data-notice-action=skip],.acpmux-harness-notice button[data-notice-action=dismiss]{background:transparent;color:var(--agent-muted)} +.acpmux-harness-spinner{display:inline-block;margin-right:6px;vertical-align:-1px;color:var(--agent-muted)} +@media (prefers-reduced-motion: no-preference){.acpmux-harness-spinner{animation:acpmux-mp-spin 900ms linear infinite}} .acpmux-instructions-notice button[data-notice-action=dismiss]{background:transparent;color:var(--agent-muted)} .acpmux-composer{flex-wrap:wrap}.acpmux-composer-dropping .acpmux-composer-box{border-color:var(--agent-accent)} .acpmux-switch-notice{display:block;padding:0 var(--cv-inset,max(18px,calc((100% - 760px)/2))) 6px;color:var(--agent-muted);font-size:12px;line-height:16px} From 4107f353ffd7edc11de7459c5207b7e3e8abe1c4 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:57:19 -0700 Subject: [PATCH 141/183] cmux-next: honest Claude Reasoning menu: resolved default, Ultracode switch, no Ultrathink (cx-ervhd) (#20592) * test(acpmux): Claude reasoning lists effort levels; Ultracode is its own switch (cx-ervhd) Red: the claude_stdio Reasoning select still lists Ultracode and Ultrathink as levels and Ultrathink rewrites every prompt. Co-Authored-By: Claude Opus 5.5 * test(acpmux): Claude reasoning test in its own file (cx-ervhd) hub_integration.rs is over the god-file budget; it does not grow. Co-Authored-By: Claude Opus 5.5 * test(webviews): Claude's default reasoning reads as its resolved level (cx-ervhd) Red: a chat on Claude Code's own level on claude-opus-5-5[1m] shows Low with no row checked (local Chromium play: chip=Low, waitFor did not hold). Co-Authored-By: Claude Opus 5.5 * fix(cmux-next): honest reasoning menu: resolved default level, Ultracode as its own switch, no Ultrathink (cx-ervhd) acpmux claude_stdio: Reasoning lists Claude Code's effort levels only. Ultracode (a Claude Code setting, xhigh plus workflow orchestration) is its own On/Off option; Ultrathink, which only prefixed every prompt, is gone and a remembered pick of it runs the default level. Respawns, starts and saved picks keep Ultracode. webviews: the agent's own level names the level it resolves to (the running model's default effort, through an alias, a [1m] variant or a resolved default model), checked with a Default hint; never the first level, and an unresolved label is not remembered. Fast mode shows only where the model honors it. Terminal harnesses (Muse Code) stay off the provider rail. Co-Authored-By: Claude Opus 5.5 * test: a new Claude chat names its starting effort and attaches with Reasoning and Fast mode (cx-ervhd) Lawrence 2026-10-14, "we lost effort": the New Tab composer showed only the model chip. Red (local Chromium play on the base logic): chips = model only, waitFor did not hold. Co-Authored-By: Claude Opus 5.5 * fix(cmux-next): a new chat's effort chip names the level acpmux starts it on (cx-ervhd) Before a chat's session reports its options, the effort chip drew only a level a previous chat on that harness had remembered, so a fresh profile or harness showed the model chip alone. It now falls back to _acpmux/harnesses start.effort. Co-Authored-By: Claude Opus 5.5 * test(acpmux): the Claude reasoning client is the person at the app (cx-ervhd) Ultracode, like Fast mode, is a switch the person gate keeps for the app. Co-Authored-By: Claude Opus 5.5 * test(webviews): Ultracode On/Off ride the free effort option (cx-ervhd) A pick on acpmux's own ultracode id is a mode switch the app confirms with a sheet and refuses a non-person pane (review of #20592). Co-Authored-By: Claude Opus 5.5 * fix(cmux-next): Ultracode rides the free effort option, no mode sheet (cx-ervhd) The Reasoning menu's Ultracode switch sends effort=ultracode (On) and effort=xhigh (Off), which acpmux already honors, so the app never confirms it with a mode sheet. A new chat that starts on Ultracode names it on its pending chip, and the default-level play lists an older acpmux's Ultrathink so it proves the menu drops it. Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at 463b34dacbf5 - vp fmt webviews/test/ultracode-rides-effort.test.tsx Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38557583865 * chore(next): PR autofix at b9e7d4a9bcbb - vp fmt webviews/src/pages/home-channels/cloudHome.ts - cargo fmt in Native/OptChat/optchat-chief Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38652714129 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- .../crates/acpmux/src/claude_stdio/mod.rs | 89 +++--- .../acpmux/src/claude_stdio/outbound.rs | 49 ++-- cmux-tui/crates/acpmux/src/hub/lifecycle.rs | 2 +- cmux-tui/crates/acpmux/src/hub/mod.rs | 4 +- .../crates/acpmux/src/hub/model_options.rs | 9 + cmux-tui/crates/acpmux/src/hub/pool/start.rs | 2 +- .../crates/acpmux/src/hub/start_defaults.rs | 19 +- cmux-tui/crates/acpmux/src/hub/turns.rs | 4 +- cmux-tui/crates/acpmux/src/tui/actions.rs | 4 +- .../crates/acpmux/tests/claude_reasoning.rs | 259 ++++++++++++++++++ cmux-tui/crates/acpmux/tests/fake_claude.py | 7 + .../agent-session/acpmux/ComposerPickers.tsx | 79 ++++-- .../src/agent-session/acpmux/EffortPicker.tsx | 63 +++-- .../src/agent-session/acpmux/catalogRow.ts | 11 + .../agent-session/acpmux/composer.gallery.ts | 49 +++- webviews/src/agent-session/acpmux/direct.ts | 2 + .../agent-session/acpmux/modelCatalogData.ts | 6 +- .../test/claude-reasoning-default.test.ts | 66 +++++ webviews/test/ultracode-rides-effort.test.tsx | 143 ++++++++++ 19 files changed, 748 insertions(+), 119 deletions(-) create mode 100644 cmux-tui/crates/acpmux/tests/claude_reasoning.rs create mode 100644 webviews/src/agent-session/acpmux/catalogRow.ts create mode 100644 webviews/test/claude-reasoning-default.test.ts create mode 100644 webviews/test/ultracode-rides-effort.test.tsx diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs index 2d43ba54d316..91ca4a8667c5 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs @@ -98,42 +98,57 @@ static MODELS: std::sync::LazyLock> = std::sync::LazyLock: models }); -/// The reasoning choices: Claude Code's effort levels (`--effort` and the -/// live `apply_flag_settings` `effortLevel`), plus two built on them. -/// "default" leaves the model's own level. Ultracode is xhigh plus Claude -/// Code's `ultracode` setting; Ultrathink keeps the level and prefixes each -/// prompt with `Ultrathink:` (verified on Claude Code 2.1.287). The pane -/// offers a model only the levels its `list_models` row supports. -const EFFORTS: [(&str, &str, Option<&str>); 8] = [ - ("default", "Default", None), - ("low", "Low", None), - ("medium", "Medium", None), - ("high", "High", None), - ("xhigh", "Extra High", None), - ("max", "Max", None), - ("ultracode", "Ultracode", Some("xhigh effort plus multi-agent workflow orchestration")), - ("ultrathink", "Ultrathink", Some("Thinks as hard as it can on every prompt")), +/// The Reasoning choices: Claude Code's effort levels (`--effort` and the +/// live `apply_flag_settings` `effortLevel`). "default" leaves the model's +/// own level. The pane offers a model only the levels its `list_models` row +/// supports. +const EFFORTS: [(&str, &str); 6] = [ + ("default", "Default"), + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("xhigh", "Extra High"), + ("max", "Max"), ]; -/// The prefix Claude Code reads as "think as hard as you can" (Ultrathink). -const ULTRATHINK_PREFIX: &str = "Ultrathink:\n"; +/// The session's effort may also be `ultracode`: Claude Code's workflow +/// orchestration setting, which runs at xhigh. It is not a level, so it is +/// its own On/Off option (`ULTRACODE_OPTION`), not a Reasoning choice. +const ULTRACODE: &str = "ultracode"; +pub const ULTRACODE_OPTION: &str = "ultracode"; -/// The `--effort` a session starts with: Ultracode runs at xhigh, Ultrathink -/// and "default" pass none (the model's own level). -fn cli_effort(effort: &str) -> Option<&str> { +/// A stored or sent effort as this translator keeps it. `ultrathink` was a +/// Reasoning choice that only prefixed each prompt with a keyword; a saved +/// pick of it now runs the model's own level. +fn effort_value(effort: &str) -> &str { match effort { - "default" | "ultrathink" | "" => None, - "ultracode" => Some("xhigh"), - other => Some(other), + "ultrathink" | "" => "default", + other => other, + } +} + +/// The Reasoning select's value for a session effort: Ultracode runs at xhigh. +fn shown_level(effort: &str) -> &str { + match effort_value(effort) { + ULTRACODE => "xhigh", + level => level, } } -/// The `apply_flag_settings` settings for a reasoning choice. Every choice but -/// Ultracode clears `ultracode`; Ultrathink leaves the level as it is. +/// The `--effort` a session starts with: Ultracode runs at xhigh, "default" +/// passes none (the model's own level). +fn cli_effort(effort: &str) -> Option<&str> { + match effort_value(effort) { + "default" => None, + level => Some(shown_level(level)), + } +} + +/// The `apply_flag_settings` settings for a session effort. Every level +/// clears `ultracode`. fn effort_settings(effort: &str) -> Value { - match effort { - "ultracode" => json!({"effortLevel": "xhigh", "ultracode": true}), - "ultrathink" => json!({"ultracode": false}), + match effort_value(effort) { + ULTRACODE => json!({"effortLevel": "xhigh", "ultracode": true}), "default" => json!({"effortLevel": "auto", "ultracode": false}), level => json!({"effortLevel": level, "ultracode": false}), } @@ -143,7 +158,7 @@ fn effort_settings(effort: &str) -> Value { /// Ultracode and fast mode have no command-line flag of their own. fn startup_settings(effort: &str, fast: bool) -> Option { let mut settings = serde_json::Map::new(); - if effort == "ultracode" { + if effort_value(effort) == ULTRACODE { settings.insert("effortLevel".into(), json!("xhigh")); settings.insert("ultracode".into(), json!(true)); } @@ -383,7 +398,7 @@ impl Translator { acp_session_id, mode: Mutex::new(mode.to_owned()), model: Mutex::new(model.to_owned()), - effort: Mutex::new(effort.to_owned()), + effort: Mutex::new(effort_value(effort).to_owned()), fast: Mutex::new(false), in_turn: AtomicBool::new(false), prompt_echo: Mutex::new(None), @@ -485,16 +500,20 @@ impl Translator { } pub async fn config_options_value(&self) -> Value { + let effort = self.effort.lock().await.clone(); json!([ {"id": "model", "name": "Model", "type": "select", "category": "model", "currentValue": *self.model.lock().await, "options": models().iter().map(|(v, n)| json!({"value": v, "name": n})).collect::>()}, {"id": "mode", "name": "Permission mode", "type": "select", "category": "mode", "currentValue": *self.mode.lock().await, "options": MODES.iter().map(|(v, n)| json!({"value": v, "name": n})).collect::>()}, - {"id": "effort", "name": "Reasoning", "type": "select", "category": "thought_level", "currentValue": *self.effort.lock().await, - "options": EFFORTS.iter().map(|(v, n, d)| match d { - Some(d) => json!({"value": v, "name": n, "description": d}), - None => json!({"value": v, "name": n}), - }).collect::>()}, + {"id": "effort", "name": "Reasoning", "type": "select", "category": "thought_level", "currentValue": shown_level(&effort), + "options": EFFORTS.iter().map(|(v, n)| json!({"value": v, "name": n})).collect::>()}, + {"id": ULTRACODE_OPTION, "name": "Ultracode", "type": "select", "category": "model_config", + "currentValue": if effort_value(&effort) == ULTRACODE { "on" } else { "off" }, + "options": [ + {"value": "off", "name": "Off"}, + {"value": "on", "name": "On", "description": "Extra High effort plus multi-agent workflow orchestration"}, + ]}, {"id": "fast-mode", "name": "Fast mode", "type": "select", "category": "model_config", "currentValue": if *self.fast.lock().await { "on" } else { "off" }, "options": [ diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs b/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs index 736fd5e7bf36..2c38b8b2f63c 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs @@ -100,10 +100,6 @@ impl Translator { _ => json!({"type": "text", "text": b.get("text").and_then(Value::as_str).unwrap_or("")}), }) .collect(); - let mut content = content; - if !steer && *self.effort.lock().await == "ultrathink" { - ultrathink(&mut content); - } // Claude Code echoes the line with this uuid when it reads it. let uuid = uuid::Uuid::now_v7().to_string(); let line = json!({"type": "user", "uuid": uuid, "message": {"role": "user", "content": content}}); @@ -172,26 +168,51 @@ impl Translator { ]) } "effort" => { - if !EFFORTS.iter().any(|(v, _, _)| *v == val) { + // Ultracode and Ultrathink were Reasoning choices once; a + // remembered pick of either still applies. + let legacy = matches!(val.as_str(), "ultracode" | "ultrathink"); + if !legacy && !EFFORTS.iter().any(|(v, _)| *v == val) { return Outbound::Reply(Message::err( id.clone(), RpcError::invalid_params(format!( "effort must be one of {}", EFFORTS .iter() - .map(|(v, _, _)| *v) + .map(|(v, _)| *v) .collect::>() .join(", ") )), )); } + let effort = effort_value(&val).to_owned(); + let settings = effort_settings(&effort); self.pending.lock().await.insert( id.to_string(), - Pending::Control(Setting::Effort, val.clone()), + Pending::Control(Setting::Effort, effort), ); // Claude Code's live effort switch is the flag-settings channel. Outbound::Lines(vec![ - json!({"type": "control_request", "request_id": format!("ctl-{}", id), "request": {"subtype": "apply_flag_settings", "settings": effort_settings(&val)}}), + json!({"type": "control_request", "request_id": format!("ctl-{}", id), "request": {"subtype": "apply_flag_settings", "settings": settings}}), + ]) + } + ULTRACODE_OPTION => { + // On runs Ultracode (xhigh plus the setting); Off keeps xhigh. + let effort = match val.as_str() { + "on" => ULTRACODE, + "off" => "xhigh", + _ => { + return Outbound::Reply(Message::err( + id.clone(), + RpcError::invalid_params("ultracode must be on or off"), + )); + } + }; + self.pending.lock().await.insert( + id.to_string(), + Pending::Control(Setting::Effort, effort.to_owned()), + ); + Outbound::Lines(vec![ + json!({"type": "control_request", "request_id": format!("ctl-{}", id), "request": {"subtype": "apply_flag_settings", "settings": effort_settings(effort)}}), ]) } other => Outbound::Reply(Message::err( @@ -301,18 +322,6 @@ impl Translator { } } -/// Ultrathink: the prompt's first text block starts with the prefix (one is -/// added when the prompt has only images or resources). -fn ultrathink(content: &mut Vec) { - match content.iter_mut().find(|b| b["type"] == "text") { - Some(block) => { - let text = block["text"].as_str().unwrap_or(""); - block["text"] = json!(format!("{ULTRATHINK_PREFIX}{text}")); - } - None => content.insert(0, json!({"type": "text", "text": ULTRATHINK_PREFIX.trim_end()})), - } -} - /// An ACP `resource` or `resource_link` block as prompt text, so embedded /// context reaches Claude instead of an empty string. fn resource_text(b: &Value) -> String { diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index 18582e82a5ad..a4b76e337f8e 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -447,7 +447,7 @@ impl Hub { }; let fresh_id = if resume.is_none() { Some(uuid::Uuid::now_v7().to_string()) } else { None }; - let effort = current_option(&meta, "effort").unwrap_or_else(|| "default".into()); + let effort = current_effort(&meta).unwrap_or_else(|| "default".into()); let fast = current_option(&meta, "fast-mode").as_deref() == Some("on"); let mode = meta .modes diff --git a/cmux-tui/crates/acpmux/src/hub/mod.rs b/cmux-tui/crates/acpmux/src/hub/mod.rs index afffd1ee80c3..4179f45c41c9 100644 --- a/cmux-tui/crates/acpmux/src/hub/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/mod.rs @@ -24,7 +24,9 @@ mod hosts; mod lifecycle; pub(crate) mod model_availability; mod model_options; -pub use model_options::{current_model, current_option, resolve_config_id, web_url}; +pub use model_options::{ + current_effort, current_model, current_option, resolve_config_id, web_url, +}; mod model_hint; mod models_view; mod paging; diff --git a/cmux-tui/crates/acpmux/src/hub/model_options.rs b/cmux-tui/crates/acpmux/src/hub/model_options.rs index e81d29a07b9e..a5086e123fad 100644 --- a/cmux-tui/crates/acpmux/src/hub/model_options.rs +++ b/cmux-tui/crates/acpmux/src/hub/model_options.rs @@ -21,6 +21,15 @@ pub fn current_option(m: &SessionMeta, id: &str) -> Option { .and_then(|o| o.get("currentValue").and_then(Value::as_str).map(str::to_owned)) } +/// The effort a Claude Code session (re)starts with: its Reasoning level, or +/// `ultracode` while that switch is on. +pub fn current_effort(m: &SessionMeta) -> Option { + if current_option(m, crate::claude_stdio::ULTRACODE_OPTION).as_deref() == Some("on") { + return Some("ultracode".into()); + } + current_option(m, "effort") +} + /// The option id a harness uses for thinking effort, given the name a /// client asked for. `effort` is the portable name; Codex calls it /// `reasoning_effort`. diff --git a/cmux-tui/crates/acpmux/src/hub/pool/start.rs b/cmux-tui/crates/acpmux/src/hub/pool/start.rs index bc7ac0054e80..5bbbe1cdf51a 100644 --- a/cmux-tui/crates/acpmux/src/hub/pool/start.rs +++ b/cmux-tui/crates/acpmux/src/hub/pool/start.rs @@ -14,7 +14,7 @@ impl Hub { let (command_line, translator) = if claude { // As `ensure_child` starts a fresh Claude session. let fresh_id = uuid::Uuid::now_v7().to_string(); - let effort = current_option(draft, "effort").unwrap_or_else(|| "default".into()); + let effort = current_effort(draft).unwrap_or_else(|| "default".into()); let mode = "default".to_owned(); let model = current_model(draft).unwrap_or_else(|| "default".into()); let plan = crate::claude_stdio::spawn_plan( diff --git a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs index 5c916444e2d1..384e9dcd131b 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs @@ -41,6 +41,16 @@ pub(super) fn is_effort_id(id: &str) -> bool { EFFORT_IDS.contains(&id) } +/// The effort a `session/set_config_option` sets, if it sets one: an effort +/// option's value, or Claude Code's Ultracode switch (on runs `ultracode`, +/// off leaves xhigh). +pub(super) fn effort_of<'a>(config_id: &str, value: &'a Value) -> Option<&'a str> { + if config_id == crate::claude_stdio::ULTRACODE_OPTION { + return Some(if value == "on" { "ultracode" } else { "xhigh" }); + } + if is_effort_id(config_id) { value.as_str() } else { None } +} + /// Records that `session` runs `model` now. A model change can reset the /// harness's effort (Codex does), so the recorded effort is dropped. pub(super) fn model_applied(session: &Session, model: &str) { @@ -130,7 +140,9 @@ fn effort_fits(curated: Option<&CatalogHarness>, model: Option<&str>, effort: &s else { return true; }; - entry.efforts.as_ref().is_none_or(|list| list.iter().any(|e| e == effort)) + // Claude Code's Ultracode runs at xhigh. + let level = if effort == "ultracode" { "xhigh" } else { effort }; + entry.efforts.as_ref().is_none_or(|list| list.iter().any(|e| e == level)) } impl Hub { @@ -175,8 +187,9 @@ impl Hub { .clone(); let r = self.set_config(session, config_id, value.clone()).await?; let resolved = super::model_options::resolve_config_id(&session.meta(), config_id); - if pick && is_effort_id(&resolved) && before.as_deref() != value.as_str() { - self.note_pick(session, None, value.as_str()).await; + let effort = effort_of(&resolved, &value); + if pick && effort.is_some() && before.as_deref() != effort { + self.note_pick(session, None, effort).await; } // The mode as a config option (Claude Code's `mode`): a mode pick. if pick diff --git a/cmux-tui/crates/acpmux/src/hub/turns.rs b/cmux-tui/crates/acpmux/src/hub/turns.rs index 845e0dc1848f..4175890aad62 100644 --- a/cmux-tui/crates/acpmux/src/hub/turns.rs +++ b/cmux-tui/crates/acpmux/src/hub/turns.rs @@ -848,8 +848,8 @@ impl Hub { { let mut applied = session.applied.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if super::start_defaults::is_effort_id(config_id) { - applied.effort = value.as_str().map(str::to_owned); + if let Some(effort) = super::start_defaults::effort_of(config_id, &value) { + applied.effort = Some(effort.to_owned()); } } Ok(r) diff --git a/cmux-tui/crates/acpmux/src/tui/actions.rs b/cmux-tui/crates/acpmux/src/tui/actions.rs index d251c1dfbe6a..20beb87725eb 100644 --- a/cmux-tui/crates/acpmux/src/tui/actions.rs +++ b/cmux-tui/crates/acpmux/src/tui/actions.rs @@ -83,7 +83,7 @@ pub fn def(action: Action) -> &'static ActionDef { } /// Effort levels offered for a draft, before the harness can be asked. -/// Codex uses ultra as its top level; Claude Code adds Ultracode and Ultrathink after max. +/// Codex uses ultra as its top level; Claude Code's own levels end at max. pub fn draft_effort_levels(agent: &str) -> Vec<(&'static str, &'static str)> { if agent.contains("codex") { vec![ @@ -102,8 +102,6 @@ pub fn draft_effort_levels(agent: &str) -> Vec<(&'static str, &'static str)> { ("high", "High"), ("xhigh", "Extra High"), ("max", "Max"), - ("ultracode", "Ultracode"), - ("ultrathink", "Ultrathink"), ] } } diff --git a/cmux-tui/crates/acpmux/tests/claude_reasoning.rs b/cmux-tui/crates/acpmux/tests/claude_reasoning.rs new file mode 100644 index 000000000000..0aecad72686d --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/claude_reasoning.rs @@ -0,0 +1,259 @@ +//! Claude Code's reasoning options, as the composer shows them. +//! +//! The Reasoning select lists only Claude Code's effort levels. Ultracode is +//! a Claude Code setting (workflow orchestration at Extra High), not a level, +//! so it is its own On/Off switch. No choice rewrites the person's prompt. + +#![cfg(unix)] + +use acpmux::config::{Config, HarnessProfile, PermissionPolicy, StoreMode}; +use acpmux::hub::Hub; +use acpmux::rpc::{Message, method}; +use acpmux::server::{Origin, serve_connection_with}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +/// One local client of the hub (the app pane's connection). +struct TestClient { + tx: mpsc::Sender, + rx: mpsc::Receiver, + next: i64, +} + +impl TestClient { + async fn request(&mut self, m: &str, params: Value) -> Result { + self.next += 1; + let id = self.next; + self.tx.send(Message::request(id, m, params).to_line()).await.unwrap(); + loop { + let line = tokio::time::timeout(Duration::from_secs(20), self.rx.recv()) + .await + .expect("timeout waiting for response") + .expect("connection closed"); + if let Message::Response { id: rid, result, error } = Message::parse(&line).unwrap() + && rid == id + { + return match error { + Some(e) => Err(e.message), + None => Ok(result.unwrap_or(Value::Null)), + }; + } + } + } +} + +/// The person key this test's hub holds, as a daemon the app started does. +const PERSON_KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn cwd() -> String { + std::env::temp_dir().to_string_lossy().into_owned() +} + +/// A hub whose only harness runs `fake_claude.py` as Claude Code. +async fn claude_setup() -> (Arc, TestClient) { + let fake_claude = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_claude.py"); + let mut agents = BTreeMap::new(); + agents.insert( + "fakeclaude".to_owned(), + HarnessProfile { + kind: acpmux::config::HarnessKind::ClaudeStdio, + argv: vec!["python3".into(), fake_claude.into()], + env: BTreeMap::new(), + description: None, + fallback: None, + family: None, + models: vec![], + model: None, + effort: None, + policy: None, + }, + ); + let mut cfg = Config { + harnesses: agents, + default_harness: Some("fakeclaude".into()), + ..Default::default() + }; + cfg.store.mode = StoreMode::Memory; + cfg.permission_policy = PermissionPolicy::ApproveAll; + let store = acpmux::store::open(&cfg.store, std::path::Path::new("/nonexistent")).unwrap(); + let hub = Hub::new(cfg, store); + // The client is the person at the app (`hub/person.rs`): Fast mode and the + // `ultracode` option are theirs to switch (the app's pane rides `effort`). + hub.person.install_spawn_key(PERSON_KEY).unwrap(); + let (in_tx, in_rx) = mpsc::channel(64); + let (out_tx, out_rx) = mpsc::channel(4096); + tokio::spawn(serve_connection_with(hub.clone(), in_rx, out_tx, Origin::Local)); + let mut c = TestClient { tx: in_tx, rx: out_rx, next: 0 }; + let init = c + .request(method::INITIALIZE, json!({"protocolVersion": 1, "clientInfo": {"name": "test"}})) + .await + .unwrap(); + let challenge = &init["_meta"]["acpmux"]["personChallenge"]; + let proof = acpmux::hub::person::person_proof( + PERSON_KEY, + acpmux::hub::person::TRANSPORT_UNIX, + challenge["nonce"].as_str().unwrap(), + challenge["connection"].as_str().unwrap(), + ) + .unwrap(); + let r = c.request("_acpmux/person_prove", json!({"proof": proof})).await.unwrap(); + assert_eq!(r["person"], json!(true), "{r}"); + (hub, c) +} + +fn option<'a>(options: &'a Value, id: &str) -> &'a Value { + options + .as_array() + .and_then(|a| a.iter().find(|o| o["id"] == id)) + .unwrap_or_else(|| panic!("no {id} option: {options}")) +} + +fn values(option: &Value) -> Vec { + option["options"] + .as_array() + .unwrap() + .iter() + .map(|o| o["value"].as_str().unwrap().to_owned()) + .collect() +} + +async fn set(c: &mut TestClient, id: &str, config: &str, value: &str) -> Value { + let r = c + .request( + method::SESSION_SET_CONFIG_OPTION, + json!({"sessionId": id, "configId": config, "value": value}), + ) + .await + .unwrap(); + r["configOptions"].clone() +} + +/// What Claude Code read: the prompt text, every flag-settings change and its argv. +async fn probe(hub: &Arc, c: &mut TestClient, id: &str) -> Value { + let before = hub.events(id, 0, 10_000).unwrap().len(); + c.request( + method::SESSION_PROMPT, + json!({"sessionId": id, "prompt": [{"type": "text", "text": "settings-probe"}]}), + ) + .await + .unwrap(); + let text: String = hub + .events(id, 0, 10_000) + .unwrap() + .iter() + .skip(before) + .filter(|e| e.kind == "agent_message_chunk") + .filter_map(|e| e.msg.pointer("/params/update/content/text").and_then(Value::as_str)) + .collect(); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{e}: {text}")) +} + +#[tokio::test] +async fn claude_reasoning_lists_effort_levels_and_ultracode_is_its_own_switch() { + let (hub, mut c) = claude_setup().await; + let s = c + .request( + method::SESSION_NEW, + json!({"cwd": cwd(), "mcpServers": [], "_meta": {"acpmux": {"harness": "fakeclaude", "name": "think"}}}), + ) + .await + .unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + + let options = set(&mut c, &id, "effort", "high").await; + let effort = option(&options, "effort"); + assert_eq!(values(effort), ["default", "low", "medium", "high", "xhigh", "max"], "{effort}"); + assert_eq!(effort["currentValue"], "high"); + let ultracode = option(&options, "ultracode"); + assert_eq!(values(ultracode), ["off", "on"], "{ultracode}"); + assert_eq!(ultracode["currentValue"], "off"); + + // Ultracode on: Claude Code runs at Extra High with its ultracode setting. + let options = set(&mut c, &id, "ultracode", "on").await; + assert_eq!(option(&options, "ultracode")["currentValue"], "on"); + assert_eq!(option(&options, "effort")["currentValue"], "xhigh"); + let got = probe(&hub, &mut c, &id).await; + assert_eq!(got["said"], "settings-probe", "the prompt reaches Claude as typed: {got}"); + assert_eq!( + got["settings"].as_array().unwrap().last().unwrap(), + &json!({"effortLevel": "xhigh", "ultracode": true}), + "{got}" + ); + + // Picking a level turns Ultracode off. + let options = set(&mut c, &id, "effort", "medium").await; + assert_eq!(option(&options, "ultracode")["currentValue"], "off"); + assert_eq!(option(&options, "effort")["currentValue"], "medium"); + let got = probe(&hub, &mut c, &id).await; + assert_eq!( + got["settings"].as_array().unwrap().last().unwrap(), + &json!({"effortLevel": "medium", "ultracode": false}), + "{got}" + ); + + // Ultracode survives a respawn: Extra High on the command line, the + // setting on the live channel before the first turn. + set(&mut c, &id, "ultracode", "on").await; + let session = hub.resolve("think").unwrap(); + hub.detach_child(&session).await; + let got = probe(&hub, &mut c, &id).await; + let argv: Vec = serde_json::from_value(got["argv"].clone()).unwrap(); + let at = argv.iter().position(|a| a == "--effort").unwrap_or_else(|| panic!("{argv:?}")); + assert_eq!(argv[at + 1], "xhigh", "{argv:?}"); + assert_eq!(got["settings"], json!([{"effortLevel": "xhigh", "ultracode": true}]), "{got}"); + assert_eq!(got["said"], "settings-probe", "{got}"); +} + +#[tokio::test] +async fn claude_reasoning_a_saved_ultrathink_pick_runs_the_default_level_unrewritten() { + let (hub, mut c) = claude_setup().await; + let s = c + .request( + method::SESSION_NEW, + json!({"cwd": cwd(), "mcpServers": [], "_meta": {"acpmux": {"harness": "fakeclaude", "name": "legacy"}}}), + ) + .await + .unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + // An older pane (or a remembered pick) still sends the retired value. + let options = set(&mut c, &id, "effort", "ultrathink").await; + assert_eq!(option(&options, "effort")["currentValue"], "default", "{options}"); + let got = probe(&hub, &mut c, &id).await; + assert_eq!(got["said"], "settings-probe", "{got}"); + assert_eq!( + got["settings"].as_array().unwrap().last().unwrap(), + &json!({"effortLevel": "auto", "ultracode": false}), + "{got}" + ); +} + +#[tokio::test] +async fn claude_reasoning_a_new_chat_attaches_with_its_effort_and_fast_options() { + // The composer draws Reasoning and Fast mode from the attached chat's + // options (Lawrence 2026-10-14: "we lost effort"). + let (_hub, mut c) = claude_setup().await; + let s = c + .request( + method::SESSION_NEW, + json!({"cwd": cwd(), "mcpServers": [], "_meta": {"acpmux": {"harness": "fakeclaude", "name": "fresh"}}}), + ) + .await + .unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + for moment in ["before its first prompt", "after its first prompt"] { + let a = c.request(method::MUX_ATTACH, json!({"sessionId": id, "limit": 0})).await.unwrap(); + let options = &a["session"]["configOptions"]; + assert!(options.is_array(), "{moment}: no options: {a}"); + assert!(values(option(options, "effort")).len() > 1, "{moment}: {options}"); + assert_eq!(values(option(options, "fast-mode")), ["off", "on"], "{moment}: {options}"); + c.request( + method::SESSION_PROMPT, + json!({"sessionId": id, "prompt": [{"type": "text", "text": "hello"}]}), + ) + .await + .unwrap(); + } +} diff --git a/cmux-tui/crates/acpmux/tests/fake_claude.py b/cmux-tui/crates/acpmux/tests/fake_claude.py index 79af4f883871..0e1373d370b6 100755 --- a/cmux-tui/crates/acpmux/tests/fake_claude.py +++ b/cmux-tui/crates/acpmux/tests/fake_claude.py @@ -16,6 +16,8 @@ a user and an assistant line per prompt; "history-probe" replies with the prompts the conversation holds (a resumed or forked transcript's included). "hold: PATH" answers once PATH exists, so a test can act while a turn runs. +A prompt ending in "settings-probe" replies with the prompt text as Claude +read it, every `apply_flag_settings` it got, and its argv. A stop_task control request ends its task killed (task_updated); FAKE_CLAUDE_STOP_NOTIFY=1 sends the task's task_notification too. FAKE_CLAUDE_AUTH=1 looks for a sign-in the way Claude Code does (the @@ -56,6 +58,7 @@ def stored(sid): return os.path.join(STORE, sid + (LAYOUT or "")) SESSION = "fake-claude-session" RESUME = None +FLAG_SETTINGS = [] if STORE: for flag in ("--session-id", "--resume"): if flag in sys.argv[:-1]: @@ -144,6 +147,8 @@ def ask(rid, tool): if request.get("subtype") == "initialize" and SKILLS: answer = {"commands": COMMANDS} send({"type": "control_response", "response": {"subtype": "success", "request_id": msg.get("request_id"), "response": answer}}) + if request.get("subtype") == "apply_flag_settings": + FLAG_SETTINGS.append(request.get("settings")) # Like Claude Code, a stopped task ends killed (task_updated); FAKE_CLAUDE_STOP_NOTIFY=1 also # sends its task_notification, as a Claude Code that does not suppress it would. if request.get("subtype") == "stop_task": @@ -235,6 +240,8 @@ def ask(rid, tool): while not os.path.exists(said[len("hold: "):]): time.sleep(0.02) text = json.dumps(sys.argv[1:]) + if said.endswith("settings-probe"): + text = json.dumps({"said": said, "settings": FLAG_SETTINGS, "argv": sys.argv[1:]}) if said.strip() == "history-probe" and STORE and LAYOUT: with open(stored(SESSION)) as f: records = [json.loads(line) for line in f if line.strip()] diff --git a/webviews/src/agent-session/acpmux/ComposerPickers.tsx b/webviews/src/agent-session/acpmux/ComposerPickers.tsx index 180f048e0c68..ca535b2fabf5 100644 --- a/webviews/src/agent-session/acpmux/ComposerPickers.tsx +++ b/webviews/src/agent-session/acpmux/ComposerPickers.tsx @@ -16,6 +16,7 @@ import type { CatalogRefreshState, ModelCombo, ModelPickerProps } from "./modelP import { topModelsFor } from "./topModels"; import { rememberPick } from "./lastPick"; import { defaultModel } from "./harnessSwitch"; +import { catalogRow } from "./catalogRow"; import { harnessProfiles } from "./harnessProfiles"; import type { PickerCatalog } from "./modelCatalogData"; import { Popover } from "../../ui/Popover"; @@ -212,11 +213,35 @@ export function ComposerPickers({ (option) => option.category === "thought_level" || option.id === "effort" || option.id === "reasoning_effort", ); // The running model's catalog row (live list_models / model/list when known): which levels it - // supports and which one is its own default. - const catalogModel = pickerCatalog?.harnesses - .find((entry) => entry.acpmuxHarness === summary?.harness || entry.id === summary?.harness) - ?.models.find((entry) => entry.id === summary?.model); + // supports and which one is its own default. A chat on the agent's default model is described + // by the model that default resolved to. + const runningModel = + summary?.model && isDefaultChoice({ id: summary.model }) + ? (resolvedModel(summary) ?? (summary.harness ? loadResolvedDefaults()[summary.harness] : undefined)) + : summary?.model; + const catalogModel = catalogRow( + pickerCatalog?.harnesses.find((entry) => entry.acpmuxHarness === summary?.harness || entry.id === summary?.harness) + ?.models, + runningModel, + ); const efforts: Choice[] = reasoningChoices(effort?.options ?? [], catalogModel, t); + // Claude Code's Ultracode: a switch beside the levels, offered where the model runs Extra High. + // It rides the free effort option (ultracode on, Extra High off): acpmux's own `ultracode` id is a + // mode switch the app would confirm with a sheet. + const ultracodeOption = summary?.configOptions?.find((option) => option.id === "ultracode"); + const ultracode: SpeedSection | undefined = + ultracodeOption && efforts.some((choice) => choice.id === "xhigh") + ? { + title: t("effort.level.ultracode"), + choices: [ + { id: "on", name: t("picker.on"), description: t("effort.ultracodeDetail") }, + { id: "off", name: t("picker.off") }, + ], + current: ultracodeOption.currentValue, + on: "on", + onPick: (value) => onEffort(effort?.id ?? "effort", value === "on" ? "ultracode" : "xhigh"), + } + : undefined; const fastOption = snapshot.summary?.configOptions?.find( (option) => option.id === "fast-mode" || /fast[ _-]?mode/i.test(option.name ?? ""), ); @@ -418,18 +443,20 @@ export function ComposerPickers({ // bars; the session's values then replace them in place (cx-ncc.58, Leo 2026-10-13). const awaiting = chipHarness !== undefined && (!summary?.sessionId || (switching && snapshot.switching?.phase !== "failed")); - const effortValue = - effort && - (effort.currentValue === "default" && catalogModel?.defaultEffort - ? catalogModel.defaultEffort - : effort.currentValue); - const effortChoice = efforts.find((choice) => choice.id === effortValue) ?? efforts[0]; + // The agent's own level ("default") names the level it resolves to: the running model's default + // effort. Unresolved, the chip says Reasoning; it never guesses the first level. + const effortDefaulted = effort?.currentValue === "default"; + const effortValue = effort && (effortDefaulted ? catalogModel?.defaultEffort : effort.currentValue); + const effortChoice = efforts.find((choice) => choice.id === effortValue); + const ultracodeOn = ultracode !== undefined && ultracode.current === ultracode.on; const effortLabel = effort && efforts.length > 1 - ? !effortChoice || isDefaultChoice(effortChoice) - ? t("picker.reasoning") - : effortChoice.name + ? ultracodeOn + ? t("effort.level.ultracode") + : (effortChoice?.name ?? t("picker.reasoning")) : undefined; + // An unresolved level is not remembered: the next chat would paint it as a pick. + const effortShown = effortChoice || ultracodeOn ? effortLabel : undefined; const shownMode = modes.length > 0 ? (modes.find((choice) => choice.id === mode?.id) ?? modes[0]) : undefined; const modeLabel = shownMode?.name; const modeId = shownMode?.id; @@ -440,9 +467,9 @@ export function ComposerPickers({ const startEffortKey = startEffort ? LEVEL_KEYS[startEffort] : undefined; const pendingEffort = lastChips?.effort ?? (startEffortKey ? t(startEffortKey) : undefined); useEffect(() => { - if (!awaiting && chipHarness && summary?.sessionId && (effortLabel || modeLabel)) - rememberLastChips(chipHarness, { effort: effortLabel, mode: modeLabel, modeId }); - }, [awaiting, chipHarness, summary?.sessionId, effortLabel, modeLabel, modeId]); + if (!awaiting && chipHarness && summary?.sessionId && (effortShown || modeLabel)) + rememberLastChips(chipHarness, { effort: effortShown, mode: modeLabel, modeId }); + }, [awaiting, chipHarness, summary?.sessionId, effortShown, modeLabel, modeId]); const usage = summary?.usage; const usageNote = showContextUsage && usage && usage.size > 0 @@ -500,8 +527,14 @@ export function ComposerPickers({ label={t(PICKER_LABELS.effort)} efforts={efforts} current={effortValue} + defaulted={effortDefaulted} + ultracode={ultracode} speed={ - fastMode && fastOn && fastOff + // Fast mode only where the running model honors it (Claude Code offers it on every model). + fastMode && + fastOn && + fastOff && + (catalogModel?.fast !== false || fastMode.currentValue === fastMode.onValue) ? speedSection(/codex/i.test(summary?.harness ?? ""), fastMode, fastOn, t) : undefined } @@ -1154,15 +1187,16 @@ const LEVEL_KEYS: Record = { xhigh: "effort.level.xhigh", max: "effort.level.max", ultra: "effort.level.ultra", + // A new chat that starts on Ultracode (its saved pick); never a level row (reasoningChoices). ultracode: "effort.level.ultracode", - ultrathink: "effort.level.ultrathink", }; /// The plain levels a catalog row can list (`PickerModel.efforts`). const PLAIN_LEVELS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); /// The Reasoning section's rows: the agent's levels, named by the pane, narrowed to what the -/// running model supports when its catalog row says (Ultracode needs Extra High; Ultrathink needs -/// any level). The agent-owned default sentinel is intentionally omitted. +/// running model supports when its catalog row says. The agent-owned default sentinel is omitted, +/// and so are an older acpmux's Ultracode (now its own switch) and Ultrathink (a prompt keyword, +/// not a level). export function reasoningChoices( options: { value: string; name?: string; description?: string }[], model: { efforts?: readonly string[]; defaultEffort?: string } | undefined, @@ -1170,16 +1204,13 @@ export function reasoningChoices( ): Choice[] { const levels = model?.efforts && model.efforts.length > 0 ? new Set(model.efforts) : undefined; const offered = options.filter((option) => { - if (option.value === "default") return false; + if (["default", "ultracode", "ultrathink"].includes(option.value)) return false; if (!levels) return true; - if (option.value === "ultracode") return levels.has("xhigh"); - if (option.value === "ultrathink") return true; return !PLAIN_LEVELS.has(option.value) || levels.has(option.value); }); return offered.map((option) => { const key = LEVEL_KEYS[option.value]; const base: Choice = { id: option.value, name: key ? t(key) : option.name || option.value }; - if (option.value === "ultracode") base.description = t("effort.ultracodeDetail"); if (option.value === model?.defaultEffort) base.recommended = true; return base; }); diff --git a/webviews/src/agent-session/acpmux/EffortPicker.tsx b/webviews/src/agent-session/acpmux/EffortPicker.tsx index a7b6a2d45d8a..973137abb668 100644 --- a/webviews/src/agent-session/acpmux/EffortPicker.tsx +++ b/webviews/src/agent-session/acpmux/EffortPicker.tsx @@ -5,8 +5,8 @@ import { useT } from "./i18n"; import { registerPicker } from "./pickerOpeners"; import { Menu, MenuButton, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator } from "../../ui/Menu"; -/// A second choice in the reasoning menu: Claude Code's Fast Mode (On/Off) or Codex's Service -/// Tier (Standard/Fast). `on` is the choice that makes the chip say "Fast". +/// A switch section in the reasoning menu: Claude Code's Fast Mode (On/Off) or Codex's Service +/// Tier (Standard/Fast), and Claude Code's Ultracode. `on` is the choice the chip names. export type SpeedSection = { title: string; choices: Choice[]; @@ -16,16 +16,19 @@ export type SpeedSection = { }; /// The reasoning chip and its menu (Lawrence 2026-10-09, MonoCode's picker): a Reasoning section -/// with one checked row per level the model offers and a line under a level that needs one -/// (Ultracode); then the speed section when the agent has one. -/// The chip names the level, plus "Fast" while fast mode is on. The menu is the shared popup -/// layer's opaque surface (ui/Menu), so nothing shows through it. Picking sends chat.effort -/// through `onPick` (and the speed section's own `onPick`). +/// with one checked row per level the model offers; then Ultracode and the speed section when the +/// agent has them. The agent's own level ("default", `defaulted`) checks the level it resolves to, +/// with a Default hint, and the chip names that level; unresolved, it says Reasoning, never the +/// first level. The chip says Ultracode while that is on, plus "Fast" while fast mode is on. +/// The menu is the shared popup layer's opaque surface (ui/Menu), so nothing shows through it. +/// Picking sends chat.effort through `onPick` (and each section's own `onPick`). export function EffortPicker({ label, efforts, current, onPick, + defaulted = false, + ultracode, speed, chevron, }: { @@ -34,6 +37,9 @@ export function EffortPicker({ efforts: Choice[]; current?: string; onPick(value: string): void; + /// The agent runs its own level; `current` is the level that resolves to. + defaulted?: boolean; + ultracode?: SpeedSection; speed?: SpeedSection; chevron?: React.ReactNode; }) { @@ -42,9 +48,11 @@ export function EffortPicker({ // The agent's implicit level is represented by the chip, not a second "Default" row. Keep the // real levels in the menu so the first arrow lands on an actionable choice. const visibleEfforts = efforts.filter((choice) => !isDefaultChoice(choice)); - const selected = efforts.find((choice) => choice.id === current) ?? efforts[0]; const selectedVisible = visibleEfforts.find((choice) => choice.id === current); - const level = !selected || isDefaultChoice(selected) ? t("picker.reasoning") : selected.name; + const level = + ultracode && ultracode.current === ultracode.on + ? ultracode.title + : (selectedVisible?.name ?? t("picker.reasoning")); const fast = speed !== undefined && speed.current === speed.on; // Automation opens the menu by its label as a click does (see pickerOpeners.ts). useEffect(() => registerPicker(label, () => setOpen(true)), [label]); @@ -62,6 +70,8 @@ export function EffortPicker({ }); return () => cancelAnimationFrame(frame); }, [open]); + const levelRow = (choice: Choice) => + row(defaulted && choice.id === selectedVisible?.id ? { ...choice, hint: t("picker.default") } : choice); const row = (choice: Choice) => ( @@ -92,25 +102,28 @@ export function EffortPicker({ setOpen(false); }} > - {visibleEfforts.map(row)} + {visibleEfforts.map(levelRow)} )} - {speed && ( - <> - - - { - speed.onPick(next); - setOpen(false); - }} - > - {speed.choices.map(row)} - - - + {[ultracode, speed].map( + (section) => + section && ( + + + + { + section.onPick(next); + setOpen(false); + }} + > + {section.choices.map(row)} + + + + ), )} diff --git a/webviews/src/agent-session/acpmux/catalogRow.ts b/webviews/src/agent-session/acpmux/catalogRow.ts new file mode 100644 index 000000000000..3bbe0de08049 --- /dev/null +++ b/webviews/src/agent-session/acpmux/catalogRow.ts @@ -0,0 +1,11 @@ +/// The catalog row of the model a chat runs: by its id, or the release an alias row stands for +/// ("opus"), then without a context variant suffix ("claude-opus-5-5[1m]" runs Opus 5.5's +/// levels). Its levels and default effort describe the Reasoning menu. +export function catalogRow( + models: readonly T[] | undefined, + id: string | undefined, +): T | undefined { + if (!models || !id) return undefined; + const find = (want: string) => models.find((model) => model.id === want || model.release === want); + return find(id) ?? find(id.replace(/\[[^\]]*\]$/, "")); +} diff --git a/webviews/src/agent-session/acpmux/composer.gallery.ts b/webviews/src/agent-session/acpmux/composer.gallery.ts index c34207dcf469..ebb5732b8e1a 100644 --- a/webviews/src/agent-session/acpmux/composer.gallery.ts +++ b/webviews/src/agent-session/acpmux/composer.gallery.ts @@ -51,6 +51,18 @@ const composerControls = { }, }; +/// Claude Code's Ultracode switch, as acpmux's claude_stdio lists it beside the levels. +const CLAUDE_ULTRACODE_OFF = { + id: "ultracode", + name: "Ultracode", + category: "model_config", + currentValue: "off", + options: [ + { value: "off", name: "Off" }, + { value: "on", name: "On", description: "Extra High effort plus multi-agent workflow orchestration" }, + ], +}; + export default agentPaneEntry({ id: "agent-pane.composer", title: "Composer", @@ -750,7 +762,7 @@ export default agentPaneEntry({ }, }, "reasoning-claude": { - note: "Play: Claude's reasoning menu: Low to Max with Extra High, Ultracode (with its line), Ultrathink, then Fast Mode On/Off; the chip reads Medium Fast.", + note: "Play: Claude's reasoning menu: Low to Max with Extra High, then Ultracode On/Off (with its line) and Fast Mode On/Off; the chip reads Medium Fast.", snapshot: withSummary(chat(finished, { title: "Claude reasoning" }), { configOptions: [ { @@ -765,10 +777,9 @@ export default agentPaneEntry({ { value: "high", name: "High" }, { value: "xhigh", name: "Extra High" }, { value: "max", name: "Max" }, - { value: "ultracode", name: "Ultracode" }, - { value: "ultrathink", name: "Ultrathink" }, ], }, + CLAUDE_ULTRACODE_OFF, { id: "fast-mode", name: "Fast mode", @@ -786,6 +797,38 @@ export default agentPaneEntry({ await ctx.waitFor(() => ctx.document.querySelector(".acpmux-effort-menu")); }, }, + "reasoning-claude-default": { + note: "Play: a chat on Claude Code's own level (default) on Opus 5.5 [1m]: the chip names the level it resolves to, Medium; the menu checks Medium with a Default hint and lists no Ultrathink.", + snapshot: withSummary(chat(finished, { title: "Claude default reasoning" }), { + model: "claude-opus-5-5[1m]", + configOptions: [ + { + id: "effort", + name: "Reasoning", + category: "thought_level", + currentValue: "default", + // An older acpmux still lists Ultrathink, a prompt keyword: the menu drops it. + options: ["default", "low", "medium", "high", "xhigh", "max", "ultrathink"].map((value) => ({ value })), + }, + CLAUDE_ULTRACODE_OFF, + ], + }), + play: async (ctx) => { + const chip = () => ctx.document.querySelector(".acpmux-effort .acpmux-picker-button"); + await ctx.waitFor(() => chip()?.textContent?.trim() === "Medium"); + await ctx.click({ selector: ".acpmux-effort .acpmux-picker-button" }); + await ctx.waitFor(() => { + const checked = ctx.document.querySelectorAll( + '.acpmux-effort-menu [role="menuitemradio"][aria-checked="true"]', + ); + const level = checked[0]?.textContent ?? ""; + return level.includes("Medium") && level.includes("Default"); + }); + await ctx.waitFor( + () => !ctx.document.querySelector(".acpmux-effort-menu")?.textContent?.includes("Ultrathink"), + ); + }, + }, "reasoning-codex": { note: "Play: open Codex's reasoning menu, choose Service Tier Fast, and close it; the snapshot shows Low to Ultra (Xhigh reads Extra High) plus Standard (Default) and Fast.", snapshot: withSummary(chat(finished, { harness: "codex", model: "gpt-6-astra", title: "Codex reasoning" }), { diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index c4cc0163162b..3c6b91ecc2cc 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -2882,6 +2882,8 @@ export function normalizeCatalog(value: any): AcpmuxSnapshot["catalog"] { models: (harness.models ?? []).map((model: any) => catalogModel(model)), ...(typeof harness.family === "string" && harness.family ? { family: harness.family } : {}), ...(typeof harness.icon === "string" && harness.icon ? { icon: harness.icon } : {}), + // A terminal harness (Muse Code) runs in a terminal tab, never as a chat: no picker lists it. + ...(harness.kind === "terminal" ? { pickable: false } : {}), ...(harnessStart(harness.start) ? { start: harnessStart(harness.start) } : {}), ...(typeof harness.auth?.login === "string" && harness.auth.login.trim() ? { auth: { login: harness.auth.login.trim() } } diff --git a/webviews/src/agent-session/acpmux/modelCatalogData.ts b/webviews/src/agent-session/acpmux/modelCatalogData.ts index a740228f0034..f460f7f49e36 100644 --- a/webviews/src/agent-session/acpmux/modelCatalogData.ts +++ b/webviews/src/agent-session/acpmux/modelCatalogData.ts @@ -527,7 +527,11 @@ function withLiveOptions(model: PickerModel, options: ConfigOptions): PickerMode const narrowed = offered && model.efforts.length > 0 ? offered.filter((value) => model.efforts.includes(value)) : offered; const efforts = narrowed && narrowed.length > 0 ? narrowed : offered; - const fast = options.some((option) => option.id === "fast" || option.id === "fast_mode" || option.id === "fast-mode"); + // An adapter can offer fast mode for every model too (Claude Code's, honored on a few): a model + // the catalog describes keeps its own. + const fast = + model.efforts.length === 0 && + options.some((option) => option.id === "fast" || option.id === "fast_mode" || option.id === "fast-mode"); return { ...model, ...(efforts && efforts.length > 0 ? { efforts } : {}), diff --git a/webviews/test/claude-reasoning-default.test.ts b/webviews/test/claude-reasoning-default.test.ts new file mode 100644 index 000000000000..a375818b4ba3 --- /dev/null +++ b/webviews/test/claude-reasoning-default.test.ts @@ -0,0 +1,66 @@ +// Claude's reasoning chip on the agent's own level (cx-ervhd, Leo 2026-10-13: a new chat read "Low" +// with nothing checked). A chat whose effort is "default" names the level that resolves to, the +// running model's default effort, even on a context variant id ("claude-opus-5-5[1m]"); the menu +// checks it with a Default hint, and Ultrathink (a prompt keyword) is not a level. +// Before its session reports, a new chat names the level acpmux starts it on, not nothing. +// The composer gallery's plays drive the real pane in Chromium. +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { playFailures } from "./support/galleryPlay"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("claude-reasoning-default.test.ts", async () => { + setDefaultTimeout(120_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("claude-reasoning-default: skipping (run `bunx playwright install chromium`)"); + describe.skip("claude reasoning default", () => test("chromium", () => {})); + return; + } + + const webviews = path.resolve(import.meta.dir, ".."); + const { createServer } = await import("vite"); + let vite: Awaited> | undefined; + let browser: Browser | undefined; + + beforeAll(async () => { + vite = await createServer({ + configFile: path.join(webviews, "vite.config.gallery-dev.ts"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + // Each play gets its own page (and browser context): no chip a play remembers reaches the next. + const play = async (variant: string) => { + const port = (vite!.httpServer!.address() as { port: number }).port; + const page = await browser!.newPage({ viewport: { width: 760, height: 560 } }); + await page.goto(`http://127.0.0.1:${port}/gallery/frame.html?entry=agent-pane.composer&variant=${variant}`); + // A cold dev server compiles the gallery on the first request; give it longer than the default. + await page.waitForFunction(() => Boolean(document.documentElement.dataset.galleryPlay), undefined, { + timeout: 90_000, + }); + const report = await page.evaluate(() => + JSON.stringify((window as unknown as { cmuxGalleryPlayReport?: unknown }).cmuxGalleryPlayReport), + ); + await page.close(); + return report; + }; + + test("the default level reads as the level it resolves to, checked, never Low", async () => { + expect(playFailures(await play("reasoning-claude-default"))).toEqual([]); + }); + + // Lawrence 2026-10-14, "we lost effort": a New Tab chat showed the model chip alone. + test("a new chat names the level acpmux starts it on before its session reports", async () => { + expect(playFailures(await play("new-chat-reasoning"))).toEqual([]); + }); +}); diff --git a/webviews/test/ultracode-rides-effort.test.tsx b/webviews/test/ultracode-rides-effort.test.tsx new file mode 100644 index 000000000000..1e02afb2b69a --- /dev/null +++ b/webviews/test/ultracode-rides-effort.test.tsx @@ -0,0 +1,143 @@ +// Review of #20592: Ultracode is a switch in the Reasoning menu, but it rides the session's free +// effort option (effort=ultracode / effort=xhigh). A pick on acpmux's own `ultracode` id is a mode +// switch the app confirms with a sheet, and a non-person pane is refused it. At the composer's +// boundary: the real picker and a host that records what the pane sends. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +const scope = globalThis as Record; +const keys = [ + "window", + "document", + "navigator", + "IS_REACT_ACT_ENVIRONMENT", + "HTMLElement", + "Element", + "Node", + "ShadowRoot", + "MouseEvent", + "KeyboardEvent", + "Event", + "CustomEvent", + "customElements", + "MutationObserver", + "CSSStyleSheet", + "DocumentFragment", + "SVGElement", + "HTMLTemplateElement", + "localStorage", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + "PointerEvent", + "FocusEvent", +] as const; +const saved = keys.map((key) => [key, key in scope, scope[key]] as const); +let dom: JSDOM; +let root: Root | undefined; +let host: HTMLElement; + +beforeAll(() => { + dom = new JSDOM("
", { pretendToBeVisual: true, url: "https://cmux.test/" }); + const w = dom.window as unknown as Record; + Object.assign(scope, Object.fromEntries(keys.map((key) => [key, w[key]])), { + IS_REACT_ACT_ENVIRONMENT: true, + PointerEvent: w.PointerEvent ?? w.MouseEvent, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + }); + host = dom.window.document.getElementById("root")!; +}); + +afterAll(() => { + act(() => root?.unmount()); + dom.window.close(); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +function press(element: Element) { + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) + element.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, cancelable: true, button: 0 })); +} + +async function pick(label: string) { + const items = () => [...dom.window.document.querySelectorAll('.acpmux-effort-menu [role="menuitemradio"]')]; + if (items().length === 0) await act(async () => press(host.querySelector(".acpmux-effort .acpmux-picker-button")!)); + // The Ultracode section's rows come after the levels: its On is the first "On". + const row = items().find((item) => item.textContent?.trim().startsWith(label)); + expect( + row, + `row ${label} in ${items() + .map((item) => item.textContent) + .join(" | ")}`, + ).toBeDefined(); + await act(async () => press(row!)); +} + +test("Ultracode On and Off ride the free effort option, never acpmux's ultracode id", async () => { + const { createRoot } = await import("react-dom/client"); + const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query"); + const { ComposerPickers } = await import("../src/agent-session/acpmux/ComposerPickers"); + type Snapshot = import("../src/agent-session/acpmux/model").AcpmuxSnapshot; + const sent: string[] = []; + const snapshot = (ultracode: string) => + ({ + type: "snapshot", + protocolVersion: 1, + rows: [], + sessions: [], + connection: "test", + isWorking: false, + queue: [], + canLoadOlder: false, + catalog: [], + summary: { + sessionId: "s1", + harness: "claude", + model: "claude-opus-5-5", + turnCount: 1, + configOptions: [ + { + id: "effort", + name: "Reasoning", + category: "thought_level", + currentValue: ultracode === "on" ? "xhigh" : "medium", + options: ["default", "low", "medium", "high", "xhigh", "max"].map((value) => ({ value })), + }, + { + id: "ultracode", + name: "Ultracode", + category: "model_config", + currentValue: ultracode, + options: [{ value: "off" }, { value: "on" }], + }, + ], + }, + }) as Snapshot; + const render = (ultracode: string) => + root!.render( + + undefined} + onMode={() => undefined} + onEffort={(configId, value) => sent.push(`${configId}=${value}`)} + onHarness={() => undefined} + showPlan={false} + /> + , + ); + const client = new QueryClient(); + root = createRoot(host); + await act(async () => render("off")); + await pick("On"); + await act(async () => render("on")); + await pick("Off"); + expect(sent).toEqual(["effort=ultracode", "effort=xhigh"]); +}); From 1bf155a1923a848b1514202518a2e4c24f2e9337 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:57:24 -0700 Subject: [PATCH 142/183] cmux-next: model rail dims a signed-out harness and offers Log In (cx-ae9eb) (#20692) * test(webviews): a signed-out harness offers Log In in the model rail, never its models (cx-ae9eb) Co-Authored-By: Claude Opus 5.5 * fix(cmux-next): the model rail dims a signed-out harness and offers Log In (cx-ae9eb) The pane reads which harnesses this Mac's acpmux last found signed out (_acpmux/harness/health) and hands them to the model picker. Their rail tab dims and lists Log In (the harness's login, run in a terminal tab) in place of their models, and a pick or a search never reaches those models, so a chat no longer starts on a harness that fails its first turn. Co-Authored-By: Claude Opus 5.5 * gallery: the model picker with a signed-out harness (cx-ae9eb) Co-Authored-By: Claude Opus 5.5 * test(webviews): the running chat's harness keeps its models; Starred lists no signed-out model (cx-ae9eb) From the review of #20692. Co-Authored-By: Claude Opus 5.5 * fix(cmux-next): rail keeps the running chat's harness, hides signed-out stars, rechecks after Log In (cx-ae9eb) From the review of #20692: - after a Log In the rail asks acpmux to check again, not its last rows; - a started chat's own harness is never blocked; - Starred lists no model a pick would refuse; - a signed-out tab's list holds no rows, so the keyboard never moves through hidden ones. Co-Authored-By: Claude Opus 5.5 * test(webviews): a signed-out tab shows no More models fold (cx-ae9eb) Seen on a cosmic-gray DEV build of #20692. Co-Authored-By: Claude Opus 5.5 * fix(cmux-next): a restored pane reads sign-in once its client connects; no More models on a signed-out tab (cx-ae9eb) Seen on a cosmic-gray DEV build of #20692: a pane restored at launch read health before its direct client existed and never again, so its rail stayed undimmed; and a signed-out tab still offered its More models fold. Co-Authored-By: Claude Opus 5.5 * chore(next): PR autofix at 92a5f0e76900 - vp fmt webviews/src/pages/home-channels/HomeChannelsPage.tsx Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38634899230 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: cmux-autofix[bot] --- webviews/src/agent-session/acpmux/App.tsx | 44 ++- .../agent-session/acpmux/ComposerPickers.tsx | 17 ++ .../acpmux/ModelPicker.gallery.tsx | 10 + .../src/agent-session/acpmux/ModelPicker.tsx | 44 ++- webviews/src/agent-session/acpmux/direct.ts | 9 +- .../agent-session/acpmux/modelPickerLayout.ts | 6 + webviews/src/agent-session/acpmux/signIn.ts | 26 ++ webviews/test/signed-out-rail.test.tsx | 268 ++++++++++++++++++ 8 files changed, 412 insertions(+), 12 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/signIn.ts create mode 100644 webviews/test/signed-out-rail.test.tsx diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 95b0e894de3c..5a0ca4504332 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -192,6 +192,7 @@ import { sideChatRow } from "./header/sideChatRow"; import { Thinking } from "./conversation/Thinking"; import { WorkingFor } from "./conversation/WorkingFor"; import { type HarnessHealth, HarnessHealthNotice } from "./HarnessHealthNotice"; +import { type SignIn, SignInContext, signedOutHarnesses, useSignIn } from "./signIn"; import { HostError } from "./HostError"; import { SHELL_ROW, ShellRuns, shellContextAttachments, withShellRows } from "./shell/shellRuns"; import { SUBAGENTS } from "./subagents/subagentFold"; @@ -2093,6 +2094,7 @@ function DefaultComposerChips({ }) { const inFolder = startsChat ? { ...(cwd ? { cwd } : {}), deferred: true } : {}; const addAgent = useHarnessesPage(); + const signIn = useSignIn(); // acpmux has answered with its harnesses: only then can the chip say none is installed. const harnessesListed = useHarnessesListed(); // `chat.new` for the same agent and folder is the switch already running, so a second pick @@ -2141,6 +2143,7 @@ function DefaultComposerChips({ // Add harness… (the picker's last row; the chip itself with no harness): the Harnesses page, // in place, from this click's gesture. {...(addAgent ? { onAddAgent: addAgent } : {})} + {...(signIn ? { signIn } : {})} harnessesListed={harnessesListed} showPlan={false} pickerCatalog={picker.catalog} @@ -2430,6 +2433,44 @@ function AcpmuxPane() { live = false; }; }, [connected, healthHarness, healthLocal, healthRecheck, readHarnessHealth]); + // Every harness this Mac's acpmux last found signed out, for the model picker's rail (cx-ae9eb): + // read once the pane's direct client connects (a restored pane connects after its first render), + // and again when a Log In comes back. + const [signedOut, setSignedOut] = useState>({}); + const signedOutRechecked = useRef(0); + useEffect(() => { + const client = directClient.current; + if (!connected || !client || !healthLocal) { + setSignedOut({}); + return; + } + let live = true; + // After a Log In, acpmux checks again: its last rows would still say signed out. + const refresh = healthRecheck !== signedOutRechecked.current; + signedOutRechecked.current = healthRecheck; + void Promise.resolve() + .then(() => client.harnessHealthRows(refresh ? { refresh: true } : {})) + .then((rows) => live && setSignedOut(signedOutHarnesses(rows))) + .catch(() => undefined); + return () => { + live = false; + }; + }, [connected, healthLocal, healthRecheck, catalogSource]); + const signIn = useMemo( + () => ({ + signedOut, + logIn: (_harness, login) => { + healthStale.current = true; + void callNative("tab.open", { + kind: "terminal", + text: login, + ...(snapshot.summary?.cwd ? { cwd: snapshot.summary.cwd } : {}), + run: true, + }).catch(() => undefined); + }, + }), + [signedOut, snapshot.summary?.cwd], + ); const loginCommand = chatLoginCommand(catalog, snapshot.summary?.harness, harnessHealth); // A new chat centers its composer under the hero. const handoff = snapshot.handoff?.record; @@ -5044,7 +5085,7 @@ function AcpmuxPane() { ); if (paneRenderStart > 0) acpmuxPerf.span("pane.body", performance.now() - paneRenderStart); - return ( + const pane = (
@@ -5384,4 +5425,5 @@ function AcpmuxPane() {
); + return {pane}; } diff --git a/webviews/src/agent-session/acpmux/ComposerPickers.tsx b/webviews/src/agent-session/acpmux/ComposerPickers.tsx index ca535b2fabf5..d3b1ee1f6b7a 100644 --- a/webviews/src/agent-session/acpmux/ComposerPickers.tsx +++ b/webviews/src/agent-session/acpmux/ComposerPickers.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useId, useRef, useState } from "react"; +import type { SignIn } from "./signIn"; import { sessionModels } from "./modelCatalog"; import { isDefaultChoice, @@ -123,6 +124,8 @@ type Props = { /// Add harness… (see ModelPickerProps.onAddAgent): the picker's last row, and the whole chip /// once acpmux has listed no harness at all. onAddAgent?(): void; + /// The harnesses this Mac's acpmux found signed out, and their Log In (see ModelPickerProps.signedOut). + signIn?: SignIn; /// acpmux has answered with its harnesses: only then does an empty catalog mean none is installed. harnessesListed?: boolean; /// The model picker's room for side submenus (tests pass a fixed one; see ModelPicker). @@ -149,6 +152,7 @@ export function ComposerPickers({ onHarnessHint, onHarnessEnable, onAddAgent, + signIn, harnessesListed = false, folder, settleMs = RECENT_SETTLE_MS, @@ -498,6 +502,7 @@ export function ComposerPickers({ onHarnessHint={onHarnessHint} onHarnessEnable={onHarnessEnable} onAddAgent={onAddAgent} + {...(signIn ? { signedOut: pickableSignedOut(signIn.signedOut, summary), onLogIn: signIn.logIn } : {})} fastMode={fastMode} catalogRefresh={catalogRefresh} harnessNotes={ @@ -1180,6 +1185,18 @@ export const SlashIcon = () => ( ); /// Level names the pane writes itself, whatever the agent calls them ("Xhigh" reads Extra High). +/// The signed-out harnesses the picker blocks: never the one a started chat runs on, whose session +/// already signed in (a stale or wrong health row must not lock the person out of their chat). +function pickableSignedOut( + signedOut: Readonly>, + summary: AcpmuxSnapshot["summary"], +): Readonly> { + const running = summary?.sessionId ? summary.harness : undefined; + if (!running || !(running in signedOut)) return signedOut; + const { [running]: _running, ...rest } = signedOut; + return rest; +} + const LEVEL_KEYS: Record = { low: "effort.level.low", medium: "effort.level.medium", diff --git a/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx b/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx index bde21ad2a70e..b2a5cb06146e 100644 --- a/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx +++ b/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx @@ -185,6 +185,16 @@ export default componentEntry({ props: { ...base }, play: numericSearch, }, + // Codex is signed out on this Mac (cx-ae9eb): its rail tab dims, and its list offers Log In in + // place of models, so a pick never starts a chat that fails its first turn. + "signed-out": { + props: { ...base, signedOut: { codex: "codex login" }, onLogIn: () => undefined }, + play: async (ctx) => { + await ctx.click({ role: "button", name: "Model" }); + await ctx.click({ selector: ".acpmux-mp-harness[data-signed-out]" }); + await ctx.waitFor(() => ctx.document.querySelector(".acpmux-mp-login") !== null); + }, + }, // A folder profile whose icon names no brand: the chip draws no mark (never a placeholder // square), and its rail tab its initial. "profile-without-mark": { diff --git a/webviews/src/agent-session/acpmux/ModelPicker.tsx b/webviews/src/agent-session/acpmux/ModelPicker.tsx index 4a027a060407..f9738566bafe 100644 --- a/webviews/src/agent-session/acpmux/ModelPicker.tsx +++ b/webviews/src/agent-session/acpmux/ModelPicker.tsx @@ -245,8 +245,12 @@ export function ModelPicker(props: ModelPickerProps) { onCombo, fastMode, catalogRefresh, + signedOut, + onLogIn, usage, } = props; + /// A signed-out harness's login command ("" when acpmux names none); undefined when it can start. + const loginFor = useCallback((entry: HarnessChoice) => signedOut?.[entry.acpmuxHarness ?? entry.id], [signedOut]); const t = useT(); const modelText = t(PICKER_LABELS.model); const searchText = t("picker.search"); @@ -306,25 +310,29 @@ export function ModelPicker(props: ModelPickerProps) { const owner = new Map(); if (searching) { for (const entry of harnesses) - if (entry.pickable && !blockedProfile(entry)) + if (entry.pickable && !blockedProfile(entry) && loginFor(entry) === undefined) for (const model of choicesFor(entry)) if (fitsQuery(model, entry.name, parsed)) owner.set(model, entry); } else if (starredView) { for (const entry of harnesses) - for (const model of choicesFor(entry)) if (favorites.has(model.id)) owner.set(model, entry); - } else if (selected) { + if (loginFor(entry) === undefined) + for (const model of choicesFor(entry)) if (favorites.has(model.id)) owner.set(model, entry); + } else if (selected && loginFor(selected) === undefined) { // Every model past the top ones folds under More models until it opens (cx-jqkx, cx-espus). const { latest, older } = shownSections(selected, runsHere); for (const model of olderOpen ? [...latest, ...older] : latest) owner.set(model, selected); } return owner; - }, [favorites, harnesses, olderOpen, parsed, searching, selected, starredView, runsHere]); + }, [favorites, harnesses, olderOpen, parsed, searching, selected, starredView, runsHere, loginFor]); const visible = useMemo(() => [...owners.keys()], [owners]); // Add harness… is the list's last row, after the models: arrows reach it like a model row. const addRow = onAddAgent ? visible.length : -1; const rowCount = visible.length + (onAddAgent ? 1 : 0); const addRowId = `${menuId}-add-harness`; const onAddRow = active === addRow && (visible.length > 0 || addChosen); - const olderCount = searching || starredView ? 0 : shownSections(selected, runsHere).older.length; + const olderCount = + searching || starredView || (selected && loginFor(selected) !== undefined) + ? 0 + : shownSections(selected, runsHere).older.length; const refreshStatus = localRefreshStatus ?? catalogRefresh?.status ?? "idle"; const refreshDate = catalogRefresh?.date; const formattedRefreshDate = refreshDate @@ -467,7 +475,7 @@ export function ModelPicker(props: ModelPickerProps) { const selectModel = (model: ModelChoice) => { const owner = owners.get(model); - if (model.unavailable || !owner?.pickable) return; + if (model.unavailable || !owner?.pickable || loginFor(owner) !== undefined) return; // A typed effort or fast mode, or a model of another harness, lands as one combo. const effort = searching ? modelEffort(model, parsed.effort) : undefined; const fast = searching ? parsed.fast : undefined; @@ -646,7 +654,8 @@ export function ModelPicker(props: ModelPickerProps) { const railTitle = (entry: HarnessChoice) => { const mark = entry.mark ?? agentBrand(entry.id) ?? entry.id; const shared = (markCounts.get(mark) ?? 0) > 1 && entry.name !== entry.id; - return [shared ? `${entry.name} (${entry.id})` : entry.name, folderNote(entry)].filter(Boolean).join(" · "); + const signIn = loginFor(entry) !== undefined ? t("harnesses.signedOut") : undefined; + return [shared ? `${entry.name} (${entry.id})` : entry.name, folderNote(entry), signIn].filter(Boolean).join(" · "); }; return ( @@ -736,7 +745,8 @@ export function ModelPicker(props: ModelPickerProps) { type="button" key={entry.folder ? `folder:${entry.id}` : entry.name} aria-selected={entry.ids.includes(selectedHarness ?? "")} - aria-disabled={blockedProfile(entry) || undefined} + aria-disabled={blockedProfile(entry) || loginFor(entry) !== undefined || undefined} + data-signed-out={loginFor(entry) !== undefined || undefined} disabled={!entry.pickable} data-harness-index={index} className={`acpmux-mp-harness ${railTab}`} @@ -823,7 +833,23 @@ export function ModelPicker(props: ModelPickerProps) { className="acpmux-mp-models p-1.5" aria-label={searching ? searchText : starredView ? starredText : (selected?.name ?? modelText)} > - {selectedOther && blockedProfile(selected) ? ( + {selected && !searching && !starredView && loginFor(selected) !== undefined ? ( + <> +
{t("harness.signedOut", { agent: selected.name })}
+ {loginFor(selected) && onLogIn && ( + + )} + + ) : selectedOther && blockedProfile(selected) ? (
{selected.folder?.state === "needs-trust" ? t("picker.trustFirst") diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 3c6b91ecc2cc..c7ba1824f6cd 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -2194,12 +2194,17 @@ export class AcpmuxDirectClient { /// undefined: `refresh` checks again; `rescan` has acpmux look for harnesses first (after an /// install from the Harnesses page). async harnessHealth(id: string, options: { refresh?: boolean; rescan?: boolean } = {}): Promise { + const rows = await this.harnessHealthRows(options); + return rows.find((row) => (row as { id?: unknown } | null)?.id === id); + } + /// Every harness's health row from this Mac's acpmux (`_acpmux/harness/health`): its last check + /// unless `refresh`; the model picker's rail reads which ones are signed out (cx-ae9eb). + async harnessHealthRows(options: { refresh?: boolean; rescan?: boolean } = {}): Promise { const reply = await this.request("_acpmux/harness/health", { ...(options.refresh ? { refresh: true } : {}), ...(options.rescan ? { rescan: true } : {}), }); - const rows: unknown[] = Array.isArray(reply?.harnesses) ? reply.harnesses : []; - return rows.find((row) => (row as { id?: unknown } | null)?.id === id); + return Array.isArray(reply?.harnesses) ? reply.harnesses : []; } /// Changes a waiting prompt (`_acpmux/queue_edit`): `update` its text, `move` it to `index` /// (0 runs next) or `send_now`. `ok` is false when it no longer waits. diff --git a/webviews/src/agent-session/acpmux/modelPickerLayout.ts b/webviews/src/agent-session/acpmux/modelPickerLayout.ts index 9b665d5211ed..c99a18ce07ef 100644 --- a/webviews/src/agent-session/acpmux/modelPickerLayout.ts +++ b/webviews/src/agent-session/acpmux/modelPickerLayout.ts @@ -56,6 +56,12 @@ export type ModelPickerProps = { onAddAgent?(): void; /// A short note per harness in place of "New chat" (a harness that failed to start). harnessNotes?: Readonly>; + /// The harnesses this Mac's acpmux found signed out, by acpmux id, with each one's login + /// command (cx-ae9eb). Their rail tab dims and lists Log In in place of models; none of their + /// models can be picked, so a pick never starts a chat that fails on its first turn. + signedOut?: Readonly>; + /// Runs a signed-out harness's login (its Log In row). + onLogIn?(harness: string, login: string): void; /// The room, in px, left of the open menu for its submenus (`menuRoom`). Tests pass a /// number in place of real layout. measureRoom?(menu: HTMLElement): number; diff --git a/webviews/src/agent-session/acpmux/signIn.ts b/webviews/src/agent-session/acpmux/signIn.ts new file mode 100644 index 000000000000..a49d010332a7 --- /dev/null +++ b/webviews/src/agent-session/acpmux/signIn.ts @@ -0,0 +1,26 @@ +import { createContext, useContext } from "react"; + +/// The harnesses this Mac's acpmux last found signed out (`_acpmux/harness/health`, cx-ae9eb), +/// by acpmux harness id, each with its login command ("" when acpmux names none). The model +/// picker's rail dims them and offers Log In in place of their models, so a pick never starts a +/// chat that fails on its first turn. +export type SignIn = { + signedOut: Readonly>; + /// Runs `login` for `harness` in a terminal tab; the pane checks again when it is back. + logIn(harness: string, login: string): void; +}; + +export const SignInContext = createContext(undefined); + +export const useSignIn = () => useContext(SignInContext); + +/// The signed-out rows of a `_acpmux/harness/health` reply, as `SignIn.signedOut`. +export function signedOutHarnesses(rows: readonly unknown[]): Record { + const out: Record = {}; + for (const row of rows) { + const health = row as { id?: unknown; auth?: unknown; login?: unknown } | null; + if (typeof health?.id !== "string" || health.auth !== "signedOut") continue; + out[health.id] = typeof health.login === "string" ? health.login : ""; + } + return out; +} diff --git a/webviews/test/signed-out-rail.test.tsx b/webviews/test/signed-out-rail.test.tsx new file mode 100644 index 000000000000..4049c5251cd4 --- /dev/null +++ b/webviews/test/signed-out-rail.test.tsx @@ -0,0 +1,268 @@ +// cx-ae9eb: the model rail listed a harness this Mac's acpmux found signed out like any other, so a +// pick started a chat that failed on its first turn ("authentication failed"). At the composer's +// boundary: the real catalog and picker, and what the pane sends. A signed-out harness's tab is +// dimmed and offers Log In (its login command) in place of its models, and no pick or search +// reaches its models. +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; + +const scope = globalThis as Record; +const keys = [ + "window", + "document", + "navigator", + "IS_REACT_ACT_ENVIRONMENT", + "HTMLElement", + "Element", + "Node", + "ShadowRoot", + "MouseEvent", + "KeyboardEvent", + "Event", + "CustomEvent", + "customElements", + "MutationObserver", + "CSSStyleSheet", + "DocumentFragment", + "SVGElement", + "HTMLTemplateElement", + "localStorage", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", +] as const; +const saved = keys.map((key) => [key, key in scope, scope[key]] as const); +let dom: JSDOM; +let root: Root | undefined; +let host: HTMLElement; + +beforeAll(() => { + dom = new JSDOM("
", { pretendToBeVisual: true, url: "https://cmux.test/" }); + Object.assign(scope, { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + IS_REACT_ACT_ENVIRONMENT: true, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + Node: dom.window.Node, + ShadowRoot: dom.window.ShadowRoot, + MouseEvent: dom.window.MouseEvent, + KeyboardEvent: dom.window.KeyboardEvent, + Event: dom.window.Event, + CustomEvent: dom.window.CustomEvent, + customElements: dom.window.customElements, + MutationObserver: dom.window.MutationObserver, + CSSStyleSheet: dom.window.CSSStyleSheet, + DocumentFragment: dom.window.DocumentFragment, + SVGElement: dom.window.SVGElement, + HTMLTemplateElement: dom.window.HTMLTemplateElement, + localStorage: dom.window.localStorage, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + }); + host = dom.window.document.getElementById("root")!; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = undefined; + host.replaceChildren(); + dom.window.localStorage.clear(); +}); + +afterAll(() => { + dom.window.close(); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +type Sent = { method: "model" | "harness" | "effort"; value: string }; + +async function mount(start: { + signIn?: import("../src/agent-session/acpmux/signIn").SignIn; + harness: string; + extra?: { id: string; name: string; family?: string; unavailable?: string; probeFailed?: boolean }[]; + /// Completed turns of the shown chat: none (the default) is a chat with no message yet. + turnCount?: number; + /// acpmux's session list (the sidebar's Chats). + sessions?: import("../src/agent-session/acpmux/model").AcpmuxSnapshot["sessions"]; +}) { + const { createRoot } = await import("react-dom/client"); + const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query"); + const { ComposerPickers } = await import("../src/agent-session/acpmux/ComposerPickers"); + const { usePickerCatalog } = await import("../src/agent-session/acpmux/modelCatalogHost"); + const { BUNDLED_MODEL_CATALOG } = await import("../src/agent-session/acpmux/modelCatalogData"); + type Snapshot = import("../src/agent-session/acpmux/model").AcpmuxSnapshot; + // acpmux's harness list: what `_acpmux/models` reports for each catalog harness. + const acpmux: Snapshot["catalog"] = BUNDLED_MODEL_CATALOG.harnesses + .map((harness) => ({ + id: harness.id, + name: harness.name, + family: harness.families[0], + models: harness.models.map((model) => ({ id: model.id, name: model.name })), + })) + .concat((start.extra ?? []).map((entry) => ({ family: entry.id, ...entry, models: [] }))) as Snapshot["catalog"]; + const sent: Sent[] = []; + let session = 0; + function Host() { + const [state, setState] = useState({ + sessionId: `s${session}`, + harness: start.harness, + model: "default", + effort: "medium", + }); + const snapshot: Snapshot = { + type: "snapshot", + protocolVersion: 1, + rows: [], + sessions: start.sessions ?? [], + connection: "test", + isWorking: false, + queue: [], + canLoadOlder: false, + catalog: acpmux, + summary: { + sessionId: state.sessionId, + harness: state.harness, + model: state.model, + turnCount: start.turnCount ?? 0, + configOptions: [ + { + id: "effort", + name: "Effort", + category: "thought_level", + currentValue: state.effort, + options: ["low", "medium", "high"].map((value) => ({ value, name: value })), + }, + ], + }, + } as Snapshot; + const picker = usePickerCatalog(snapshot.catalog, { harness: state.harness }, snapshot.sessions); + return ( + { + sent.push({ method: "model", value: model }); + setState((current) => ({ ...current, model })); + }} + onMode={() => undefined} + onEffort={(_id, value) => { + sent.push({ method: "effort", value }); + setState((current) => ({ ...current, effort: value })); + }} + onHarness={(harness, model) => { + sent.push({ method: "harness", value: model ? `${harness}/${model}` : harness }); + session += 1; + setState({ sessionId: `s${session}`, harness, model: model ?? "default", effort: "medium" }); + }} + showPlan={false} + {...(start.signIn ? { signIn: start.signIn } : {})} + /> + ); + } + const client = new QueryClient(); + root = createRoot(host); + await act(async () => { + root!.render( + + + , + ); + }); + return { sent, catalog: BUNDLED_MODEL_CATALOG }; +} + +const tabFor = (name: string) => + [...host.querySelectorAll(".acpmux-mp-harness")].find( + (button) => button.title.split(" · ")[0] === name, + ); +const modelRows = () => [ + ...host.querySelectorAll('.acpmux-mp-models [role="option"][data-key^="model:"]'), +]; + +test("a signed-out harness offers Log In, never its models", async () => { + const logins: string[] = []; + const { sent, catalog } = await mount({ + harness: "claude", + signIn: { signedOut: { codex: "codex login" }, logIn: (harness, login) => logins.push(`${harness}: ${login}`) }, + }); + const codex = catalog.harnesses.find((entry) => entry.id === "codex")!; + await act(async () => { + host.querySelector('[data-menu="Model"]')!.click(); + }); + const tab = tabFor(codex.name); + expect(tab, "the Codex rail tab").toBeDefined(); + expect(tab!.getAttribute("aria-disabled")).toBe("true"); + await act(async () => tab!.click()); + expect(modelRows().map((row) => row.textContent)).toEqual([]); + // No More models fold either: it would open models a pick refuses (seen on a DEV build). + expect(host.querySelector(".acpmux-mp-older")).toBeNull(); + const login = host.querySelector(".acpmux-mp-login"); + expect(login?.textContent).toBe("Log In"); + await act(async () => login!.click()); + expect(logins).toEqual(["codex: codex login"]); + expect(sent).toEqual([]); +}); + +test("a search never lists a signed-out harness's models", async () => { + const { sent, catalog } = await mount({ + harness: "claude", + signIn: { signedOut: { codex: "codex login" }, logIn: () => undefined }, + }); + const codexModels = new Set(catalog.harnesses.find((entry) => entry.id === "codex")!.models.map((model) => model.id)); + await act(async () => { + host.querySelector('[data-menu="Model"]')!.click(); + }); + const input = host.querySelector(".acpmux-mp input")!; + const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!; + await act(async () => { + setValue.call(input, "gpt"); + input.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + }); + const keys = modelRows().map((row) => row.dataset.key!.slice("model:".length)); + expect(keys.filter((key) => codexModels.has(key))).toEqual([]); + expect(sent).toEqual([]); +}); + +// Review of #20692: a chat already running on a harness keeps its models (a stale or wrong row +// must never lock the person out of the chat they are in), and Starred lists no model a pick +// would refuse. +test("the running chat's own harness keeps its models", async () => { + const { catalog } = await mount({ + harness: "codex", + turnCount: 1, + signIn: { signedOut: { codex: "codex login" }, logIn: () => undefined }, + }); + const codex = catalog.harnesses.find((entry) => entry.id === "codex")!; + await act(async () => { + host.querySelector('[data-menu="Model"]')!.click(); + }); + const tab = tabFor(codex.name); + expect(tab?.getAttribute("aria-disabled")).toBeNull(); + expect(modelRows().length).toBeGreaterThan(0); + expect(host.querySelector(".acpmux-mp-login")).toBeNull(); +}); + +test("Starred lists no model of a signed-out harness", async () => { + const { BUNDLED_MODEL_CATALOG } = await import("../src/agent-session/acpmux/modelCatalogData"); + const first = (id: string) => BUNDLED_MODEL_CATALOG.harnesses.find((entry) => entry.id === id)!.models[0]!.id; + const [codexModel, claudeModel] = [first("codex"), first("claude")]; + // The picker reads its stars when it mounts. + dom.window.localStorage.setItem("cmux.model-picker.favorites", JSON.stringify([codexModel, claudeModel])); + await mount({ harness: "claude", signIn: { signedOut: { codex: "codex login" }, logIn: () => undefined } }); + await act(async () => { + host.querySelector('[data-menu="Model"]')!.click(); + }); + const starred = host.querySelector('.acpmux-mp-rail-list [aria-label="Starred"]'); + expect(starred, "the Starred tab").not.toBeNull(); + await act(async () => starred!.click()); + const keys = modelRows().map((row) => row.dataset.key!.slice("model:".length)); + expect(keys).toEqual([claudeModel]); +}); From 5f306f18b7139526d8c3fb5fef1e861dff6b12e9 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Oct 2026 20:57:29 -0700 Subject: [PATCH 143/183] Chat math: KaTeX loads only with math, prices and shell $$ stay text, copy gives TeX (#20510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: chat math stays lazy, never eats prices or shell $$, copies as TeX Red: 'echo $$ and $$' typesets ' and ', and a selection over an equation has no copy handler. Co-Authored-By: Claude Opus 5.5 * feat: chat math loads KaTeX only when a reply draws math, copies as TeX - KaTeX, its stylesheet and fonts load with the first equation, not after every pane's first frame: the stylesheet (fonts inlined as woff2 data URLs) is a chunk beside KaTeX (katexStylePlugin.mjs), so the page's first \n\n\n
\n\n\n\n' } | perl -pe 's/[ \t]+$//' > "$WORK/index.html" +# KaTeX's rules and fonts load with KaTeX, only when a reply draws math (conversation/Math.tsx, +# katexStylePlugin.mjs): never in the page's first