Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
8da67ef
cmux NEXT Cloud: pick a chat's machine from its folder (repo, ref, si…
teamleaderleo Oct 28, 2026
9b73ba6
cmux NEXT Cloud pick: clone the upstream's branch name, no GitHub rep…
teamleaderleo Oct 28, 2026
5669354
cmux NEXT Cloud: cloud.startChat, a new chat's first send started in …
teamleaderleo Oct 28, 2026
7a596b7
cloud.startChat: the page's folder is optional; the tab's own folder …
teamleaderleo Oct 28, 2026
db58f3a
cloud.startChat: a started chat keeps the pane's hold; the folder mus…
teamleaderleo Oct 28, 2026
4f11ea7
cloud.startChat: only a chat on this Mac starts one; the App checks t…
teamleaderleo Oct 28, 2026
4c46551
Merge feat-cmux-next into carry of #26791
teamleaderleo Oct 30, 2026
acbeafe
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Oct 30, 2026
4f1f500
cloud.startChat: the machine's chat starts on the picked model; type …
teamleaderleo Oct 30, 2026
b3389d1
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Oct 31, 2026
f832258
cloud.startChat: a retry opens the same tab on the machine; the folde…
teamleaderleo Oct 31, 2026
ce57601
cloud.startChat: copy only a folder the pane may name, each send into…
teamleaderleo Oct 31, 2026
caac4e4
Merge feat-cmux-next into lane/carry-26791-crash
teamleaderleo Oct 31, 2026
eb77ba3
fix(cloud.startChat): a first send takes this person's free machine, …
teamleaderleo Oct 31, 2026
8b117be
fix(acpmux): a chat's first send that a revoked OAuth token failed go…
teamleaderleo Oct 31, 2026
13a4dfe
fix(cloud.startChat): copy only a checked upload root, never copy a f…
teamleaderleo Oct 31, 2026
4337dc5
fix(cloud.startChat): no await inside the upload root check's autoclo…
teamleaderleo Oct 31, 2026
4c71cc8
fix(cloud.startChat): not behind Labs; its machine is sized for the c…
teamleaderleo Oct 31, 2026
8c9971b
fix(cloud sizing): the chat's size pick is never behind Labs
teamleaderleo Oct 31, 2026
39364d9
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Oct 31, 2026
10fb9a8
fix(cloud.startChat): a send with no folder copies only the tab's own…
teamleaderleo Oct 31, 2026
ddd40fa
fix(cloud.startChat): a scanned project is copied only once the perso…
teamleaderleo Oct 31, 2026
3b0af5d
fix(cloud.startChat): a retry whose record is gone copies into a new …
teamleaderleo Oct 31, 2026
a090a49
fix(cloud.startChat): the Cloud app server's link is refused before a…
teamleaderleo Oct 31, 2026
d76a98d
fix(cloud.startChat): a send with no folder never copies the home fol…
teamleaderleo Oct 31, 2026
2a315af
acpmux: read the first-send retry's turn fields with get/set, no inde…
teamleaderleo Oct 31, 2026
2208632
cloud.startChat: take and copy to a machine only of the account the s…
teamleaderleo Oct 31, 2026
933c7d8
cloud.startChat: create in the send's team, recheck the account befor…
teamleaderleo Oct 31, 2026
020b012
cloud.startChat: reach AcpmuxPathPolicy.canonical through public Agen…
teamleaderleo Oct 31, 2026
17731ac
cloud.startChat: count a creatorless machine as yours only by this ac…
teamleaderleo Oct 31, 2026
6621dde
cloud.startChat: walk up to the upload root in bounded steps, no whil…
teamleaderleo Oct 31, 2026
146c134
Merge branch 'feat-cmux-next' into lane/carry-26791-crash
teamleaderleo Oct 31, 2026
e773912
cloud.startChat: read .git through the checked FileManager seam
teamleaderleo Oct 31, 2026
06c9a2b
arch-ratchet: app_lines exception for cloud.startChat until its logic…
teamleaderleo Oct 31, 2026
0ad4224
Merge remote-tracking branch 'origin/lane/carry-26791-crash' into lan…
teamleaderleo Oct 31, 2026
7b6cb60
guardrails: dated reviewed exceptions for a new App file (guardrail-e…
teamleaderleo Oct 31, 2026
7149eba
guardrails: read the exceptions file only when an app-size or app-typ…
teamleaderleo Oct 31, 2026
6beb0f2
cloud.startChat: refuse a working copy whose git folders lie outside …
teamleaderleo Oct 31, 2026
c1d0cf8
cloud.startChat: a folderless send checks the tab folder's git folder…
teamleaderleo Oct 31, 2026
52d4e01
cloud.startChat: pick and pin only machines the current account's lis…
teamleaderleo Oct 31, 2026
403c5ba
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ public nonisolated struct AgentHome: Sendable, Equatable {
return canonical
}

/// `path` in canonical form (``AcpmuxPathPolicy/canonical(_:)``: realpath, the filesystem's
/// spelling, NFC) when it exists, else nil, for callers outside this module. Touches the disk.
public static func canonical(_ path: String) -> String? {
AcpmuxPathPolicy.canonical(path)
}

/// Whether `path` is `/`, the home folder `home` or a folder above it (by path components,
/// after `~` and `..` are resolved as text). Such a folder is never a chat's implicit folder.
public static func isHomeOrAbove(_ path: String, home: String? = NSHomeDirectory()) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public nonisolated struct AgentPageOps {
"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", "dictation.audio", "dictation.recording.upload", "dictation.recording.cancel",
"transport.open", "transport.send", "transport.close", "transport.gesture", "transport.gesture.release",
"chat.missing", "uiPointer.report", "acpmux.restart", "acpmux.restartStuck", "sandbox.run", "sandbox.state",
"chat.missing", "uiPointer.report", "acpmux.restart", "acpmux.restartStuck", "sandbox.run", "sandbox.state", "cloud.startChat",
// The composer streams every upload (cloudUploads.ts); without these the page fell back to one frame, which refuses a file over 24 MiB (#28647).
"chat.upload.begin", "chat.upload.chunk", "chat.upload.finish", "chat.upload.cancel",
].map { ($0, $0) })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import CmuxNextCompat
public import Foundation

/// `cloud.startChat {task, cwd?, harness?, model?, key}`: a new chat's first send with Cloud picked
/// as where it runs (the environment picker's Cloud row, Labs). The App picks or makes a Cloud
/// machine sized for the chat, copies the chat's folder there, starts the chat there with this
/// prompt and shows it, with no machine, size or environment prompt (``AgentPaneModel/onCloudStartChat``).
public nonisolated struct AgentPaneCloudStart: Sendable, Equatable {
/// The person's first prompt (never logged).
public var task: String
/// The chat's folder on this Mac, absolute; nil: the tab's own folder.
public var cwd: String?
public var harness: String?
public var model: String?
/// The page's send: a second call with the same key while one runs is the same send.
public var key: String
/// The folder the copy uploads (the Git working copy that holds ``cwd``, else ``cwd``), set by
/// the pane once it checked that folder against its roots too (``AgentPaneHandOff``); nil when
/// the page named no folder. The App copies only this folder, never one it finds anew.
public var uploadRoot: String?

public init(task: String, cwd: String?, harness: String? = nil, model: String? = nil, key: String) {
(self.task, self.cwd, self.harness, self.model, self.key) = (task, cwd, harness, model, key)
}

/// The page's params, checked; nil for anything else.
public init?(params: [String: Any]?) {
let name = { (key: String) -> String?? in
guard let value = params?[key] else { return .some(nil) }
guard let text = value as? String, !text.isEmpty, text.count <= 128 else { return nil }
return .some(text)
}
guard let task = params?["task"] as? String, !task.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
task.count <= AgentPaneRequest.maximumSandboxTask,
let key = params?["key"] as? String, UUID(uuidString: key) != nil,
let harness = name("harness"), let model = name("model") else { return nil }
let cwd = params?["cwd"]
if let cwd, !((cwd as? String).map { $0.hasPrefix("/") && !$0.contains("\0") && $0.count <= 4096 } ?? false) { return nil }
self.init(task: task, cwd: cwd as? String, harness: harness, model: model, key: key)
}

/// The machine's chat tab's creation key: a retry of the same send (its answer was lost after
/// the machine's daemon made the tab) gets that tab back, never a second chat with the prompt again.
public var tabKey: String { "cloud-start-chat-" + key.lowercased() }

/// The folder under the machine's work root this send's copy goes to: its own, so a second
/// Cloud chat from the same folder never writes over the first one's changes on a reused
/// machine (Move to Cloud's sync overwrites every file the machine changed), and a retry of
/// the same send lands in the same copy.
public var remotePlace: String { "chats/" + key.lowercased() }

/// The idempotency key of the Cloud machine a send creates: a retry of the same send whose
/// create reached the server but lost its answer gets that machine back, never a second VM.
public var createKey: String { "cloud-start-chat-" + key.lowercased() }

/// The folder `cmux link sync push` copies for a chat in `folder`: the nearest folder at or
/// above it holding `.git` (a Git working copy's top), else `folder` itself (as
/// `CloudMoveFolder` picks it). Reads the disk.
public nonisolated static func uploadRoot(of folder: String) -> String {
// The folder and each one above it, up to `/`: a bounded walk, one step per path component.
let components = (folder as NSString).pathComponents
for count in stride(from: components.count, through: 1, by: -1) {
let current = NSString.path(withComponents: Array(components.prefix(count)))
if FileManager.checked.fileExists(atPath: (current as NSString).appendingPathComponent(".git")) { return current }
}
return folder
}

/// The git folders `cmux link sync push` copies with the working copy at `root` (its objects and
/// refs): `root/.git` when it is a folder, else the folder a `.git` file names (`gitdir: X`, a
/// linked worktree or `--separate-git-dir`), plus the common folder that one's `commondir` names.
/// Each may lie outside `root`, so the send checks them against the pane's roots too. Empty
/// outside git. Reads the disk.
public nonisolated static func gitFolders(of root: String) -> [String] {
let dotGit = (root as NSString).appendingPathComponent(".git")
var isFolder: ObjCBool = false
guard FileManager.checked.fileExists(atPath: dotGit, isDirectory: &isFolder) else { return [] }
func resolved(_ path: String, against base: String) -> String {
let path = path.trimmingCharacters(in: .whitespacesAndNewlines)
return ((path.hasPrefix("/") ? path : (base as NSString).appendingPathComponent(path)) as NSString).standardizingPath
}
let own: String
if isFolder.boolValue {
own = dotGit
} else {
guard let text = try? String(blockingContentsOf: URL(fileURLWithPath: dotGit), encoding: .utf8),
let line = text.split(whereSeparator: \.isNewline).first, line.hasPrefix("gitdir:") else { return [dotGit] }
own = resolved(String(line.dropFirst("gitdir:".count)), against: root)
}
var folders = [own]
let commonFile = URL(fileURLWithPath: (own as NSString).appendingPathComponent("commondir"))
if let common = try? String(blockingContentsOf: commonFile, encoding: .utf8), !common.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
folders.append(resolved(common, against: own))
}
return folders
}

/// Whether every one of `root`'s ``gitFolders(of:)`` is `root` or below it (by canonical path
/// components): a folder copied with no pane check behind it (a tab's own folder) never uploads
/// a repository a `.git` file or `commondir` names elsewhere. Reads the disk.
public nonisolated static func gitFoldersAreInside(_ root: String) -> Bool {
let base = ((AcpmuxPathPolicy.canonical(root) ?? root) as NSString).pathComponents
return gitFolders(of: root).allSatisfy { folder in
let parts = ((AcpmuxPathPolicy.canonical(folder) ?? (folder as NSString).standardizingPath) as NSString).pathComponents
return parts.count >= base.count && Array(parts.prefix(base.count)) == base
}
}

/// The seed of the chat on the Cloud machine, in `cwd` (the folder's copy there): the person's
/// prompt, sent once with the Send's gesture, on the agent and model the page picked.
public func seed(cwd: String) -> AgentPaneSeed {
AgentPaneSeed(cwd: cwd, prompt: task, promptBy: .person, harness: harness, model: model)
}
}

/// A new chat's first send that runs somewhere other than this Mac's chat: a Sandboxed send
/// (`sandbox.run`) or a Cloud chat (`cloud.startChat`). Both are the Send's own gesture, only for a
/// chat that has not started or opened here, one at a time, and while one runs no chat starts or
/// opens here. The send line drains first, so a start or open the page handed over before is counted.
@MainActor
struct AgentPaneHandOff {
let model: AgentPaneModel

func sandbox(task: String, agent: String?, key: String) async -> [String: Any] {
guard let run = model.onSandboxRun else { return AgentPaneModel.unsupported("sandbox.run") }
return await held("sandbox.run") { replacements in
await model.sandboxRuns.run(task: task, key: key) {
defer { replacements.endSandbox() }
return await run(task, agent, key)
}
}
}

func cloud(_ start: AgentPaneCloudStart) async -> [String: Any] {
// The folder is a path on this Mac: a chat on another machine never names one to copy.
guard let run = model.onCloudStartChat, !model.chatIsElsewhere else { return AgentPaneModel.unsupported("cloud.startChat") }
// The page names the folder, and its whole tree is copied off this Mac: it must be one of
// the pane's own folders (AcpmuxPathPolicy's roots, by canonical path and by components),
// never home or above it, as for a chat that starts here. Nil: the App uses the tab's folder.
var start = start
start.uploadRoot = nil
if let cwd = start.cwd {
guard let folder = await Self.folderInScope(cwd, transport: model.transport) else {
return AgentPaneModel.transportFailure(.pathOutsideRoots)
}
// What is copied is the Git working copy that holds the folder: it must be in scope too,
// so a grant of /repo/project never uploads /repo, its siblings and its history.
let root = await Task.detached(priority: .userInitiated) { AgentPaneCloudStart.uploadRoot(of: folder) }.value
if root != folder {
let checked = await Self.folderInScope(root, transport: model.transport)
guard checked == root else { return AgentPaneModel.transportFailure(.pathOutsideRoots) }
}
// Its git folders are copied too (history, refs): a `.git` file or `commondir` that names a
// repository outside the pane's roots never uploads it.
let gitFolders = await Task.detached(priority: .userInitiated) { AgentPaneCloudStart.gitFolders(of: root) }.value
for gitFolder in gitFolders {
let checked = await Self.folderInScope(gitFolder, transport: model.transport)
guard checked != nil else { return AgentPaneModel.transportFailure(.pathOutsideRoots) }
}
start.cwd = folder
start.uploadRoot = root
}
return await held("cloud.startChat") { [start] replacements in
let reply = await run(start)
// A chat that started on the machine keeps the hold: this pane never starts one too,
// also when the App could not close it.
if reply["ok"] as? Bool != true { replacements.endSandbox() }
return reply
}
}

/// `cwd` canonical when it is a folder under one of the pane's roots and not the home folder
/// or above it; nil otherwise. The roots are the pane's own (never home or above it) and the
/// folders the person added or picked, as given. Never the New Tab page's scan and open folders
/// (`gestureRoots`) by the Send's gesture alone: a folder there becomes a root only once the
/// person picks it, and this send copies the folder off this Mac.
static func folderInScope(_ cwd: String, transport: AgentPaneTransport) async -> String? {
let roots = transport.roots(), granted = transport.addedRoots
let home = transport.homeFolder
return await Task.detached(priority: .userInitiated) { () -> String? in
guard let folder = AcpmuxPathPolicy.canonical(cwd), AcpmuxPathPolicy.isDirectory(folder) else { return nil }
let canonicalHome = home.flatMap(AcpmuxPathPolicy.canonical)
guard !AgentHome.isHomeOrAbove(folder, home: canonicalHome) else { return nil }
let own = roots.compactMap(AcpmuxPathPolicy.canonical).filter { !AgentHome.isHomeOrAbove($0, home: canonicalHome) }
let allowed = own + granted.compactMap(AcpmuxPathPolicy.canonical).filter { $0 != "/" }
return allowed.contains { AcpmuxPathPolicy.contains(root: $0, path: folder) } ? folder : nil
}.value
}

/// The shared guards, then `body` with the replacements held (it ends the hold).
private func held(_ method: String, _ body: @MainActor (AcpmuxPaneReplacements) async -> [String: Any]) async -> [String: Any] {
guard model.sessionId == nil else { return AgentPaneModel.unsupported(method) }
let notNow = AgentPaneReply.failure(code: "unavailable_now", message: "unavailable_now")
let transport = model.transport
guard await transport.lineDrained(within: .seconds(5)) else { return notNow }
if let refusal = model.sandboxRuns.refusal { return AgentPaneReply.failure(code: refusal, message: refusal) }
let replacements = transport.replacements
guard replacements.beginSandbox() else { return notNow }
guard transport.gestures.consume() else {
replacements.endSandbox()
return AgentPaneModel.transportFailure(.gestureRequired)
}
return await body(replacements)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ public final class AgentPaneModel {
/// Cloud VM behind Labs and answers the page's reply (the receipt, or a typed refusal). Nil: no
/// sandboxes in this app.
@ObservationIgnored public var onSandboxRun: (@MainActor (_ task: String, _ agent: String?, _ key: String) async -> [String: Any])?
/// A new chat's first send with Cloud picked (``AgentPaneCloudStart``); nil: no Cloud chats here.
@ObservationIgnored public var onCloudStartChat: (@MainActor (AgentPaneCloudStart) async -> [String: Any])?
/// This pane's Sandboxed send, kept across a page reload (``AgentPaneSandboxRuns``).
@ObservationIgnored public let sandboxRuns = AgentPaneSandboxRuns()
/// Agent session avatars (the app's ``ChiefAvatarUploads``, keyed per session): the avatar as a
Expand Down Expand Up @@ -435,25 +437,9 @@ public final class AgentPaneModel {
case .watchChat(let key): if let onWatchChat { onWatchChat(key); return AgentPaneReply.success() } else { return Self.unsupported("chats.watch") }
case .chatsPage(let query): return await respondToChatsPage(query)
case .environment(let request): return await request.reply(store: startFolders.environments, newTabPage: newTab != nil && sessionId == nil)
case .sandboxRun(let task, let agent, let key):
// A new chat only, and it spends Cloud time: the Send's own gesture, as a git write.
// Not once a chat starts or opens here (it would replace the run's result), and one at a time;
// while it runs no chat starts or opens here. The send line drains first, so a start or
// open the page handed over before this is counted.
guard sessionId == nil, let onSandboxRun else { return Self.unsupported("sandbox.run") }
let notNow = AgentPaneReply.failure(code: "unavailable_now", message: "unavailable_now")
guard await transport.lineDrained(within: .seconds(5)) else { return notNow }
if let refusal = sandboxRuns.refusal { return AgentPaneReply.failure(code: refusal, message: refusal) }
let replacements = transport.replacements
guard replacements.beginSandbox() else { return notNow }
guard transport.gestures.consume() else {
replacements.endSandbox()
return Self.transportFailure(.gestureRequired)
}
return await sandboxRuns.run(task: task, key: key) {
defer { replacements.endSandbox() }
return await onSandboxRun(task, agent, key)
}
// A new chat's send elsewhere spends Cloud time: the Send's own gesture (``AgentPaneHandOff``).
case .sandboxRun(let task, let agent, let key): return await AgentPaneHandOff(model: self).sandbox(task: task, agent: agent, key: key)
case .cloudStartChat(let start): return await AgentPaneHandOff(model: self).cloud(start)
case .sandboxState(let wait):
return await sandboxRuns.state(wait: wait)
case .cloudMachine(let request): return await request.reply(cloudMachines, gestures: transport.gestures)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ extension AgentPaneRequest {
}
case "project.browse": self = .browseProject
case "sandbox.state": self = .sandboxState(wait: params?["wait"] as? Bool == true)
case "cloud.startChat": self = AgentPaneCloudStart(params: params).map(Self.cloudStartChat) ?? .unsupported(method)
case "sandbox.run":
// The task is the person's prompt (never logged); the server checks the agent by name.
let agent = params?["agent"] as? String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl
case .runAction: "runAction"
case .browseProject: "browseProject"
case .sandboxRun: "sandboxRun"
case .cloudStartChat: "cloudStartChat"
case .sandboxState: "sandboxState"
case .chooseFolder: "chooseFolder"
case .useFolder: "useFolder"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
/// throwaway Cloud VM, answered with its receipt (``AgentPaneModel/onSandboxRun``). `key` names
/// the page's send, so its retry after an unclear failure finds the same Run.
case sandboxRun(task: String, agent: String?, key: String)
/// `cloud.startChat {task, cwd, harness?, model?, key}`: a new chat's first send in Cloud (``AgentPaneCloudStart``).
case cloudStartChat(AgentPaneCloudStart)
/// `sandbox.state {wait?}`: this pane's Sandboxed send, for a page that reloaded while it ran
/// (``AgentPaneSandboxRuns/state(wait:)``).
case sandboxState(wait: Bool)
Expand Down
Loading
Loading