From 9761c3f202d8a1ddb7b620557a4d57964ba4b180 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 03:23:50 -0800 Subject: [PATCH 1/5] test(next): incognito workspace chats must be purged when it closes Adds _acpmux/workspace_purge (registered, documented, not implemented yet) and the tests that prove it: acpmux purges every chat it recorded for the closed incognito workspace and keeps every other one, only the local socket may ask, and a transcript older than its session (an adopted chat) is never deleted. The app side's IncognitoChats.key names an incognito workspace's key only. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-afa4d1219370df72d7ba --- .../AcpmuxEnvironment+WorkspacePurge.swift | 12 ++ .../Sources/CmuxNextApp/AppServices.swift | 2 +- .../CmuxNextApp/Quit/QuitCoordinator.swift | 2 + .../CmuxNextApp/Windows/IncognitoChats.swift | 44 +++++++ .../IncognitoChatsTests.swift | 24 ++++ .../crates/acpmux/docs/acpmux-schema.json | 1 + .../crates/acpmux/spec/acpmux-protocol.json | 6 + cmux-tui/crates/acpmux/src/protocol/mod.rs | 1 + cmux-tui/crates/acpmux/src/rpc.rs | 3 + cmux-tui/crates/acpmux/src/server/chats.rs | 5 + .../src/server/chats/workspace_purge.rs | 111 ++++++++++++++++++ .../src/server/chats/workspace_purge_tests.rs | 89 ++++++++++++++ .../acpmux/protocol/generated/methods.ts | 2 + 13 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment+WorkspacePurge.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/IncognitoChats.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/IncognitoChatsTests.swift create mode 100644 cmux-tui/crates/acpmux/src/server/chats/workspace_purge.rs create mode 100644 cmux-tui/crates/acpmux/src/server/chats/workspace_purge_tests.rs diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment+WorkspacePurge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment+WorkspacePurge.swift new file mode 100644 index 000000000000..69a68a266d8f --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment+WorkspacePurge.swift @@ -0,0 +1,12 @@ +public import Foundation + +extension AcpmuxEnvironment { + /// Stops and deletes every chat this daemon recorded for cmux workspace `key` (its sub-sessions, + /// forks, and agents started from its terminals included), with the transcripts those chats wrote + /// (`_acpmux/workspace_purge`, cmux-tui acpmux `server/chats/workspace_purge.rs`). acpmux decides which + /// chats those are; an incognito workspace's close sends this. It never starts a daemon. + public nonisolated func purgeWorkspace(key: String, deadline: Duration = .seconds(5)) async throws { + _ = try await AcpmuxStatusClient.call(socketPath: socketPath, method: "_acpmux/workspace_purge", + params: ["workspaceId": key], deadline: deadline) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift index 1cf3e96ce053..f2ca9db9fae1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift @@ -698,7 +698,7 @@ final class AppServices { _ = previews // built here, as before _ = remoteTerminals // built here, as before remoteTerminals.start() - WorkspaceClose.willClose = { [weak self] workspace in self?.remoteTerminals.workspaceClosing(workspace) } + WorkspaceClose.willClose = { [weak self] workspace in self?.remoteTerminals.workspaceClosing(workspace); self.map { IncognitoChats.closing(workspace, $0) } } let registry = registry daemon.workTracker = { registry.track($0) } // A pane verb on a pane still opening was not sent: the refusal path logs it and shows it (P0 rule a; every diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitCoordinator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitCoordinator.swift index 1b1062ba8063..1b12c056c993 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitCoordinator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitCoordinator.swift @@ -188,6 +188,8 @@ final class QuitCoordinator { // session ends, while the daemon still answers). await services.cache.browserTabs.flushRecords() await services.windows.prepareForTermination() + // The incognito windows' chats it closed are deleted before acpmux's agents end (IncognitoChats). + await IncognitoChats.drain() await services.sidebarSnapshots.flush() // Home's drafts, scroll places and unsent sends (its cache's last batch). await services.home.flushCacheForQuit() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/IncognitoChats.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/IncognitoChats.swift new file mode 100644 index 000000000000..f88c3a617f05 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/IncognitoChats.swift @@ -0,0 +1,44 @@ +import CmuxNextAgentPane +import CmuxNextDaemon +import Foundation +import os + +/// Agent chats of incognito workspaces. An incognito space keeps no history, is never restored and is +/// deleted when its window closes (leo-workspace design/spaces.md "Incognito"; Leo, 2026-10-31: "like +/// incognito spaces, each new window is a space"). A chat started in one is an ordinary acpmux chat, +/// so when the workspace closes (its window, Delete Space, quit) acpmux stops and deletes every chat +/// it recorded for that workspace, and the transcripts they wrote: none lists under Earlier, in the +/// palette or on New Tab after. acpmux owns which chats those are; the app only names the workspace. +@MainActor +enum IncognitoChats { + /// Purges not answered yet; the quit waits for them before acpmux's agents end. + private static var pending: [UUID: Task] = [:] + + /// `WorkspaceClose.willClose`: every close path tells it before it closes `workspace`. + static func closing(_ workspace: WorkspaceModel, _ services: AppServices) { + guard let key = key(of: workspace.id, key: workspace.key?.rawValue, incognito: services.windows.isIncognito(workspace:)), + let acpmux = QuitAgents.environment(services) else { return } + let id = UUID() + // task-owner: this enum until acpmux answers (or its deadline); the quit drains it. + pending[id] = Task { @MainActor in + defer { pending[id] = nil } + do { + try await acpmux.purgeWorkspace(key: key) + } catch { + Logger(subsystem: "com.cmuxterm.app.next", category: "incognito") + .error("incognito chats purge failed: \(String(describing: error), privacy: .public)") + } + } + } + + /// The acpmux workspace key whose chats go when workspace `id` closes: its key when it is + /// incognito, else nil (a normal workspace's chats stay in history). + static func key(of id: String, key: String?, incognito: (String) -> Bool) -> String? { + nil // not implemented yet + } + + /// Quit: the purges of the incognito workspaces just closed reach acpmux before its agents end. + static func drain() async { + for task in pending.values { await task.value } + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/IncognitoChatsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/IncognitoChatsTests.swift new file mode 100644 index 000000000000..85d4e3c4ca88 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/IncognitoChatsTests.swift @@ -0,0 +1,24 @@ +import Testing +@testable import CmuxNextApp + +/// An incognito workspace's chats are deleted when it closes; a normal workspace's stay in history +/// (leo-workspace design/spaces.md "Incognito"). +@MainActor +struct IncognitoChatsTests { + private let incognito: Set = ["incognito-workspace"] + + @Test func anIncognitoWorkspacesChatsArePurgedByItsKey() { + let key = "3f2b8c1e-5d4a-4b6f-9e7d-0a1b2c3d4e5f" + #expect(IncognitoChats.key(of: "incognito-workspace", key: key, incognito: incognito.contains) == key) + } + + @Test func aNormalWorkspaceKeepsItsChats() { + let key = "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" + #expect(IncognitoChats.key(of: "normal-workspace", key: key, incognito: incognito.contains) == nil) + } + + @Test func aWorkspaceWithNoKeyPurgesNothing() { + #expect(IncognitoChats.key(of: "incognito-workspace", key: nil, incognito: incognito.contains) == nil) + #expect(IncognitoChats.key(of: "incognito-workspace", key: "", incognito: incognito.contains) == nil) + } +} diff --git a/cmux-tui/crates/acpmux/docs/acpmux-schema.json b/cmux-tui/crates/acpmux/docs/acpmux-schema.json index d0c1b064b01e..f8c2eb40b3c9 100644 --- a/cmux-tui/crates/acpmux/docs/acpmux-schema.json +++ b/cmux-tui/crates/acpmux/docs/acpmux-schema.json @@ -56,6 +56,7 @@ "_acpmux/btw": {"params": {"sessionId": "string (the chat)", "question": "string (non-empty after trim)", "model": "string? (default: config.json btw.model., else claude haiku, codex its smallest advertised model, else the harness default)"}, "result": {"sessionId": "string (the side session)", "model": "string|null (null: the harness default)", "readOnly": "mode|policy"}, "errors": "invalid params: an empty question, a model that is not a string, an explicit model the harness refuses; data.reason btw.remote: a Web connection or a peer; data.reason btw.no_read_only_mode (\"this agent has no read-only mode\"): the codex-acp adapter, opencode, or a family with no asking mode, before anything opens; data.reason btw.mode_unset: the mode could not be set and confirmed, the side session discarded; not found: an unknown chat", "note": "The composer's /btw (hub/btw.rs): a side session in the chat's harness and folder, tagged side with the chat (the lists hide it), titled with the question (first line, 80 chars). Read-only: policy approve-reads (ask or deny-all when the chat's is that) with rules that refuse every tool kind but read, search, fetch and think; The session runs in a mode set and confirmed in its reported current mode: readOnly mode for one that reads only (Claude plan, Codex in process read-only: sandbox read-only, approvals on-request), readOnly policy for another family's first asking mode (webAskingModes), which asks before every change for the rules to refuse. A profile that runs opencode or codex-acp under family claude is refused, and while one is configured Claude's own /btw is refused too (fail closed). The confirm reads acpmux's own record after a successful session/set_mode, not a readback from the agent; a pane or the socket can still re-mode a side session (the person's choice); respawn replays the mode for ACP harnesses without failing on a refusal. Harness-side: Claude permissions.allow still applies in plan mode, and Codex MCP tools run outside its sandbox. The transcript context is marked untrusted, not instructions, and every closing tag inside it (any case, whitespace around the slash or before >) is made inert. Claude and Codex fork through the chat's latest finished turn, also while a turn runs; any other harness, or a chat with no finished turn, starts a new session whose first prompt carries the chat's newest transcript (at most 12 KiB) as hidden context (prompt_context.rs). The question is sent as its first prompt; the reply comes once the prompt is recorded, before the answer. The chat is never paused, cancelled or changed."}, "_acpmux/history": {"params": {"sessionId": "string", "limit": "number?"}, "result": {"turns": [{"seq": "number", "startedAt": "number", "endedAt": "number|null", "wallMs": "number|null", "prompt": "string", "stopReason": "string|null", "status": "completed|cancelled|failed|running", "toolCalls": "number", "permissions": "number", "tokens": "number|null"}]}}, "_acpmux/stats": {"params": {"days": "number? default 84, at most 366", "tzOffsetMinutes": "number? the caller's UTC offset (east positive), default 0"}, "result": {"since": "number", "until": "number", "tzOffsetMinutes": "number", "sessions": "number (sessions with a turn in range)", "turns": "number", "turnsCompleted": "number", "turnsCancelled": "number", "turnsFailed": "number", "prompts": "number", "steers": "number", "toolCalls": "number", "permissionRequests": "number", "permissionAuto": "number", "workingMs": "number (turn time summed over sessions)", "waitingMs": "number (time waiting on a permission answer)", "peakParallel": "number", "peakParallelAt": "number|null", "harnesses": "{harness, sessions, turns, workingMs}[] most working time first", "branches": "{cwd, branch, sessions, turns, workingMs, costUsd?, inputTokens?, outputTokens?, cacheReadTokens?, cacheWriteTokens?}[] per git branch each turn ended on (its turn_result gitBranch; a turn that recorded none, as in older logs, counts on the branch its session started on, session.json gitBranch) and the session's cwd as given (a subfolder of the same checkout is another row), most working time first, at most 50: the range's turns with the spend their turn_result records carry (never the session totals), a spend field absent when no counted turn reported it; a session whose turns ended on several branches is a session of each; turns with no branch either way are left out", "days": "{day: YYYY-MM-DD, turns, workingMs}[] oldest first, active days only", "hours": "number[24] working ms per hour of the caller's day", "streak": "{current, longest} days in a row with a finished turn, over the last 366 days whatever the range"}, "errors": "Method not found over any WebSocket (Web, LocalApp, Peer)", "note": "Local unix socket only (hq#1920): counts and times, never prompt or tool text. One offset applies to the whole range, so days before a DST change shift by an hour. A turn or wait the daemon lost when it died ends at the session's last record before the loss: at the restart's own record (turn_result detail outcome_unknown, or status idle detail restart, which a restart writes for a session it found waiting), or at the end of the log when this daemon does not hold the session running or waiting; older logs without the restart record end such a wait at the next status. A prompt ends a wait left after its turn at the prompt's time. A prompt that never got turn_started and never settled counts as a prompt, not a turn. A steer sent again as the next prompt (same promptId) counts once, as the prompt. A fork's log starts with copies of its parent's prompts, tool calls and turns before its forked record: they count only in the parent's log, in every total, day, hour, streak and peakParallel, and not at all once that log is gone. branches follows a session that switches branch: each turn counts on the branch its turn_result recorded it ended on, else on the branch the session started on."}, + "_acpmux/workspace_purge": {"params": {"workspaceId": "string (a cmux workspace id, a UUID; any case)"}, "result": {"workspaceId": "string (lowercase)", "purged": "string[] (the session ids stopped and purged)", "transcripts": "number (harness transcript files and folders deleted)"}, "errors": "Method not found over any WebSocket (Web, LocalApp, Peer); invalid params: a workspaceId that is not a workspace id", "note": "Local unix socket only. Stops and purges every session acpmux recorded for that workspace at its start (_meta.acpmux.workspaceId or CMUX_WORKSPACE_ID; sub-sessions, forks and side sessions keep their parent's): the cmux app sends it for each workspace of an incognito window when the window closes and at quit. A Claude Code or Codex session's own transcript goes too (the chat index file named for each agent session id it recorded, born after the session was created, and Claude Code's folder of that name), so the chat leaves the device chat index; an adopted or watched chat's older file is kept."}, "_acpmux/wait": {"params": {"sessions": "string[]? (empty = every running or waiting session on every host)", "until": "('ready'|'permission'|'closed'|'done'|'running')[]? default ready+permission", "all": "boolean? default false (first to resolve)", "afterSeq": "{sessionId: number}?", "timeoutMs": "number?"}, "result": {"sessions": "SessionSummary[] with resolved: boolean and matched: string[]", "resolved": "SessionSummary[]", "timedOut": "boolean"}}, "_acpmux/watch_add": {"params": {"to": "string? (recipient session; default authenticated caller)", "sessions": "string[] (local or HOST/name; non-empty)", "until": "string[]?", "match": "string?", "once": "true? (recurring watches are not supported yet)"}, "result": "Watch {id,to,sessions,until,match,once,active,offline}"}, "_acpmux/watch_ls": {"params": {}, "result": {"watches": "Watch[]", "deliveries": "undelivered wake statuses[]"}}, diff --git a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json index e18fe66851ee..ab4309ff3e27 100644 --- a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json +++ b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json @@ -383,6 +383,12 @@ "params": true, "result": true }, + { + "kind": "request", + "name": "_acpmux/workspace_purge", + "params": true, + "result": true + }, { "kind": "request", "name": "_acpmux/github_action", diff --git a/cmux-tui/crates/acpmux/src/protocol/mod.rs b/cmux-tui/crates/acpmux/src/protocol/mod.rs index 00f7540b3828..8f4f66d9acf3 100644 --- a/cmux-tui/crates/acpmux/src/protocol/mod.rs +++ b/cmux-tui/crates/acpmux/src/protocol/mod.rs @@ -209,6 +209,7 @@ pub fn registry() -> Vec { untyped(method::MUX_WORKFLOWS, Request), untyped(method::MUX_APP_REPLY, Request), untyped(method::MUX_STATS, Request), + untyped(method::MUX_WORKSPACE_PURGE, Request), untyped(method::MUX_GITHUB_ACTION, Request), untyped(method::MUX_GITHUB_APP_LEASE, Request), untyped(method::MUX_BOOKMARK_GET, Request), diff --git a/cmux-tui/crates/acpmux/src/rpc.rs b/cmux-tui/crates/acpmux/src/rpc.rs index 841ffcfaa84a..99ff156cd76c 100644 --- a/cmux-tui/crates/acpmux/src/rpc.rs +++ b/cmux-tui/crates/acpmux/src/rpc.rs @@ -401,6 +401,9 @@ pub mod method { /// Personal stats over every session log (`stats.rs`); the local unix /// socket only (the app reaches it there), never a WebSocket origin. pub const MUX_STATS: &str = "_acpmux/stats"; + /// Stop and delete every chat of one cmux workspace, an incognito one when its window + /// closes (`server/chats/workspace_purge.rs`); the local unix socket only. + pub const MUX_WORKSPACE_PURGE: &str = "_acpmux/workspace_purge"; /// Read or replace the unsent composer text for one session. pub const MUX_DRAFT_GET: &str = "_acpmux/draft_get"; pub const MUX_DRAFT_SET: &str = "_acpmux/draft_set"; diff --git a/cmux-tui/crates/acpmux/src/server/chats.rs b/cmux-tui/crates/acpmux/src/server/chats.rs index 9e3e01980951..f5624b9eaaf4 100644 --- a/cmux-tui/crates/acpmux/src/server/chats.rs +++ b/cmux-tui/crates/acpmux/src/server/chats.rs @@ -52,6 +52,7 @@ mod offers; mod stats; +mod workspace_purge; use crate::no_panic::JsonExt; use std::path::PathBuf; @@ -124,6 +125,10 @@ pub(super) async fn route( } return hub.transfer_request(m, ¶ms).await; } + // An incognito workspace's chats, ended and deleted (`workspace_purge.rs`): local only. + if m == workspace_purge::METHOD { + return workspace_purge::handle(hub, conn.origin, ¶ms).await; + } // Personal stats over the session logs (`stats.rs`): local, never a peer's. if m == crate::rpc::method::MUX_STATS { return stats::handle(hub, conn.origin, ¶ms).await; diff --git a/cmux-tui/crates/acpmux/src/server/chats/workspace_purge.rs b/cmux-tui/crates/acpmux/src/server/chats/workspace_purge.rs new file mode 100644 index 000000000000..6ec466b7b9b6 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/server/chats/workspace_purge.rs @@ -0,0 +1,111 @@ +//! `_acpmux/workspace_purge {workspaceId}`: ends and deletes every chat of one cmux workspace. +//! +//! An incognito space keeps no history, is never restored and is deleted when its window closes +//! (leo-workspace design/spaces.md "Incognito"; Leo, 2026-10-31: "like incognito spaces, each new +//! window is a space"). The app calls this for each workspace of an incognito window when the +//! window closes and at quit. A workspace's chats are the sessions acpmux recorded it for when +//! they started (`_meta.acpmux.workspaceId`, `session_env::workspace_id`): the app's own chats, +//! their sub-sessions, forks and side questions (each keeps its parent's), and an agent started +//! from the workspace's terminal (`CMUX_WORKSPACE_ID`). +//! +//! Each one is stopped and purged (`Hub::kill` with purge: its log, queue, background tasks and +//! temporary folder go). Its harness's own transcript would keep the chat in the device chat +//! index (Earlier, the palette, New Tab), so the transcript goes too, but only one this session +//! wrote itself (``own_transcripts``): a conversation the workspace only continued, an adopted +//! or watched chat that existed before, keeps its file. +//! +//! Local unix socket only, as `_acpmux/stats`: a WebSocket origin gets "Method not found". + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use cmux_chat_index::{AdapterKind, ChatKey}; +use serde_json::{Value, json}; + +use super::super::{Origin, str_param}; +use crate::hub::{Hub, Session}; +use crate::rpc::RpcError; +use crate::store::SessionMeta; + +pub(crate) const METHOD: &str = crate::rpc::method::MUX_WORKSPACE_PURGE; + +pub(super) async fn handle( + hub: &Arc, + origin: Origin, + params: &Value, +) -> Result { + if origin != Origin::Local { + return Err(RpcError::method_not_found(&format!( + "{METHOD} (served only on the local unix socket)" + ))); + } + let workspace = str_param(params, "workspaceId") + .filter(|w| crate::session_env::is_workspace_id(w)) + .ok_or_else(|| RpcError::invalid_params("workspaceId must be a workspace id (a UUID)"))? + .to_ascii_lowercase(); + // Not implemented yet: nothing is purged (the test proves the next commit's fix). + let purged: Vec = Vec::new(); + let _ = (of_workspace, own_transcripts, remove); + Ok(json!({"workspaceId": workspace, "purged": purged, "transcripts": 0})) +} + +/// This daemon's sessions recorded for `workspace` (a lowercase workspace id). +fn of_workspace(hub: &Hub, workspace: &str) -> Vec> { + hub.sessions() + .into_iter() + .filter(|s| s.meta().workspace_id.as_deref() == Some(workspace)) + .collect() +} + +/// The harness transcripts `meta`'s session wrote itself: for a Claude Code or Codex session, the +/// file the chat index holds for each agent session id it recorded, named for that id, and Claude +/// Code's folder of the same name beside it (subagents, tool results). Only one born after the +/// session was created: an older file is a conversation it continued (an adopted chat), which +/// is not this session's to delete. A watched chat writes none. Other harnesses: none yet. +fn own_transcripts(hub: &Hub, meta: &SessionMeta) -> Vec { + let (Some(index), None) = (hub.chat_index(), &meta.watching) else { return Vec::new() }; + let harness = match meta.family.as_deref() { + Some("claude") => AdapterKind::ClaudeCode, + Some("codex") => AdapterKind::Codex, + _ => return Vec::new(), + }; + let since = UNIX_EPOCH + Duration::from_millis(meta.created_at); + let mut files = Vec::new(); + for id in meta.agent_session_id.iter().chain(&meta.previous_agent_session_ids) { + let Some(chat) = index.get(&ChatKey { harness, session_id: id.clone() }) else { continue }; + let file = chat.entry.source_path; + if !(file.is_absolute() && names(&file, id) && born_since(&file, since)) { + continue; + } + let folder = file.with_file_name(id); + if harness == AdapterKind::ClaudeCode && folder.is_dir() && born_since(&folder, since) { + files.push(folder); + } + files.push(file); + } + files +} + +/// Whether `file` is a transcript (`.jsonl`) whose name holds agent session `id` +/// (`.jsonl`, Codex's `rollout-