Skip to content
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
params: ["workspaceId": key], deadline: deadline)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,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 guard let self else { return }; remoteTerminals.workspaceClosing(workspace); IncognitoChats.closing(workspace, self) }
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,13 @@ final class QuitCoordinator {
.save(services.cache.terminals.mapValues(\.session))
},
browserRecords: { await services.cache.browserTabs.flushRecords() },
windows: { await services.windows.prepareForTermination() },
windows: {
// Incognito windows' chats are purged before their spaces go (IncognitoChats), and
// the step waits for them: every quit, Keep included, waits for a step past its limit.
IncognitoChats.closingForQuit(services)
await services.windows.prepareForTermination()
await IncognitoChats.drain()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: This drain happens after incognito workspaces and their terminals have already been closed, so a terminal agent can disappear before acpmux scans the workspace and its transcript can survive. Await the purge immediately after closingForQuit and before prepareForTermination (and separately drain any close-hook purges if needed).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitCoordinator.swift, line 213:

<comment>This drain happens after incognito workspaces and their terminals have already been closed, so a terminal agent can disappear before acpmux scans the workspace and its transcript can survive. Await the purge immediately after `closingForQuit` and before `prepareForTermination` (and separately drain any close-hook purges if needed).</comment>

<file context>
@@ -206,10 +206,11 @@ final class QuitCoordinator {
+                        // the step waits for them: every quit, Keep included, waits for a step past its limit.
                         IncognitoChats.closingForQuit(services)
                         await services.windows.prepareForTermination()
+                        await IncognitoChats.drain()
                     },
                     stopSavingWindows: { services.windows.stopSavingForTermination() },
</file context>

},
stopSavingWindows: { services.windows.stopSavingForTermination() },
sidebar: { await services.sidebarSnapshots.flush() },
homeCache: { await services.home.flushCacheForQuit() },
Expand All @@ -215,6 +221,8 @@ final class QuitCoordinator {
endLocalSessions: { await services.daemon.endSessionsAndStop(choice) },
confirmFailures: { [weak self] failures in await self?.confirm(failures) ?? .quitAnyway },
endLocalAgents: { [attempts = QuitAttempts()] in
// The incognito chats' purges reach acpmux before its agents end.
await IncognitoChats.drain()
// The Chief home's host ends with the sessions (home-state-ownership.md section 3),
// and Home does not start it again.
await services.home.brain.stop()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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<Void, Never>] = [:]

/// `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:)),
// An attached daemon (`CMUX_NEXT_ACPMUX_ATTACH`) is another app's: its chats are not this app's to delete.
let acpmux = QuitAgents.environment(services), !acpmux.isAttached 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 {
// Within a Keep quit's 4 s deadline (QuitSteps).
try await acpmux.purgeWorkspace(key: key, deadline: .seconds(3))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The 3-second deadline also applies to ordinary incognito workspace closes, and a timeout only gets logged; a busy or large purge can therefore leave the incognito chats and transcripts behind. Apply the short budget only during quit, while allowing normal closes to use the longer purge deadline or retry until the purge is acknowledged.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/IncognitoChats.swift, line 28:

<comment>The 3-second deadline also applies to ordinary incognito workspace closes, and a timeout only gets logged; a busy or large purge can therefore leave the incognito chats and transcripts behind. Apply the short budget only during quit, while allowing normal closes to use the longer purge deadline or retry until the purge is acknowledged.</comment>

<file context>
@@ -17,13 +17,15 @@ enum IncognitoChats {
             do {
-                try await acpmux.purgeWorkspace(key: key)
+                // Within a Keep quit's 4 s deadline (QuitSteps).
+                try await acpmux.purgeWorkspace(key: key, deadline: .seconds(3))
             } catch {
                 Logger(subsystem: "com.cmuxterm.app.next", category: "incognito")
</file context>

} catch {
Logger(subsystem: "com.cmuxterm.app.next", category: "incognito")
.error("incognito chats purge failed: \(String(describing: error), privacy: .public)")
}
}
}

/// Quit, before the incognito spaces are deleted (deleting a space closes its workspaces without
/// `WorkspaceClose.willClose`): every incognito window's workspaces purge their chats now.
static func closingForQuit(_ services: AppServices) {
let windows = services.windows
let registry = windows.registry.value
for window in registry.windows where registry.isIncognito(window.id) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Quit only purges registered incognito windows, so a workspace still in pendingClaims can be omitted during the creation/quit race. Include pending incognito workspace claims in the quit purge and close set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/IncognitoChats.swift, line 39:

<comment>Quit only purges registered incognito windows, so a workspace still in `pendingClaims` can be omitted during the creation/quit race. Include pending incognito workspace claims in the quit purge and close set.</comment>

<file context>
@@ -0,0 +1,58 @@
+    static func closingForQuit(_ services: AppServices) {
+        let windows = services.windows
+        let registry = windows.registry.value
+        for window in registry.windows where registry.isIncognito(window.id) {
+            for id in window.workspaceIDs {
+                guard let (workspace, _) = services.machines.workspace(id: id) else { continue }
</file context>

for id in window.workspaceIDs {
guard let (workspace, _) = services.machines.workspace(id: id) else { continue }
closing(workspace, services)
}
}
}

/// 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? {
guard let key, !key.isEmpty, incognito(id) else { return nil }
return key
}

/// 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 }
}
}
Original file line number Diff line number Diff line change
@@ -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<String> = ["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)
}
}
1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/docs/acpmux-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"_acpmux/btw": {"params": {"sessionId": "string (the chat)", "question": "string (non-empty after trim)", "model": "string? (default: config.json btw.model.<profile or family>, 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", "turnId": "string? (the exact completed turn; omitted in older logs)"}]}},
"_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[]"}},
Expand Down
6 changes: 6 additions & 0 deletions cmux-tui/crates/acpmux/spec/acpmux-protocol.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@
"params": true,
"result": true
},
{
"kind": "request",
"name": "_acpmux/workspace_purge",
"params": true,
"result": true
},
{
"kind": "request",
"name": "_acpmux/github_action",
Expand Down
1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ pub fn registry() -> Vec<MethodDecl> {
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),
Expand Down
3 changes: 3 additions & 0 deletions cmux-tui/crates/acpmux/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,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";
Expand Down
5 changes: 5 additions & 0 deletions cmux-tui/crates/acpmux/src/server/chats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
mod offers;
mod search;
mod stats;
mod workspace_purge;

use crate::no_panic::JsonExt;
use std::path::PathBuf;
Expand Down Expand Up @@ -130,6 +131,10 @@ pub(super) async fn route(
}
return hub.transfer_request(m, &params).await;
}
// An incognito workspace's chats, ended and deleted (`workspace_purge.rs`): local only.
if m == workspace_purge::METHOD {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
return workspace_purge::handle(hub, conn.origin, &params).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, &params).await;
Expand Down
Loading
Loading