Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -0,0 +1,60 @@
public import Foundation

/// Typed decoder for the `mobile.agent.launch_options` RPC result: what the
/// launch composer needs before offering a launch — the coding agents this Mac
/// can run and the working directories that make sense for a new agent
/// workspace.
public struct MobileAgentLaunchOptionsResponse: Decodable, Sendable {
/// A coding agent the Mac knows how to launch with a prompt.
public struct Agent: Decodable, Sendable {
/// Stable agent identifier (`claude`, `codex`).
public let id: String
/// User-facing agent name reported by the Mac.
public let name: String
/// Whether the agent's executable resolves on the Mac right now.
public let installed: Bool

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
name = try container.decodeIfPresent(String.self, forKey: .name) ?? id
installed = try container.decodeIfPresent(Bool.self, forKey: .installed) ?? false
}

private enum CodingKeys: String, CodingKey {
case id
case name
case installed
}
}

/// A suggested working directory for the new workspace.
public struct Directory: Decodable, Sendable {
/// Absolute path on the Mac.
public let path: String
}

/// Launchable agents in the Mac's preferred order.
public let agents: [Agent]
/// Suggested working directories, most relevant first.
public let directories: [Directory]
/// The directory a plain workspace create would inherit, if any.
public let defaultDirectory: String?

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
agents = try container.decodeIfPresent([Agent].self, forKey: .agents) ?? []
directories = try container.decodeIfPresent([Directory].self, forKey: .directories) ?? []
defaultDirectory = try container.decodeIfPresent(String.self, forKey: .defaultDirectory)
}

private enum CodingKeys: String, CodingKey {
case agents
case directories
case defaultDirectory = "default_directory"
}

public static func decode(_ data: Data) throws -> MobileAgentLaunchOptionsResponse {
try JSONDecoder().decode(Self.self, from: data)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,11 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
return false
case "workspace.create":
return false
case "mobile.workspace.launch_agent", "mobile.agent.launch_options":
// Launch metadata is Mac-scoped like the workspace list; the launch
// itself is a Mac-scoped create. Both ride the attach ticket so the
// host can reject workspace-scoped tickets.
return false
case "workspace.action", "workspace.close":
return !ticketCoverage.ticketCoversWorkspaceRequest(
ticket: ticket,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import CmuxMobileRPC
import Foundation
import Testing

@Suite("MobileAgentLaunchOptionsResponse")
struct MobileAgentLaunchOptionsResponseTests {
@Test("Decodes the full wire shape")
func decodesFullShape() throws {
let data = Data("""
{
"agents": [
{"id": "claude", "name": "Claude Code", "installed": true},
{"id": "codex", "name": "Codex", "installed": false}
],
"directories": [
{"path": "/Users/dev/Projects/app"},
{"path": "/Users/dev"}
],
"default_directory": "/Users/dev/Projects/app"
}
""".utf8)
let response = try MobileAgentLaunchOptionsResponse.decode(data)
#expect(response.agents.map(\.id) == ["claude", "codex"])
#expect(response.agents.map(\.installed) == [true, false])
#expect(response.directories.map(\.path) == ["/Users/dev/Projects/app", "/Users/dev"])
#expect(response.defaultDirectory == "/Users/dev/Projects/app")
}

@Test("Missing fields decode to safe defaults")
func decodesSparsePayload() throws {
let response = try MobileAgentLaunchOptionsResponse.decode(Data("{}".utf8))
#expect(response.agents.isEmpty)
#expect(response.directories.isEmpty)
#expect(response.defaultDirectory == nil)
}

@Test("Agent name and installed default when omitted")
func agentDefaults() throws {
let data = Data("""
{"agents": [{"id": "claude"}]}
""".utf8)
let response = try MobileAgentLaunchOptionsResponse.decode(data)
#expect(response.agents.first?.name == "claude")
#expect(response.agents.first?.installed == false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,6 @@ public struct MobileMacUpdateCapabilityRequirement: Sendable, Equatable {
.init(capability: "workspace.group_actions.v1", feature: .workspaceGroupActions, firstReleasedMacVersion: nil),
.init(capability: "workspace.create_in_group.v1", feature: .workspaceCreateInGroup, firstReleasedMacVersion: nil),
.init(capability: "workspace.group_create.v1", feature: .workspaceGroupCreate, firstReleasedMacVersion: nil),
.init(capability: "workspace.launch_agent.v1", feature: .agentLaunch, firstReleasedMacVersion: nil),
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ public enum MobileMacUpdateFeature: String, CaseIterable, Sendable {

/// Creating workspace groups.
case workspaceGroupCreate

/// Composing a prompt and launching an agent workspace.
case agentLaunch
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
internal import CmuxMobileRPC
public import CmuxMobileShellModel
internal import Foundation

/// The data the launch composer needs before offering a launch: the coding
/// agents the connected Mac can run and the working directories that make
/// sense for a new agent workspace.
public struct MobileAgentLaunchOptions: Sendable, Equatable {
/// A coding agent the Mac knows how to launch with a prompt.
public struct Agent: Sendable, Equatable, Identifiable {
/// Stable agent identifier (`claude`, `codex`).
public let id: String
/// User-facing agent name reported by the Mac.
public let name: String
/// Whether the agent's executable resolves on the Mac right now.
public let installed: Bool

public init(id: String, name: String, installed: Bool) {
self.id = id
self.name = name
self.installed = installed
}
}

/// Launchable agents in the Mac's preferred order.
public let agents: [Agent]
/// Suggested working directories, most relevant first.
public let directoryPaths: [String]
/// The directory a plain workspace create would inherit, if any.
public let defaultDirectory: String?

public init(agents: [Agent], directoryPaths: [String], defaultDirectory: String?) {
self.agents = agents
self.directoryPaths = directoryPaths
self.defaultDirectory = defaultDirectory
}
}

extension MobileShellComposite {
static let workspaceLaunchAgentCapability = "workspace.launch_agent.v1"

/// Whether the connected Mac supports the prompt-compose agent launch
/// (`mobile.workspace.launch_agent`). Capability-gated only: like
/// `workspace.create`, a non-group launch is authorized for
/// workspace-scoped attach tickets too, so the Mac-wide-ticket policy that
/// gates group mutations must not hide this feature.
public var supportsAgentLaunch: Bool {
supportedHostCapabilities.contains(Self.workspaceLaunchAgentCapability)
}

/// Fetches launch options from the connected Mac. Options are advisory:
/// `nil` (offline, old Mac, transient failure) leaves the composer on its
/// defaults rather than blocking composition.
public func fetchAgentLaunchOptions() async -> MobileAgentLaunchOptions? {
guard let client = remoteClient else { return nil }
do {
let data = try await client.sendRequest(
MobileCoreRPCClient.requestData(method: "mobile.agent.launch_options", params: [:])
)
let response = try MobileAgentLaunchOptionsResponse.decode(data)
return MobileAgentLaunchOptions(
agents: response.agents.map {
MobileAgentLaunchOptions.Agent(id: $0.id, name: $0.name, installed: $0.installed)
},
directoryPaths: response.directories.map(\.path),
defaultDirectory: response.defaultDirectory
)
} catch {
return nil
}
}

/// Creates a workspace on the connected Mac running `agentID` on `prompt`,
/// then selects it so the shell navigates straight into the live terminal.
/// Mirrors `createRemoteWorkspace`'s apply/select path so launch and plain
/// create stay one behavior.
public func launchAgentWorkspace(
prompt: String,
agentID: String?,
workingDirectory: String?
) async -> Result<Void, MobileWorkspaceMutationFailure> {
guard let client = remoteClient else {
return .failure(.notConnected(hostDisplayName: connectedHostName))
}
guard supportsAgentLaunch else {
return .failure(.unsupported(hostDisplayName: connectedHostName))
}
let generation = connectionGeneration
do {
var params: [String: Any] = ["prompt": prompt]
if let agentID, !agentID.isEmpty {
params["agent"] = agentID
}
if let workingDirectory, !workingDirectory.isEmpty {
params["working_directory"] = workingDirectory
}
let resultData = try await client.sendRequest(
MobileCoreRPCClient.requestData(method: "mobile.workspace.launch_agent", params: params)
)
let response = try MobileSyncWorkspaceListResponse.decode(resultData)
guard isCurrentRemoteOperation(client: client, generation: generation), !Task.isCancelled else {
return .success(())
}
Comment on lines +101 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not report superseded launches as successful.

Both stale/cancelled branches return .success(()), so AgentLaunchComposerView clears the draft and dismisses even though this method skipped applying/selecting the created workspace. Add an explicit superseded/cancelled outcome, or return a failure that preserves the draft.

Also applies to: 122-122

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+AgentLaunch.swift
around lines 101 - 103, Update the stale/cancelled early returns in the agent
launch flow around isCurrentRemoteOperation and Task.isCancelled so they do not
return .success(()). Return an explicit superseded/cancelled outcome or an
appropriate failure that prevents AgentLaunchComposerView from clearing the
draft and dismissing when workspace application or selection is skipped.

Source: Coding guidelines

applyRemoteWorkspaceList(response, mergeExistingWorkspaces: true)
let createdWorkspace = response.createdWorkspaceID.map(MobileWorkspacePreview.ID.init(rawValue:))
if let createdWorkspace {
setSelectedWorkspaceID(
rowWorkspaceID(
forRemoteWorkspaceID: createdWorkspace,
macDeviceID: foregroundMacDeviceID
) ?? createdWorkspace
)
}
syncSelectedTerminalForWorkspace()
if createdWorkspace != nil {
// The user launched an agent to watch it work, not to type;
// keep the keyboard down on first attach like plain create.
suppressTerminalAutoFocusOnNextAttach(for: selectedTerminalID)
}
return .success(())
} catch {
guard generation == connectionGeneration, !Task.isCancelled else { return .success(()) }
if disconnectForAuthorizationFailureIfNeeded(error) {
return .failure(.authorizationFailed(hostDisplayName: connectedHostName))
}
markMacConnectionUnavailableIfNeeded(after: error)
return .failure(workspaceMutationFailure(error, hostDisplayName: connectedHostName))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ extension MobileShellComposite {
return workspaceMutationTarget(for: anchorWorkspaceID)
}

private func workspaceMutationFailure(
func workspaceMutationFailure(
_ error: any Error,
hostDisplayName: String?
) -> MobileWorkspaceMutationFailure {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import CmuxMobileSupport
import SwiftUI

/// The workspace list's bottom "Ask an agent…" affordance: a glass capsule
/// that opens the full-screen agent-launch composer. Reads as an input field
/// so the home screen itself invites composing a task.
struct AgentLaunchComposeBar: View {
let action: () -> Void

var body: some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: "sparkles")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color.accentColor)
Text(L10n.string("mobile.agentLaunch.bar.placeholder", defaultValue: "Ask an agent…"))
.font(.body)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 24))
.foregroundStyle(Color.accentColor)
}
.padding(.leading, 16)
.padding(.trailing, 8)
.frame(height: 48)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.mobileGlassPill()
.padding(.horizontal, 16)
.padding(.top, 4)
.padding(.bottom, 8)
.accessibilityLabel(L10n.string("mobile.agentLaunch.title", defaultValue: "New Agent Task"))
.accessibilityIdentifier("MobileAgentLaunchBar")
}
}
Loading