diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileAgentLaunchOptionsResponse.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileAgentLaunchOptionsResponse.swift new file mode 100644 index 000000000000..df5607e21441 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileAgentLaunchOptionsResponse.swift @@ -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) + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 1f5528d12a44..49da59a0b6cd 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -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, diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileAgentLaunchOptionsResponseTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileAgentLaunchOptionsResponseTests.swift new file mode 100644 index 000000000000..93c329f32d84 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileAgentLaunchOptionsResponseTests.swift @@ -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) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift index 1ee3e1be116a..e5f01f6c7e51 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift @@ -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), ] } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift index 78fbda115f32..a40de08aa922 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift @@ -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 } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentLaunch.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentLaunch.swift new file mode 100644 index 000000000000..3fe88aa02df7 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentLaunch.swift @@ -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 { + 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(()) + } + 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)) + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift index a4a91b57ae48..11102c983af2 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift @@ -403,7 +403,7 @@ extension MobileShellComposite { return workspaceMutationTarget(for: anchorWorkspaceID) } - private func workspaceMutationFailure( + func workspaceMutationFailure( _ error: any Error, hostDisplayName: String? ) -> MobileWorkspaceMutationFailure { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentLaunchComposeBar.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentLaunchComposeBar.swift new file mode 100644 index 000000000000..bc860450fb76 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentLaunchComposeBar.swift @@ -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") + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentLaunchComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentLaunchComposerView.swift new file mode 100644 index 000000000000..c44e1c551ae0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentLaunchComposerView.swift @@ -0,0 +1,330 @@ +import CmuxMobileShell +import CmuxMobileSupport +import SwiftUI +#if os(iOS) +import UIKit +#endif + +/// Full-screen prompt composer: describe a task, pick where it runs and which +/// agent runs it, then launch. On success the shell has already selected the +/// new workspace, so dismissing this cover reveals the agent's live terminal. +struct AgentLaunchComposerView: View { + /// Owned by the presenting list so a swiped-away composer keeps its draft. + @Binding var draft: String + let fetchOptions: () async -> MobileAgentLaunchOptions? + let launch: ( + _ prompt: String, + _ agentID: String?, + _ directoryPath: String? + ) async -> Result + + @Environment(\.dismiss) private var dismiss + @FocusState private var isPromptFocused: Bool + @State private var options: MobileAgentLaunchOptions? + @State private var selectedAgentID: String? + @State private var selectedDirectoryPath: String? + @State private var isLaunching = false + @State private var launchFailure: MobileWorkspaceMutationFailure? + + /// Offered while `mobile.agent.launch_options` is still in flight (or the + /// Mac never answers); the Mac re-validates the agent at launch. + private static let fallbackAgents = [ + MobileAgentLaunchOptions.Agent(id: "claude", name: "Claude Code", installed: true), + MobileAgentLaunchOptions.Agent(id: "codex", name: "Codex", installed: true), + ] + + var body: some View { + NavigationStack { + promptEditor + .safeAreaInset(edge: .bottom) { accessoryBar } + .navigationTitle(L10n.string("mobile.agentLaunch.title", defaultValue: "New Agent Task")) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel")) { + dismiss() + } + .accessibilityIdentifier("MobileAgentLaunchCancel") + } + } + } + .task { + if options == nil { + options = await fetchOptions() + } + } + .accessibilityIdentifier("MobileAgentLaunchComposer") + } + + // MARK: - Prompt editor + + private var promptEditor: some View { + ZStack(alignment: .topLeading) { + TextEditor(text: $draft) + .focused($isPromptFocused) + .font(.body) + .scrollContentBackground(.hidden) + .padding(.horizontal, 12) + .padding(.top, 8) + .accessibilityIdentifier("MobileAgentLaunchPrompt") + if draft.isEmpty { + Text( + L10n.string( + "mobile.agentLaunch.prompt.placeholder", + defaultValue: "Describe a task for the agent…" + ) + ) + .font(.body) + .foregroundStyle(.tertiary) + .padding(.horizontal, 17) + .padding(.top, 16) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .onAppear { isPromptFocused = true } + } + + // MARK: - Accessory bar + + @ViewBuilder + private var accessoryBar: some View { + #if os(iOS) + if #available(iOS 26.0, *) { + GlassEffectContainer { accessoryBarContent } + } else { + accessoryBarContent + } + #else + accessoryBarContent + #endif + } + + private var accessoryBarContent: some View { + VStack(alignment: .leading, spacing: 8) { + if let launchFailure { + Text( + String.localizedStringWithFormat( + L10n.string( + "mobile.agentLaunch.failure.message", + defaultValue: "Couldn't launch: %@." + ), + launchFailure.reasonText + ) + ) + .font(.footnote) + .foregroundStyle(.red) + .padding(.horizontal, 4) + .accessibilityIdentifier("MobileAgentLaunchError") + } + HStack(spacing: 8) { + directoryChip + agentChip + Spacer(minLength: 8) + launchButton + } + } + .padding(.horizontal, 12) + .padding(.top, 2) + .padding(.bottom, 8) + } + + private var directoryChip: some View { + Menu { + Button { + selectedDirectoryPath = nil + } label: { + if selectedDirectoryPath == nil { + Label(autoDirectoryTitle, systemImage: "checkmark") + } else { + Text(autoDirectoryTitle) + } + } + ForEach(options?.directoryPaths ?? [], id: \.self) { path in + Button { + selectedDirectoryPath = path + } label: { + if selectedDirectoryPath == path { + Label(path, systemImage: "checkmark") + } else { + Text(path) + } + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: "folder") + .font(.footnote) + Text(directoryChipTitle) + .font(.subheadline) + .lineLimit(1) + } + .foregroundStyle(.primary) + .padding(.horizontal, 14) + .frame(height: 40) + } + .mobileGlassPill() + .accessibilityLabel(L10n.string("mobile.agentLaunch.directory.a11y", defaultValue: "Working directory")) + .accessibilityIdentifier("MobileAgentLaunchDirectory") + } + + private var agentChip: some View { + Menu { + ForEach(resolvedAgents) { agent in + Button { + selectedAgentID = agent.id + } label: { + if agent.id == resolvedAgentID { + Label(agentMenuTitle(agent), systemImage: "checkmark") + } else { + Text(agentMenuTitle(agent)) + } + } + .disabled(!agent.installed) + } + } label: { + HStack(spacing: 5) { + Text(resolvedAgentName) + .font(.subheadline) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + .foregroundStyle(.primary) + .padding(.horizontal, 14) + .frame(height: 40) + } + .mobileGlassPill() + .accessibilityLabel(L10n.string("mobile.agentLaunch.agent.a11y", defaultValue: "Agent")) + .accessibilityIdentifier("MobileAgentLaunchAgent") + } + + private var launchButton: some View { + Button(action: performLaunch) { + HStack(spacing: 6) { + if isLaunching { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 14, weight: .bold)) + } + Text(L10n.string("mobile.agentLaunch.launch", defaultValue: "Launch")) + .font(.body.weight(.semibold)) + .lineLimit(1) + } + // The action label must never wrap; the chips truncate instead. + .fixedSize(horizontal: true, vertical: false) + } + .mobileGlassProminentButton() + .disabled(!canLaunch) + .accessibilityIdentifier("MobileAgentLaunchButton") + } + + // MARK: - Selection resolution + + private var resolvedAgents: [MobileAgentLaunchOptions.Agent] { + let agents = options?.agents ?? [] + return agents.isEmpty ? Self.fallbackAgents : agents + } + + private var resolvedAgentID: String? { + if let selectedAgentID, + resolvedAgents.contains(where: { $0.id == selectedAgentID && $0.installed }) { + return selectedAgentID + } + return resolvedAgents.first(where: \.installed)?.id ?? resolvedAgents.first?.id + } + + private var resolvedAgentName: String { + resolvedAgents.first(where: { $0.id == resolvedAgentID })?.name + ?? Self.fallbackAgents[0].name + } + + private func agentMenuTitle(_ agent: MobileAgentLaunchOptions.Agent) -> String { + guard !agent.installed else { return agent.name } + return String.localizedStringWithFormat( + L10n.string( + "mobile.agentLaunch.agent.notInstalledFormat", + defaultValue: "%@ (not installed)" + ), + agent.name + ) + } + + private var autoDirectoryTitle: String { + if let defaultDirectory = options?.defaultDirectory, + let basename = Self.pathBasename(defaultDirectory) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.agentLaunch.directory.autoFormat", + defaultValue: "Auto (%@)" + ), + basename + ) + } + return L10n.string("mobile.agentLaunch.directory.auto", defaultValue: "Auto") + } + + private var directoryChipTitle: String { + if let selectedDirectoryPath, + let basename = Self.pathBasename(selectedDirectoryPath) { + return basename + } + if let defaultDirectory = options?.defaultDirectory, + let basename = Self.pathBasename(defaultDirectory) { + return basename + } + return L10n.string("mobile.agentLaunch.directory.auto", defaultValue: "Auto") + } + + private static func pathBasename(_ path: String) -> String? { + // A macOS home directory reads better as "~" than as the username. + let components = path.split(separator: "/", omittingEmptySubsequences: true) + if components.count == 2, components[0] == "Users" { + return "~" + } + let basename = (path as NSString).lastPathComponent + return basename.isEmpty ? nil : basename + } + + // MARK: - Launch + + private var trimmedPrompt: String { + draft.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canLaunch: Bool { + !trimmedPrompt.isEmpty && !isLaunching + } + + private func performLaunch() { + guard canLaunch else { return } + let prompt = trimmedPrompt + let agentID = resolvedAgentID + let directoryPath = selectedDirectoryPath + #if os(iOS) + UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + #endif + isLaunching = true + launchFailure = nil + Task { @MainActor in + let result = await launch(prompt, agentID, directoryPath) + isLaunching = false + switch result { + case .success: + #if os(iOS) + UINotificationFeedbackGenerator().notificationOccurred(.success) + #endif + draft = "" + dismiss() + case let .failure(failure): + #if os(iOS) + UINotificationFeedbackGenerator().notificationOccurred(.error) + #endif + launchFailure = failure + } + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHint+Display.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHint+Display.swift index 0e9512b72605..a30dd98e421e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHint+Display.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHint+Display.swift @@ -22,6 +22,8 @@ extension MobileMacUpdateFeature { L10n.string("mobile.macUpdateHint.feature.workspaceCreateInGroup", defaultValue: "Create workspaces inside groups") case .workspaceGroupCreate: L10n.string("mobile.macUpdateHint.feature.workspaceGroupCreate", defaultValue: "Create workspace groups") + case .agentLaunch: + L10n.string("mobile.macUpdateHint.feature.agentLaunch", defaultValue: "Launch agents from a prompt") } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspaceMutationFailure+Copy.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspaceMutationFailure+Copy.swift new file mode 100644 index 000000000000..e9810f8b56a0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspaceMutationFailure+Copy.swift @@ -0,0 +1,105 @@ +import CmuxMobileShell +import CmuxMobileSupport +import Foundation + +extension MobileWorkspaceMutationFailure { + /// The localized reason clause shared by the workspace-action failure toast + /// and the agent-launch composer's inline error ("not connected to your + /// Mac", "was rejected by Aziz's Mac", …). + var reasonText: String { + switch self { + case let .notConnected(hostDisplayName): + if let hostDisplayName = Self.trimmedHostDisplayName(hostDisplayName) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.workspaceAction.failure.reason.notConnected.host", + defaultValue: "not connected to %@" + ), + hostDisplayName + ) + } + return L10n.string( + "mobile.workspaceAction.failure.reason.notConnected.generic", + defaultValue: "not connected to your Mac" + ) + case let .requestTimedOut(hostDisplayName): + if let hostDisplayName = Self.trimmedHostDisplayName(hostDisplayName) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.workspaceAction.failure.reason.timedOut.host", + defaultValue: "timed out talking to %@" + ), + hostDisplayName + ) + } + return L10n.string( + "mobile.workspaceAction.failure.reason.timedOut.generic", + defaultValue: "timed out talking to your Mac" + ) + case let .authorizationFailed(hostDisplayName): + if let hostDisplayName = Self.trimmedHostDisplayName(hostDisplayName) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.workspaceAction.failure.reason.authorization.host", + defaultValue: "was not authorized by %@" + ), + hostDisplayName + ) + } + return L10n.string( + "mobile.workspaceAction.failure.reason.authorization.generic", + defaultValue: "was not authorized by your Mac" + ) + case let .busy(hostDisplayName): + if let hostDisplayName = Self.trimmedHostDisplayName(hostDisplayName) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.workspaceAction.failure.reason.busy.host", + defaultValue: "%@ is finishing another workspace action" + ), + hostDisplayName + ) + } + return L10n.string( + "mobile.workspaceAction.failure.reason.busy.generic", + defaultValue: "another workspace action is still finishing" + ) + case let .rejected(hostDisplayName): + if let hostDisplayName = Self.trimmedHostDisplayName(hostDisplayName) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.workspaceAction.failure.reason.rejected.host", + defaultValue: "was rejected by %@" + ), + hostDisplayName + ) + } + return L10n.string( + "mobile.workspaceAction.failure.reason.rejected.generic", + defaultValue: "was rejected by your Mac" + ) + case let .unsupported(hostDisplayName): + if let hostDisplayName = Self.trimmedHostDisplayName(hostDisplayName) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.workspaceAction.failure.reason.unsupported.host", + defaultValue: "%@ doesn't support that action" + ), + hostDisplayName + ) + } + return L10n.string( + "mobile.workspaceAction.failure.reason.unsupported.generic", + defaultValue: "your Mac doesn't support that action" + ) + } + } + + private static func trimmedHostDisplayName(_ hostDisplayName: String?) -> String? { + guard let hostDisplayName = hostDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines), + !hostDisplayName.isEmpty else { + return nil + } + return hostDisplayName + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Actions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Actions.swift index b294472626bc..b5f8a65ad583 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Actions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Actions.swift @@ -12,6 +12,15 @@ extension WorkspaceListView { Label(L10n.string("mobile.workspace.new", defaultValue: "New Workspace"), systemImage: "plus") } .accessibilityIdentifier("MobileNewWorkspaceMenuItem") + if launchAgent != nil { + Button { + guard canCreateWorkspaceForMacSelection else { return } + isPresentingAgentLaunchComposer = true + } label: { + Label(L10n.string("mobile.agentLaunch.title", defaultValue: "New Agent Task"), systemImage: "sparkles") + } + .accessibilityIdentifier("MobileAgentLaunchMenuItem") + } if let createWorkspaceGroup { Button { guard canCreateWorkspaceForMacSelection else { return } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 078492d23f03..34a2d9681629 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -35,6 +35,16 @@ struct WorkspaceListView: View { let createWorkspace: () -> Void var createWorkspaceInGroup: ((MobileWorkspaceGroupPreview.ID) -> Void)? = nil var createWorkspaceGroup: (() -> Void)? = nil + /// Launch an agent workspace from a composed prompt. `nil` (old Mac, + /// unsupported) hides the compose bar and composer entry points. + var launchAgent: (( + _ prompt: String, + _ agentID: String?, + _ directoryPath: String? + ) async -> Result)? = nil + /// Fetch agent/directory options for the launch composer. Advisory; `nil` + /// results leave the composer on its defaults. + var fetchAgentLaunchOptions: (() async -> MobileAgentLaunchOptions?)? = nil var canCreateWorkspace = true /// Which Mac's workspaces the list is focused on. Owned by the shell so /// every create-workspace entrypoint shares the same selected-Mac gate. @@ -125,6 +135,11 @@ struct WorkspaceListView: View { /// The workspace whose UIKit context-menu rename action is presenting the /// list-scoped SwiftUI rename sheet. @State var workspacePendingRenameID: MobileWorkspacePreview.ID? + /// Presents the full-screen agent-launch composer. + @State var isPresentingAgentLaunchComposer = false + /// The composer draft, owned at list scope so a dismissed composer keeps + /// the typed prompt for its next presentation. + @State var agentLaunchDraft = "" @State var optimisticFlatState = MobileWorkspaceOptimisticOrderReconciler() @State var optimisticGroupedState = MobileWorkspaceOptimisticOrderReconciler() /// In-flight move RPC count plus the tail of the send chain. Moves stay @@ -234,6 +249,13 @@ struct WorkspaceListView: View { ) #if os(iOS) let baseList = workspaceTable + .safeAreaInset(edge: .bottom, spacing: 0) { + if showsAgentLaunchBar { + AgentLaunchComposeBar { + isPresentingAgentLaunchComposer = true + } + } + } #else let baseList = List { switch connectionChrome { @@ -348,6 +370,15 @@ struct WorkspaceListView: View { filter.pruneMachinesForFilterMenu(visibleMacSelection: selection) } #if os(iOS) + .fullScreenCover(isPresented: $isPresentingAgentLaunchComposer) { + if let launchAgent { + AgentLaunchComposerView( + draft: $agentLaunchDraft, + fetchOptions: fetchAgentLaunchOptions ?? { nil }, + launch: launchAgent + ) + } + } .sheet(isPresented: $showingShortcutsSettings) { TerminalShortcutsSettingsView() } @@ -524,6 +555,13 @@ struct WorkspaceListView: View { } #endif + /// The bottom compose bar shows only when the connected Mac supports agent + /// launch, creation is currently allowed, and the user is not mid-search + /// (the keyboard-adjacent inset would crowd search results). + var showsAgentLaunchBar: Bool { + launchAgent != nil && canCreateWorkspaceForMacSelection && trimmedQuery.isEmpty + } + var connectionChrome: WorkspaceListConnectionChrome { WorkspaceListConnectionChrome( hasStore: store != nil, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift index f910f6c0f74c..56ced600f83b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift @@ -90,99 +90,7 @@ extension WorkspaceShellView { } private func workspaceActionFailureReasonText(_ failure: MobileWorkspaceMutationFailure) -> String { - switch failure { - case let .notConnected(hostDisplayName): - if let hostDisplayName = trimmedWorkspaceActionHostDisplayName(hostDisplayName) { - return String.localizedStringWithFormat( - L10n.string( - "mobile.workspaceAction.failure.reason.notConnected.host", - defaultValue: "not connected to %@" - ), - hostDisplayName - ) - } - return L10n.string( - "mobile.workspaceAction.failure.reason.notConnected.generic", - defaultValue: "not connected to your Mac" - ) - case let .requestTimedOut(hostDisplayName): - if let hostDisplayName = trimmedWorkspaceActionHostDisplayName(hostDisplayName) { - return String.localizedStringWithFormat( - L10n.string( - "mobile.workspaceAction.failure.reason.timedOut.host", - defaultValue: "timed out talking to %@" - ), - hostDisplayName - ) - } - return L10n.string( - "mobile.workspaceAction.failure.reason.timedOut.generic", - defaultValue: "timed out talking to your Mac" - ) - case let .authorizationFailed(hostDisplayName): - if let hostDisplayName = trimmedWorkspaceActionHostDisplayName(hostDisplayName) { - return String.localizedStringWithFormat( - L10n.string( - "mobile.workspaceAction.failure.reason.authorization.host", - defaultValue: "was not authorized by %@" - ), - hostDisplayName - ) - } - return L10n.string( - "mobile.workspaceAction.failure.reason.authorization.generic", - defaultValue: "was not authorized by your Mac" - ) - case let .busy(hostDisplayName): - if let hostDisplayName = trimmedWorkspaceActionHostDisplayName(hostDisplayName) { - return String.localizedStringWithFormat( - L10n.string( - "mobile.workspaceAction.failure.reason.busy.host", - defaultValue: "%@ is finishing another workspace action" - ), - hostDisplayName - ) - } - return L10n.string( - "mobile.workspaceAction.failure.reason.busy.generic", - defaultValue: "another workspace action is still finishing" - ) - case let .rejected(hostDisplayName): - if let hostDisplayName = trimmedWorkspaceActionHostDisplayName(hostDisplayName) { - return String.localizedStringWithFormat( - L10n.string( - "mobile.workspaceAction.failure.reason.rejected.host", - defaultValue: "was rejected by %@" - ), - hostDisplayName - ) - } - return L10n.string( - "mobile.workspaceAction.failure.reason.rejected.generic", - defaultValue: "was rejected by your Mac" - ) - case let .unsupported(hostDisplayName): - if let hostDisplayName = trimmedWorkspaceActionHostDisplayName(hostDisplayName) { - return String.localizedStringWithFormat( - L10n.string( - "mobile.workspaceAction.failure.reason.unsupported.host", - defaultValue: "%@ doesn't support that action" - ), - hostDisplayName - ) - } - return L10n.string( - "mobile.workspaceAction.failure.reason.unsupported.generic", - defaultValue: "your Mac doesn't support that action" - ) - } + failure.reasonText } - private func trimmedWorkspaceActionHostDisplayName(_ hostDisplayName: String?) -> String? { - guard let hostDisplayName = hostDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines), - !hostDisplayName.isEmpty else { - return nil - } - return hostDisplayName - } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift index 41506bb41072..d16ad7202ad3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift @@ -148,6 +148,68 @@ extension WorkspaceShellView { return { createWorkspaceGroupIfConnected() } } + /// Launch closures for the agent composer. The compact-stack variant arms + /// the same created-workspace push intent as plain create, so a successful + /// launch navigates straight into the new workspace's terminal. + var launchAgentInCompactStackClosure: ((String, String?, String?) async -> Result)? { + guard store.supportsAgentLaunch else { return nil } + return { prompt, agentID, directoryPath in + await launchAgentWorkspace( + prompt: prompt, + agentID: agentID, + directoryPath: directoryPath, + viaCompactStack: true + ) + } + } + + var launchAgentIfConnectedClosure: ((String, String?, String?) async -> Result)? { + guard store.supportsAgentLaunch else { return nil } + return { prompt, agentID, directoryPath in + await launchAgentWorkspace( + prompt: prompt, + agentID: agentID, + directoryPath: directoryPath, + viaCompactStack: false + ) + } + } + + var fetchAgentLaunchOptionsClosure: (() async -> MobileAgentLaunchOptions?)? { + guard store.supportsAgentLaunch else { return nil } + let store = store + return { await store.fetchAgentLaunchOptions() } + } + + @MainActor + private func launchAgentWorkspace( + prompt: String, + agentID: String?, + directoryPath: String?, + viaCompactStack: Bool + ) async -> Result { + guard canCreateWorkspaceForMacSelection else { + return .failure(.busy(hostDisplayName: nil)) + } + let existingWorkspaceIDs = Set(store.workspaces.map(\.id)) + if viaCompactStack { + pendingCompactCreateNavigationWorkspaceIDs = existingWorkspaceIDs + } + let result = await store.launchAgentWorkspace( + prompt: prompt, + agentID: agentID, + workingDirectory: directoryPath + ) + if viaCompactStack { + if case .failure = result { + pendingCompactCreateNavigationWorkspaceIDs = nil + } else { + clearPendingCompactCreateNavigationIfSettled(existingWorkspaceIDs: existingWorkspaceIDs) + } + } + return result + } + func createWorkspaceInCompactStack() { createWorkspaceInCompactStack(inGroup: nil) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index ca10038dbb18..9b297c4529fc 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -125,6 +125,8 @@ struct WorkspaceShellView: View { createWorkspace: createWorkspaceInCompactStack, createWorkspaceInGroup: createWorkspaceInGroupInCompactStackClosure, createWorkspaceGroup: createWorkspaceGroupInCompactStackClosure, + launchAgent: launchAgentInCompactStackClosure, + fetchAgentLaunchOptions: fetchAgentLaunchOptionsClosure, canCreateWorkspace: canCreateWorkspaceForMacSelection, macSelection: $macSelection, switchMac: { macDeviceID in @@ -232,6 +234,8 @@ struct WorkspaceShellView: View { createWorkspace: createWorkspaceIfConnected, createWorkspaceInGroup: createWorkspaceInGroupIfConnectedClosure, createWorkspaceGroup: createWorkspaceGroupIfConnectedClosure, + launchAgent: launchAgentIfConnectedClosure, + fetchAgentLaunchOptions: fetchAgentLaunchOptionsClosure, canCreateWorkspace: canCreateWorkspaceForMacSelection, macSelection: $macSelection, switchMac: { macDeviceID in diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentPromptWorkspaceLaunch.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentPromptWorkspaceLaunch.swift new file mode 100644 index 000000000000..ce9451aee36f --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentPromptWorkspaceLaunch.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Composes the startup text for launching a coding agent with an initial +/// prompt in a fresh workspace terminal (the mobile `mobile.workspace.launch_agent` +/// flow). The command is typed at the login shell prompt (Ghostty +/// `initial_input`), so the agent inherits the user's shell environment exactly +/// as if they had typed it themselves. +public enum AgentPromptWorkspaceLaunch { + /// The startup text a fresh terminal should type at its shell prompt. + public enum StartupInput: Equatable, Sendable { + /// The composed command fits the inline budget; type it directly + /// (trailing newline included). + case inline(String) + /// The command is too large or multiline to type inline; the caller + /// writes `body` to a script file and types `/bin/zsh ''`. + case script(body: String) + } + + /// Same inline budget as the agent fork/resume startup-input path: typed + /// lines beyond this go through a launcher script instead of the pty. + public static let maxInlineBytes = 900 + + /// ` ''` with both sides single-quoted for POSIX shells. + public static func shellCommand(executablePath: String, prompt: String) -> String { + "\(singleQuoted(executablePath)) \(singleQuoted(prompt))" + } + + /// Inline when the command is a single line within budget; script otherwise. + /// Multiline prompts always take the script path so the typed input never + /// depends on the shell's quote-continuation behavior. + public static func startupInput(command: String, maxInlineBytes: Int = maxInlineBytes) -> StartupInput { + let inline = command + "\n" + if !command.contains(where: \.isNewline), inline.utf8.count <= maxInlineBytes { + return .inline(inline) + } + return .script(body: "#!/bin/zsh\nexec \(command)\n") + } + + /// The typed line that runs a written launcher script. + public static func scriptInvocation(scriptPath: String) -> String { + "/bin/zsh \(singleQuoted(scriptPath))\n" + } + + /// A concise workspace title derived from the prompt: first line, collapsed + /// whitespace, cut at a word boundary. `nil` when the prompt is blank. + public static func derivedWorkspaceTitle(prompt: String, maxLength: Int = 48) -> String? { + let firstLine = prompt + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: \.isNewline) + .first + .map(String.init) ?? "" + let collapsed = firstLine + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + guard !collapsed.isEmpty else { return nil } + guard collapsed.count > maxLength else { return collapsed } + let prefix = String(collapsed.prefix(maxLength)) + let cut = prefix.lastIndex(where: { $0 == " " }).map { String(prefix[..<$0]) } ?? prefix + return cut + "…" + } + + /// POSIX single-quoting: wraps in `'…'`, escaping embedded single quotes. + public static func singleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentPromptWorkspaceLaunchTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentPromptWorkspaceLaunchTests.swift new file mode 100644 index 000000000000..8853918a5261 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentPromptWorkspaceLaunchTests.swift @@ -0,0 +1,101 @@ +import CMUXAgentLaunch +import Testing + +@Suite("AgentPromptWorkspaceLaunch") +struct AgentPromptWorkspaceLaunchTests { + @Test("Shell command quotes executable and prompt") + func shellCommandQuoting() { + #expect( + AgentPromptWorkspaceLaunch.shellCommand( + executablePath: "/Users/dev/.bun/bin/claude", + prompt: "fix the login bug" + ) == "'/Users/dev/.bun/bin/claude' 'fix the login bug'" + ) + } + + @Test("Embedded single quotes survive quoting") + func singleQuoteEscaping() { + #expect( + AgentPromptWorkspaceLaunch.singleQuoted("don't panic") == "'don'\\''t panic'" + ) + } + + @Test("Short single-line command types inline with trailing newline") + func inlineStartupInput() { + let command = "'/usr/local/bin/codex' 'add tests'" + #expect( + AgentPromptWorkspaceLaunch.startupInput(command: command) + == .inline(command + "\n") + ) + } + + @Test("Multiline prompt always routes through a launcher script") + func multilineUsesScript() { + let command = AgentPromptWorkspaceLaunch.shellCommand( + executablePath: "/usr/local/bin/claude", + prompt: "step one\nstep two" + ) + #expect( + AgentPromptWorkspaceLaunch.startupInput(command: command) + == .script(body: "#!/bin/zsh\nexec \(command)\n") + ) + } + + @Test("Oversized command routes through a launcher script") + func oversizedUsesScript() { + let command = AgentPromptWorkspaceLaunch.shellCommand( + executablePath: "/usr/local/bin/claude", + prompt: String(repeating: "a", count: 2000) + ) + guard case .script(let body) = AgentPromptWorkspaceLaunch.startupInput(command: command) else { + Issue.record("expected script fallback") + return + } + #expect(body.hasPrefix("#!/bin/zsh\nexec ")) + #expect(body.hasSuffix("\n")) + } + + @Test("Inline budget boundary is byte-exact") + func inlineBudgetBoundary() { + let boundaryCommand = String(repeating: "x", count: 899) + #expect( + AgentPromptWorkspaceLaunch.startupInput(command: boundaryCommand) + == .inline(boundaryCommand + "\n") + ) + let overCommand = String(repeating: "x", count: 900) + #expect( + AgentPromptWorkspaceLaunch.startupInput(command: overCommand) + == .script(body: "#!/bin/zsh\nexec \(overCommand)\n") + ) + } + + @Test("Script invocation quotes the path") + func scriptInvocation() { + #expect( + AgentPromptWorkspaceLaunch.scriptInvocation(scriptPath: "/tmp/agent launch.zsh") + == "/bin/zsh '/tmp/agent launch.zsh'\n" + ) + } + + @Test("Title uses the first line with collapsed whitespace") + func titleDerivation() { + #expect( + AgentPromptWorkspaceLaunch.derivedWorkspaceTitle( + prompt: " Fix the flaky\ttest \nand more detail below" + ) == "Fix the flaky test" + ) + } + + @Test("Long titles cut at a word boundary with ellipsis") + func titleWordBoundaryCut() { + let title = AgentPromptWorkspaceLaunch.derivedWorkspaceTitle( + prompt: "Refactor the workspace sidebar rendering pipeline to avoid livelocks" + ) + #expect(title == "Refactor the workspace sidebar rendering…") + } + + @Test("Blank prompt has no title") + func blankTitle() { + #expect(AgentPromptWorkspaceLaunch.derivedWorkspaceTitle(prompt: " \n ") == nil) + } +} diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index 6303d0d6a008..e9be2c4831f1 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -40,6 +40,10 @@ extension MobileHostService { // workspace.group.collapse/expand from mobile. iOS feature-detects // this to render collapsible groups only against a Mac that emits them. "workspace.groups.v1", + // mobile.agent.launch_options + mobile.workspace.launch_agent: the + // iOS composer that creates a workspace running a coding agent on a + // composed prompt. + "workspace.launch_agent.v1", ] #if DEBUG // Lets a dev Mac impersonate an older host while dogfooding the iOS update hint. diff --git a/Sources/Mobile/MobileHostService+TicketAuthorization.swift b/Sources/Mobile/MobileHostService+TicketAuthorization.swift index 3057f37b1b68..e4e6024db3b6 100644 --- a/Sources/Mobile/MobileHostService+TicketAuthorization.swift +++ b/Sources/Mobile/MobileHostService+TicketAuthorization.swift @@ -40,11 +40,15 @@ extension MobileHostService { switch request.method { case "mobile.workspace.list", "workspace.list": return nil - case "workspace.create": + case "workspace.create", "mobile.workspace.launch_agent": guard request.params["group_id"] == nil || request.params["group_id"] is NSNull else { return ticketMacScopedWorkspaceMutationAuthorizationError(authorization: authorization) } return nil + case "mobile.agent.launch_options": + // Read-only launch metadata (installed agents + suggested + // directories), Mac-scoped like the workspace list. + return nil case "workspace.move": return ticketMacScopedWorkspaceMutationAuthorizationError( authorization: authorization, diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 86846c64a7f8..f61bf6d831fd 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1347,7 +1347,7 @@ final class MobileHostService { let object = payload as? [String: Any] else { return } switch request.method { - case "workspace.create": + case "workspace.create", "mobile.workspace.launch_agent": ticketStore.recordCreatedResources( authToken: attachToken, workspaceID: object["created_workspace_id"] as? String, diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift index 659d7dc50925..f0474e49fea9 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift @@ -96,7 +96,7 @@ final class SidebarRowPullRequestIconView: NSView { ) // Tint inside the image first: .sourceAtop against the view's // transparent backing draws nothing (no destination pixels). - let tinted = NSImage(size: image.size, flipped: false) { drawRect in + let tinted = NSImage(size: image.size, flipped: false) { [color = self.color] drawRect in image.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1) color.set() drawRect.fill(using: .sourceAtop) diff --git a/Sources/TerminalController+MobileAgentLaunch.swift b/Sources/TerminalController+MobileAgentLaunch.swift new file mode 100644 index 000000000000..a23008cf64b3 --- /dev/null +++ b/Sources/TerminalController+MobileAgentLaunch.swift @@ -0,0 +1,167 @@ +import CMUXAgentLaunch +import Foundation + +/// Mobile "compose a prompt → launch an agent workspace" RPCs +/// (capability `workspace.launch_agent.v1`): +/// +/// - `mobile.agent.launch_options` reports which coding agents are installed on +/// this Mac and which working directories make sense for a new agent +/// workspace, so the iOS composer only offers launches that will succeed. +/// - `mobile.workspace.launch_agent` creates a workspace, types the agent +/// command with the composed prompt at its fresh shell (Ghostty +/// `initial_input`, so the agent inherits the user's login-shell +/// environment), records the prompt as the workspace's submitted message, +/// and returns the same workspace-list payload as `workspace.create` so the +/// phone lands directly in the new workspace. +extension TerminalController { + /// Providers the composer can launch with a positional prompt argument. + /// `opencode`'s interactive TUI has no equivalent prompt argv, so it stays + /// out until it does. + static let mobilePromptLaunchableProviders: [AgentSessionProviderID] = [.claude, .codex] + + func v2MobileAgentLaunchOptions(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "Workspace context is unavailable", data: nil) + } + let resolver = AgentExecutableResolver( + configuredExecutablePaths: AgentExecutableResolver.cmuxConfiguredExecutablePaths() + ) + let agents: [[String: Any]] = Self.mobilePromptLaunchableProviders.map { provider in + [ + "id": provider.rawValue, + "name": provider.displayName, + "installed": (try? resolver.resolve(provider)) != nil, + ] + } + let (defaultDirectory, workspaceDirectories) = v2MainSync { + ( + tabManager.implicitWorkingDirectoryForNewWorkspace(from: tabManager.selectedWorkspace), + tabManager.tabs.map(\.currentDirectory) + ) + } + var seen = Set() + var directories: [[String: Any]] = [] + for path in [defaultDirectory].compactMap({ $0 }) + workspaceDirectories { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { continue } + directories.append(["path": trimmed]) + } + return .ok([ + "agents": agents, + "directories": directories, + "default_directory": v2OrNull(defaultDirectory), + ]) + } + + func v2MobileWorkspaceLaunchAgent(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "Workspace context is unavailable", data: nil) + } + guard let prompt = v2RawString(params, "prompt")?.trimmingCharacters(in: .whitespacesAndNewlines), + !prompt.isEmpty else { + return .err(code: "invalid_params", message: "prompt is required", data: nil) + } + let agentRaw = v2RawString(params, "agent")?.trimmingCharacters(in: .whitespacesAndNewlines) + let provider: AgentSessionProviderID + if let agentRaw, !agentRaw.isEmpty { + guard let parsed = AgentSessionProviderID(rawValue: agentRaw), + Self.mobilePromptLaunchableProviders.contains(parsed) else { + return .err(code: "invalid_params", message: "Unknown agent", data: ["agent": agentRaw]) + } + provider = parsed + } else { + provider = .claude + } + let resolver = AgentExecutableResolver( + configuredExecutablePaths: AgentExecutableResolver.cmuxConfiguredExecutablePaths() + ) + guard let plan = try? resolver.resolve(provider) else { + return .err( + code: "not_found", + message: "\(provider.executableName) is not installed on this Mac", + data: ["agent": provider.rawValue] + ) + } + + let command = AgentPromptWorkspaceLaunch.shellCommand( + executablePath: plan.executableURL.path, + prompt: prompt + ) + let startupInput: String + switch AgentPromptWorkspaceLaunch.startupInput(command: command) { + case let .inline(line): + startupInput = line + case let .script(body): + guard let scriptURL = Self.writeMobileAgentLaunchScript(body: body) else { + return .err(code: "internal_error", message: "Could not stage the launch script", data: nil) + } + startupInput = AgentPromptWorkspaceLaunch.scriptInvocation(scriptPath: scriptURL.path) + } + + var createParams = params + // Never allow raw argv through this method; the composed shell line is + // the only launch vehicle. + createParams.removeValue(forKey: "initial_command") + createParams["initial_input"] = startupInput + if v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { + createParams["title"] = AgentPromptWorkspaceLaunch.derivedWorkspaceTitle(prompt: prompt) + } + createParams["focus"] = false + // The whole point of the launch is that the agent starts working now, + // before (and regardless of whether) the phone attaches to the terminal. + createParams["eager_load_terminal"] = true + createParams["auto_refresh_metadata"] = false + let createResult = v2WorkspaceCreate(params: createParams, tabManager: tabManager) + guard case let .ok(payload) = createResult else { + return createResult + } + let createdWorkspaceID = (payload as? [String: Any])?["workspace_id"] as? String + if let createdWorkspaceID, let workspaceUUID = UUID(uuidString: createdWorkspaceID) { + v2MainSync { + _ = tabManager.handlePromptSubmit(workspaceId: workspaceUUID, message: prompt) + } + createParams["workspace_id"] = createdWorkspaceID + } + return v2MobileWorkspaceList( + params: createParams, + tabManager: tabManager, + createdWorkspaceID: createdWorkspaceID + ) + } + + /// Stages an oversized/multiline launch command as a private launcher + /// script (same discipline as the agent fork/resume path). Scripts older + /// than a day are pruned opportunistically. + private static func writeMobileAgentLaunchScript( + body: String, + fileManager: FileManager = .default, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> URL? { + let directoryURL = temporaryDirectory.appendingPathComponent("cmux-agent-launch", isDirectory: true) + do { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) + pruneMobileAgentLaunchScripts(in: directoryURL, fileManager: fileManager) + let scriptURL = directoryURL.appendingPathComponent("launch-\(UUID().uuidString).zsh") + try body.write(to: scriptURL, atomically: true, encoding: .utf8) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) + return scriptURL + } catch { + return nil + } + } + + private static func pruneMobileAgentLaunchScripts(in directoryURL: URL, fileManager: FileManager) { + let cutoff = Date().addingTimeInterval(-24 * 60 * 60) + let contents = (try? fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.contentModificationDateKey] + )) ?? [] + for url in contents { + let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + if let modified, modified < cutoff { + try? fileManager.removeItem(at: url) + } + } + } +} diff --git a/Sources/TerminalController+WorkspaceCreate.swift b/Sources/TerminalController+WorkspaceCreate.swift index 657e96581100..2bb872c10e10 100644 --- a/Sources/TerminalController+WorkspaceCreate.swift +++ b/Sources/TerminalController+WorkspaceCreate.swift @@ -19,6 +19,12 @@ extension TerminalController { let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil + // Typed at the fresh shell prompt (Ghostty initial_input), unlike + // initial_command which replaces the shell as the pty argv. Not + // trimmed: the trailing newline is what submits the typed line. + let requestedInitialInput = v2RawString(params, "initial_input") + let initialInput = (requestedInitialInput?.isEmpty == false) ? requestedInitialInput : nil + let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) @@ -133,6 +139,7 @@ extension TerminalController { title: title, workingDirectory: cwd, initialTerminalCommand: layoutNode == nil ? initialCommand : nil, + initialTerminalInput: layoutNode == nil ? initialInput : nil, initialTerminalEnvironment: layoutNode == nil ? initialEnv : [:], workspaceEnvironment: workspaceEnv, select: shouldFocus, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4bb240b4e293..46a6bf47eb3e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13805,6 +13805,10 @@ class TerminalController { result = v2MobileWorkspaceList(params: request.params) case "workspace.create": result = v2MobileWorkspaceCreate(params: request.params) + case "mobile.agent.launch_options": + result = v2MobileAgentLaunchOptions(params: request.params) + case "mobile.workspace.launch_agent": + result = v2MobileWorkspaceLaunchAgent(params: request.params) case "mobile.terminal.create", "terminal.create": result = v2MobileTerminalCreate(params: request.params) case "mobile.terminal.input", "terminal.input": diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8292d27ed2ce..fa93b1baaabf 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1728,6 +1728,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C57B00040000000000000001 /* TerminalController+CustomSidebarCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57B00040000000000000002 /* TerminalController+CustomSidebarCommands.swift */; }; C0DEBEEF0000000000000002 /* TerminalController+DebugMethodNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000001 /* TerminalController+DebugMethodNames.swift */; }; A28B087F000000000000000B /* TerminalController+DiffViewerTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B087F000000000000000C /* TerminalController+DiffViewerTrust.swift */; }; + C0DE00000000000000000E02 /* TerminalController+MobileAgentLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000E01 /* TerminalController+MobileAgentLaunch.swift */; }; A7C0F0020000000000000002 /* TerminalController+MobileAttachTicket.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C0F0020000000000000001 /* TerminalController+MobileAttachTicket.swift */; }; ACA7C4A7000000000000000D /* TerminalController+MobileChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */; }; C7AF10000000000000000003 /* TerminalController+MobileChatArtifacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */; }; @@ -3741,6 +3742,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C57B00040000000000000002 /* TerminalController+CustomSidebarCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+CustomSidebarCommands.swift"; sourceTree = ""; }; C0DEBEEF0000000000000001 /* TerminalController+DebugMethodNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+DebugMethodNames.swift"; sourceTree = ""; }; A28B087F000000000000000C /* TerminalController+DiffViewerTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+DiffViewerTrust.swift"; sourceTree = ""; }; + C0DE00000000000000000E01 /* TerminalController+MobileAgentLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileAgentLaunch.swift"; sourceTree = ""; }; A7C0F0020000000000000001 /* TerminalController+MobileAttachTicket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileAttachTicket.swift"; sourceTree = ""; }; ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileChat.swift"; sourceTree = ""; }; C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileChatArtifacts.swift"; sourceTree = ""; }; @@ -4915,6 +4917,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 466DEB47603469A59697D418 /* TerminalController+ControlWorkspaceTodoContext.swift */, C0DE00000000000000000D83 /* TerminalController+ControlPaneDock.swift */, C0DE00000000000000000C83 /* TerminalController+WorkspaceCreate.swift */, + C0DE00000000000000000E01 /* TerminalController+MobileAgentLaunch.swift */, C0DE00000000000000000C8A /* TerminalController+WorkspaceGroupAction.swift */, C0DE00000000000000000C88 /* TerminalController+WorkspaceMove.swift */, C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */, @@ -7711,6 +7714,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C57B00040000000000000001 /* TerminalController+CustomSidebarCommands.swift in Sources */, C0DEBEEF0000000000000002 /* TerminalController+DebugMethodNames.swift in Sources */, A28B087F000000000000000B /* TerminalController+DiffViewerTrust.swift in Sources */, + C0DE00000000000000000E02 /* TerminalController+MobileAgentLaunch.swift in Sources */, A7C0F0020000000000000002 /* TerminalController+MobileAttachTicket.swift in Sources */, ACA7C4A7000000000000000D /* TerminalController+MobileChat.swift in Sources */, C7AF10000000000000000003 /* TerminalController+MobileChatArtifacts.swift in Sources */, diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index 4a4b46f875e2..e9a19d8cd067 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -443,6 +443,44 @@ struct MobileHostAuthorizationTests { let error = MobileHostService.ticketAuthorizationError(ticket: ticket, request: request) #expect(error == nil) } + @Test func testAttachTicketAcceptsAgentLaunchForPairedDevice() throws { + let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") + for (method, params) in [ + ("mobile.workspace.launch_agent", ["prompt": "fix the login bug"]), + ("mobile.agent.launch_options", [:]), + ] { + let request = MobileHostRPCRequest( + id: method, + method: method, + params: params, + auth: MobileHostRPCAuth( + attachToken: ticket.authToken, + stackAccessToken: nil + ) + ) + let error = MobileHostService.ticketAuthorizationError(ticket: ticket, request: request) + #expect(error == nil, "\(method) should be allowed like workspace.create") + } + } + + @Test func testAgentLaunchIntoGroupRejectsWorkspaceScopedTicket() throws { + let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) + let request = MobileHostRPCRequest( + id: "agent-launch-group", + method: "mobile.workspace.launch_agent", + params: [ + "prompt": "fix the login bug", + "group_id": "group-main", + ], + auth: MobileHostRPCAuth( + attachToken: ticket.authToken, + stackAccessToken: nil + ) + ) + let error = MobileHostService.ticketAuthorizationError(ticket: ticket, request: request) + #expect(error?.code == "forbidden") + } + @Test func testWorkspaceMoveRejectsForeignWorkspaceForWorkspaceScopedTicket() throws { let error = try workspaceMoveAuthorizationError( ticketWorkspaceID: "workspace", diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 608b1b11f50d..e14cd34d79d9 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -681,6 +681,176 @@ } } }, + "mobile.agentLaunch.agent.a11y": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント" + } + } + } + }, + "mobile.agentLaunch.agent.notInstalledFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ (not installed)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@(未インストール)" + } + } + } + }, + "mobile.agentLaunch.bar.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask an agent…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントに依頼…" + } + } + } + }, + "mobile.agentLaunch.directory.a11y": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Working directory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作業ディレクトリ" + } + } + } + }, + "mobile.agentLaunch.directory.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "mobile.agentLaunch.directory.autoFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動 (%@)" + } + } + } + }, + "mobile.agentLaunch.failure.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't launch: %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動できませんでした: %@。" + } + } + } + }, + "mobile.agentLaunch.launch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Launch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始" + } + } + } + }, + "mobile.agentLaunch.prompt.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Describe a task for the agent…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントへのタスクを入力…" + } + } + } + }, + "mobile.agentLaunch.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Agent Task" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいエージェントタスク" + } + } + } + }, "mobile.common.ok": { "extractionState": "manual", "localizations": { @@ -2772,6 +2942,23 @@ } } }, + "mobile.macUpdateHint.feature.agentLaunch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Launch agents from a prompt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロンプトからエージェントを起動" + } + } + } + }, "mobile.notifications.disable": { "extractionState": "manual", "localizations": { @@ -10286,68 +10473,1060 @@ } } }, - "mobile.common.actions": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Actions"}},"ja":{"stringUnit":{"state":"translated","value":"操作"}}}}, - "mobile.common.remove": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Remove"}},"ja":{"stringUnit":{"state":"translated","value":"削除"}}}}, - "mobile.settings.iroh": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Iroh and Relays"}},"ja":{"stringUnit":{"state":"translated","value":"Iroh とリレー"}}}}, - "mobile.settings.networking": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Networking"}},"ja":{"stringUnit":{"state":"translated","value":"ネットワーク"}}}}, - "mobile.iroh.attention": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Your relay preference needs attention. cmux is keeping an unselected provider disabled."}},"ja":{"stringUnit":{"state":"translated","value":"リレー設定を確認してください。cmux は未選択のプロバイダーを無効のまま維持しています。"}}}}, - "mobile.iroh.custom": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Custom Relays"}},"ja":{"stringUnit":{"state":"translated","value":"カスタムリレー"}}}}, - "mobile.iroh.custom.add": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Add Custom Relay"}},"ja":{"stringUnit":{"state":"translated","value":"カスタムリレーを追加"}}}}, - "mobile.iroh.custom.authentication": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Authentication"}},"ja":{"stringUnit":{"state":"translated","value":"認証"}}}}, - "mobile.iroh.custom.authentication.none": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"None"}},"ja":{"stringUnit":{"state":"translated","value":"なし"}}}}, - "mobile.iroh.custom.authentication.secret": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Device Secret"}},"ja":{"stringUnit":{"state":"translated","value":"デバイスシークレット"}}}}, - "mobile.iroh.custom.edit": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Edit Custom Relay"}},"ja":{"stringUnit":{"state":"translated","value":"カスタムリレーを編集"}}}}, - "mobile.iroh.custom.footer": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Addresses sync with your account. Provider secrets stay in this device's Keychain. A missing secret never enables another relay provider."}},"ja":{"stringUnit":{"state":"translated","value":"アドレスはアカウントと同期されます。プロバイダーのシークレットはこのデバイスのキーチェーンに保存されます。シークレットがない場合に別のリレープロバイダーが有効になることはありません。"}}}}, - "mobile.iroh.custom.name": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Name"}},"ja":{"stringUnit":{"state":"translated","value":"名前"}}}}, - "mobile.iroh.custom.provider": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Provider"}},"ja":{"stringUnit":{"state":"translated","value":"プロバイダー"}}}}, - "mobile.iroh.custom.region": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Region"}},"ja":{"stringUnit":{"state":"translated","value":"リージョン"}}}}, - "mobile.iroh.custom.remove.confirm": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Remove this custom relay?"}},"ja":{"stringUnit":{"state":"translated","value":"このカスタムリレーを削除しますか?"}}}}, - "mobile.iroh.custom.secret": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Relay Secret"}},"ja":{"stringUnit":{"state":"translated","value":"リレーシークレット"}}}}, - "mobile.iroh.custom.secret.keep": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"New Secret (optional)"}},"ja":{"stringUnit":{"state":"translated","value":"新しいシークレット(任意)"}}}}, - "mobile.iroh.custom.secret.note": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Relay secrets stay in this device's Keychain and do not sync with your account."}},"ja":{"stringUnit":{"state":"translated","value":"リレーシークレットはこのデバイスのキーチェーンに保存され、アカウントとは同期されません。"}}}}, - "mobile.iroh.custom.summary": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"%1$@ · %2$@"}},"ja":{"stringUnit":{"state":"translated","value":"%1$@ · %2$@"}}}}, - "mobile.iroh.custom.url": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Relay URL"}},"ja":{"stringUnit":{"state":"translated","value":"リレー URL"}}}}, - "mobile.iroh.diagnostics": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Diagnostics"}},"ja":{"stringUnit":{"state":"translated","value":"診断"}}}}, - "mobile.iroh.policy": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Relay Policy"}},"ja":{"stringUnit":{"state":"translated","value":"リレーポリシー"}}}}, - "mobile.iroh.policy.cached": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Last Verified Catalog"}},"ja":{"stringUnit":{"state":"translated","value":"最後に検証済みのカタログ"}}}}, - "mobile.iroh.policy.server": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Verified from cmux"}},"ja":{"stringUnit":{"state":"translated","value":"cmux から検証済み"}}}}, - "mobile.iroh.policy.unavailable": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Unavailable"}},"ja":{"stringUnit":{"state":"translated","value":"利用不可"}}}}, - "mobile.iroh.preference": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Relay Preference"}},"ja":{"stringUnit":{"state":"translated","value":"リレー設定"}}}}, - "mobile.iroh.preference.automatic": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Automatic"}},"ja":{"stringUnit":{"state":"translated","value":"自動"}}}}, - "mobile.iroh.preference.custom": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Custom Relays"}},"ja":{"stringUnit":{"state":"translated","value":"カスタムリレー"}}}}, - "mobile.iroh.preference.managed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Selected cmux Relays"}},"ja":{"stringUnit":{"state":"translated","value":"選択した cmux リレー"}}}}, - "mobile.iroh.private": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Private Networks"}},"ja":{"stringUnit":{"state":"translated","value":"プライベートネットワーク"}}}}, - "mobile.iroh.private.add": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Add Private Network"}},"ja":{"stringUnit":{"state":"translated","value":"プライベートネットワークを追加"}}}}, - "mobile.iroh.private.automatic": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Automatic"}},"ja":{"stringUnit":{"state":"translated","value":"自動"}}}}, - "mobile.iroh.private.edit": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Edit Private Network"}},"ja":{"stringUnit":{"state":"translated","value":"プライベートネットワークを編集"}}}}, - "mobile.iroh.private.enabled": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Enabled"}},"ja":{"stringUnit":{"state":"translated","value":"有効"}}}}, - "mobile.iroh.private.footer": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Iroh discovers LAN and VPN paths after authenticating the Mac. Custom raw TCP routes are not accepted because they cannot prove the remote Mac."}},"ja":{"stringUnit":{"state":"translated","value":"Iroh は Mac を認証した後、LAN および VPN 経路を検出します。カスタムの生 TCP ルートはリモート Mac を証明できないため受け付けません。"}}}}, - "mobile.iroh.private.iroh": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Iroh Private Paths"}},"ja":{"stringUnit":{"state":"translated","value":"Iroh プライベート経路"}}}}, - "mobile.iroh.private.host": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Host or IP Address"}},"ja":{"stringUnit":{"state":"translated","value":"ホストまたは IP アドレス"}}}}, - "mobile.iroh.private.name": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Name"}},"ja":{"stringUnit":{"state":"translated","value":"名前"}}}}, - "mobile.iroh.private.note": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Use a private route for Tailscale alternatives, a company VPN, or a network you operate. cmux still authenticates the Mac before use."}},"ja":{"stringUnit":{"state":"translated","value":"Tailscale の代替、会社の VPN、または自分で運用するネットワークにプライベートルートを使用します。使用前に cmux が Mac を認証します。"}}}}, - "mobile.iroh.private.port": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Port"}},"ja":{"stringUnit":{"state":"translated","value":"ポート"}}}}, - "mobile.iroh.private.provider": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Provider"}},"ja":{"stringUnit":{"state":"translated","value":"プロバイダー"}}}}, - "mobile.iroh.private.remove.confirm": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Remove this private network?"}},"ja":{"stringUnit":{"state":"translated","value":"このプライベートネットワークを削除しますか?"}}}}, - "mobile.iroh.private.tailscale": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Tailscale Compatibility"}},"ja":{"stringUnit":{"state":"translated","value":"Tailscale 互換"}}}}, - "mobile.iroh.refresh": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Refresh Relay Policy"}},"ja":{"stringUnit":{"state":"translated","value":"リレーポリシーを更新"}}}}, - "mobile.iroh.relays": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Iroh Relays"}},"ja":{"stringUnit":{"state":"translated","value":"Iroh リレー"}}}}, - "mobile.iroh.relays.footer": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Direct peer-to-peer stays enabled. cmux verifies a signed relay catalog, so fleet changes do not require an app update."}},"ja":{"stringUnit":{"state":"translated","value":"ピアツーピアの直接接続は有効なままです。cmux は署名済みリレーカタログを検証するため、フリート変更にアプリ更新は不要です。"}}}}, - "mobile.iroh.saveFailed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Could Not Save Networking Settings"}},"ja":{"stringUnit":{"state":"translated","value":"ネットワーク設定を保存できませんでした"}}}}, - "mobile.iroh.saveFailed.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Your previous networking configuration is still active. Check your account connection and values, then try again."}},"ja":{"stringUnit":{"state":"translated","value":"以前のネットワーク設定が引き続き有効です。アカウント接続と値を確認して、もう一度お試しください。"}}}}, - "mobile.iroh.status": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Connection"}},"ja":{"stringUnit":{"state":"translated","value":"接続"}}}}, - "mobile.iroh.status.active": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Iroh Active"}},"ja":{"stringUnit":{"state":"translated","value":"Iroh 稼働中"}}}}, - "mobile.iroh.status.degraded": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Direct-Only"}},"ja":{"stringUnit":{"state":"translated","value":"直接接続のみ"}}}}, - "mobile.iroh.status.direct": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Direct Peer-to-Peer"}},"ja":{"stringUnit":{"state":"translated","value":"ピアツーピア直接接続"}}}}, - "mobile.iroh.status.inactive": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Inactive"}},"ja":{"stringUnit":{"state":"translated","value":"停止中"}}}}, - "mobile.iroh.status.private": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Private Network"}},"ja":{"stringUnit":{"state":"translated","value":"プライベートネットワーク"}}}}, - "mobile.iroh.status.relayed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Relayed"}},"ja":{"stringUnit":{"state":"translated","value":"リレー経由"}}}}, - "mobile.iroh.status.starting": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Starting"}},"ja":{"stringUnit":{"state":"translated","value":"起動中"}}}}, - "mobile.iroh.test": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Test Connection"}},"ja":{"stringUnit":{"state":"translated","value":"接続をテスト"}}}}, - "mobile.iroh.test.failed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Unreachable"}},"ja":{"stringUnit":{"state":"translated","value":"到達不可"}}}}, - "mobile.iroh.test.incomplete": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Test Unavailable"}},"ja":{"stringUnit":{"state":"translated","value":"テスト利用不可"}}}}, - "mobile.iroh.test.reachable": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Reachable"}},"ja":{"stringUnit":{"state":"translated","value":"到達可能"}}}}, - "mobile.iroh.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Iroh and Relays"}},"ja":{"stringUnit":{"state":"translated","value":"Iroh とリレー"}}}} + "mobile.common.actions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Actions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作" + } + } + } + }, + "mobile.common.remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + } + } + }, + "mobile.settings.iroh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh and Relays" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh とリレー" + } + } + } + }, + "mobile.settings.networking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Networking" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + } + } + }, + "mobile.iroh.attention": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your relay preference needs attention. cmux is keeping an unselected provider disabled." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレー設定を確認してください。cmux は未選択のプロバイダーを無効のまま維持しています。" + } + } + } + }, + "mobile.iroh.custom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Relays" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムリレー" + } + } + } + }, + "mobile.iroh.custom.add": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Custom Relay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムリレーを追加" + } + } + } + }, + "mobile.iroh.custom.authentication": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証" + } + } + } + }, + "mobile.iroh.custom.authentication.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + } + } + }, + "mobile.iroh.custom.authentication.secret": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device Secret" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスシークレット" + } + } + } + }, + "mobile.iroh.custom.edit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Custom Relay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムリレーを編集" + } + } + } + }, + "mobile.iroh.custom.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Addresses sync with your account. Provider secrets stay in this device's Keychain. A missing secret never enables another relay provider." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アドレスはアカウントと同期されます。プロバイダーのシークレットはこのデバイスのキーチェーンに保存されます。シークレットがない場合に別のリレープロバイダーが有効になることはありません。" + } + } + } + }, + "mobile.iroh.custom.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前" + } + } + } + }, + "mobile.iroh.custom.provider": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provider" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロバイダー" + } + } + } + }, + "mobile.iroh.custom.region": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Region" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リージョン" + } + } + } + }, + "mobile.iroh.custom.remove.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this custom relay?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このカスタムリレーを削除しますか?" + } + } + } + }, + "mobile.iroh.custom.secret": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay Secret" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレーシークレット" + } + } + } + }, + "mobile.iroh.custom.secret.keep": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Secret (optional)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいシークレット(任意)" + } + } + } + }, + "mobile.iroh.custom.secret.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay secrets stay in this device's Keychain and do not sync with your account." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレーシークレットはこのデバイスのキーチェーンに保存され、アカウントとは同期されません。" + } + } + } + }, + "mobile.iroh.custom.summary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@" + } + } + } + }, + "mobile.iroh.custom.url": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレー URL" + } + } + } + }, + "mobile.iroh.diagnostics": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Diagnostics" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "診断" + } + } + } + }, + "mobile.iroh.policy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay Policy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレーポリシー" + } + } + } + }, + "mobile.iroh.policy.cached": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last Verified Catalog" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最後に検証済みのカタログ" + } + } + } + }, + "mobile.iroh.policy.server": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verified from cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux から検証済み" + } + } + } + }, + "mobile.iroh.policy.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用不可" + } + } + } + }, + "mobile.iroh.preference": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay Preference" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレー設定" + } + } + } + }, + "mobile.iroh.preference.automatic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "mobile.iroh.preference.custom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Relays" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムリレー" + } + } + } + }, + "mobile.iroh.preference.managed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected cmux Relays" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した cmux リレー" + } + } + } + }, + "mobile.iroh.private": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Private Networks" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライベートネットワーク" + } + } + } + }, + "mobile.iroh.private.add": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Private Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライベートネットワークを追加" + } + } + } + }, + "mobile.iroh.private.automatic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "mobile.iroh.private.edit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Private Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライベートネットワークを編集" + } + } + } + }, + "mobile.iroh.private.enabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enabled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効" + } + } + } + }, + "mobile.iroh.private.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh discovers LAN and VPN paths after authenticating the Mac. Custom raw TCP routes are not accepted because they cannot prove the remote Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh は Mac を認証した後、LAN および VPN 経路を検出します。カスタムの生 TCP ルートはリモート Mac を証明できないため受け付けません。" + } + } + } + }, + "mobile.iroh.private.iroh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh Private Paths" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh プライベート経路" + } + } + } + }, + "mobile.iroh.private.host": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Host or IP Address" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホストまたは IP アドレス" + } + } + } + }, + "mobile.iroh.private.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前" + } + } + } + }, + "mobile.iroh.private.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use a private route for Tailscale alternatives, a company VPN, or a network you operate. cmux still authenticates the Mac before use." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale の代替、会社の VPN、または自分で運用するネットワークにプライベートルートを使用します。使用前に cmux が Mac を認証します。" + } + } + } + }, + "mobile.iroh.private.port": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Port" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポート" + } + } + } + }, + "mobile.iroh.private.provider": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provider" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロバイダー" + } + } + } + }, + "mobile.iroh.private.remove.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this private network?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このプライベートネットワークを削除しますか?" + } + } + } + }, + "mobile.iroh.private.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale Compatibility" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale 互換" + } + } + } + }, + "mobile.iroh.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh Relay Policy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレーポリシーを更新" + } + } + } + }, + "mobile.iroh.relays": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh Relays" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh リレー" + } + } + } + }, + "mobile.iroh.relays.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Direct peer-to-peer stays enabled. cmux verifies a signed relay catalog, so fleet changes do not require an app update." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピアツーピアの直接接続は有効なままです。cmux は署名済みリレーカタログを検証するため、フリート変更にアプリ更新は不要です。" + } + } + } + }, + "mobile.iroh.saveFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could Not Save Networking Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク設定を保存できませんでした" + } + } + } + }, + "mobile.iroh.saveFailed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your previous networking configuration is still active. Check your account connection and values, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "以前のネットワーク設定が引き続き有効です。アカウント接続と値を確認して、もう一度お試しください。" + } + } + } + }, + "mobile.iroh.status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続" + } + } + } + }, + "mobile.iroh.status.active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh Active" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh 稼働中" + } + } + } + }, + "mobile.iroh.status.degraded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Direct-Only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "直接接続のみ" + } + } + } + }, + "mobile.iroh.status.direct": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Direct Peer-to-Peer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピアツーピア直接接続" + } + } + } + }, + "mobile.iroh.status.inactive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inactive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止中" + } + } + } + }, + "mobile.iroh.status.private": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Private Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライベートネットワーク" + } + } + } + }, + "mobile.iroh.status.relayed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relayed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレー経由" + } + } + } + }, + "mobile.iroh.status.starting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動中" + } + } + } + }, + "mobile.iroh.test": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test Connection" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続をテスト" + } + } + } + }, + "mobile.iroh.test.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unreachable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "到達不可" + } + } + } + }, + "mobile.iroh.test.incomplete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test Unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テスト利用不可" + } + } + } + }, + "mobile.iroh.test.reachable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reachable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "到達可能" + } + } + } + }, + "mobile.iroh.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh and Relays" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh とリレー" + } + } + } + } }, "version": "1.0" }