mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
The create form only offered models for Claude and Codex, so every other agent spawned on its default model. The form now loads the catalogs web uses. Agy, cursor, dsh and pi read /machines/:id/<flavor>-models. Copilot, grok, kimi and opencode probe the same route with cwd once the directory exists; the view refetches when the typed path turns out to exist. Option rows follow the web builders: - Copilot and cursor label the default row "Auto". - Kimi and pi prefix the provider; pi values are provider/modelId. - OpenCode marks the agent's own default. - Cursor shows a flat list of ACP ids, without the variant drill-down. Grok efforts and pi thinking levels follow the selected model, and an OpenCode variant becomes the reasoning effort. Grok's auto permission mode is hidden when the probe reports it unsupported. A restored selection the catalog no longer offers falls back to Default, and Create waits while a non-default selection is still being validated. The spawn body sends model for all of these agents, and cursor always sends it, with 'auto' as the default. Grok and pi send effort; opencode sends modelReasoningEffort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
468 lines
21 KiB
Swift
468 lines
21 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
/// New-session form state + the pure mapping/validation logic around it
|
|
/// (A-M3c) — the iOS counterpart of the Android reference's
|
|
/// `NewSessionForm.kt`, which itself ports the web create form
|
|
/// (`web/src/components/NewSession/index.tsx`, `handleCreate`). Everything
|
|
/// here is UI-free so package tests can assert the exact spawn body against
|
|
/// `SpawnSessionRequestSchema`.
|
|
///
|
|
/// Unlike the Android original (open strings everywhere), enum-backed fields
|
|
/// use the `HapiProtocol` catalog types directly — a draft holding a value
|
|
/// these enums cannot represent fails to decode and degrades to no draft,
|
|
/// which is the same end state as the web's try/catch around
|
|
/// `loadNewSessionFormDraft`.
|
|
public struct NewSessionForm: Codable, Equatable, Sendable {
|
|
public var machineId: String?
|
|
public var directory: String
|
|
/// Flavor from ``AgentFlavor/creatableFlavors``.
|
|
public var agent: AgentFlavor
|
|
/// `'auto'` = no explicit model (claude presets, codex and discovered catalogs).
|
|
public var model: String
|
|
/// Claude/grok effort or pi thinking level; `'auto'` = omit.
|
|
public var effort: String
|
|
/// Codex reasoning effort or opencode variant; `'default'` = omit.
|
|
public var modelReasoningEffort: String
|
|
/// Native permission mode for grok + codex-family flavors.
|
|
public var permissionMode: PermissionMode
|
|
/// HAPI YOLO preference for the remaining flavors (claude/agy/cursor/pi).
|
|
public var yolo: Bool
|
|
public var sessionType: SpawnSessionType
|
|
public var worktreeName: String
|
|
/// Codex; only sent while the fast tier is visible.
|
|
public var serviceTier: ServiceTier
|
|
/// Codex collaboration mode.
|
|
public var collaborationMode: CodexCollaborationMode
|
|
/// Copilot; always sent for copilot.
|
|
public var copilotAgentMode: CopilotAgentMode
|
|
|
|
public init(
|
|
machineId: String? = nil,
|
|
directory: String = "",
|
|
agent: AgentFlavor = .claude,
|
|
model: String = "auto",
|
|
effort: String = "auto",
|
|
modelReasoningEffort: String = "default",
|
|
permissionMode: PermissionMode = .default,
|
|
yolo: Bool = false,
|
|
sessionType: SpawnSessionType = .simple,
|
|
worktreeName: String = "",
|
|
serviceTier: ServiceTier = .standard,
|
|
collaborationMode: CodexCollaborationMode = .default,
|
|
copilotAgentMode: CopilotAgentMode = .interactive
|
|
) {
|
|
self.machineId = machineId
|
|
self.directory = directory
|
|
self.agent = agent
|
|
self.model = model
|
|
self.effort = effort
|
|
self.modelReasoningEffort = modelReasoningEffort
|
|
self.permissionMode = permissionMode
|
|
self.yolo = yolo
|
|
self.sessionType = sessionType
|
|
self.worktreeName = worktreeName
|
|
self.serviceTier = serviceTier
|
|
self.collaborationMode = collaborationMode
|
|
self.copilotAgentMode = copilotAgentMode
|
|
}
|
|
|
|
public var trimmedDirectory: String {
|
|
directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
// Manual decode with per-field defaults so a draft written by an older
|
|
// app version (missing keys) still restores, mirroring kotlinx defaults
|
|
// on the Android side. Encoding stays synthesized.
|
|
private enum CodingKeys: String, CodingKey {
|
|
case machineId, directory, agent, model, effort, modelReasoningEffort
|
|
case permissionMode, yolo, sessionType, worktreeName, serviceTier
|
|
case collaborationMode, copilotAgentMode
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
machineId = try container.decodeIfPresent(String.self, forKey: .machineId)
|
|
directory = try container.decodeIfPresent(String.self, forKey: .directory) ?? ""
|
|
agent = try container.decodeIfPresent(AgentFlavor.self, forKey: .agent) ?? .claude
|
|
model = try container.decodeIfPresent(String.self, forKey: .model) ?? "auto"
|
|
effort = try container.decodeIfPresent(String.self, forKey: .effort) ?? "auto"
|
|
modelReasoningEffort = try container.decodeIfPresent(String.self, forKey: .modelReasoningEffort)
|
|
?? "default"
|
|
permissionMode = try container.decodeIfPresent(PermissionMode.self, forKey: .permissionMode)
|
|
?? .default
|
|
yolo = try container.decodeIfPresent(Bool.self, forKey: .yolo) ?? false
|
|
sessionType = try container.decodeIfPresent(SpawnSessionType.self, forKey: .sessionType)
|
|
?? .simple
|
|
worktreeName = try container.decodeIfPresent(String.self, forKey: .worktreeName) ?? ""
|
|
serviceTier = try container.decodeIfPresent(ServiceTier.self, forKey: .serviceTier)
|
|
?? .standard
|
|
collaborationMode = try container.decodeIfPresent(
|
|
CodexCollaborationMode.self,
|
|
forKey: .collaborationMode
|
|
) ?? .default
|
|
copilotAgentMode = try container.decodeIfPresent(CopilotAgentMode.self, forKey: .copilotAgentMode)
|
|
?? .interactive
|
|
}
|
|
}
|
|
|
|
/// Pure new-session logic (Android `NewSessionLogic` port; web reference in
|
|
/// each doc comment).
|
|
public enum NewSessionLogic {
|
|
// MARK: - Permission control routing
|
|
|
|
/// Flavors sharing the codex-style native permission select
|
|
/// (`web/src/lib/codexFamilyPermissionAgents.ts`). Gemini is listed for
|
|
/// completeness though it is not creatable.
|
|
public static let codexFamilyPermissionAgents: Set<AgentFlavor> = [
|
|
.codex, .gemini, .kimi, .copilot, .opencode,
|
|
]
|
|
|
|
public static func usesCodexFamilyPermissionModes(_ flavor: AgentFlavor) -> Bool {
|
|
codexFamilyPermissionAgents.contains(flavor)
|
|
}
|
|
|
|
/// Flavors whose permission control is the native-mode select. claude
|
|
/// and grok do not share the codex-family mode set, but they render
|
|
/// through the same native select (web `usesNativePermissionSelect`,
|
|
/// `web/src/lib/codexFamilyPermissionAgents.ts`).
|
|
public static func usesNativePermissionSelect(_ flavor: AgentFlavor) -> Bool {
|
|
flavor == .claude || flavor == .grok || usesCodexFamilyPermissionModes(flavor)
|
|
}
|
|
|
|
/// `resolveHapiYoloPermissionMode` (`shared/src/agentConfig.ts`) — the
|
|
/// native mode the HAPI YOLO toggle maps to, for the toggle's caption.
|
|
public static func hapiYoloNativeMode(for flavor: AgentFlavor) -> PermissionMode? {
|
|
switch flavor {
|
|
case .claude, .grok: return .bypassPermissions
|
|
case .agy: return .alwaysProceed
|
|
case .codex, .copilot, .cursor, .gemini, .kimi, .opencode: return .yolo
|
|
case .dsh, .pi, .other: return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Spawn body
|
|
|
|
/// Exact spawn body (`POST /api/machines/:id/spawn`), field-for-field
|
|
/// port of the web `handleCreate` mapping:
|
|
/// - `model` for every flavor with a picker (claude presets, codex and the
|
|
/// ``AgentModelCatalog`` flavors) unless `'auto'`; cursor always sends
|
|
/// it (`'auto'` included, web `cursorSpawnModelId`);
|
|
/// - `effort` for claude, grok and pi; `modelReasoningEffort` for codex
|
|
/// and opencode (variants); each omitted on its Default sentinel;
|
|
/// - `yolo` only for flavors still on the YOLO toggle (not
|
|
/// `usesNativePermissionSelect`) — **including `false`**;
|
|
/// - `permissionMode` for every `usesNativePermissionSelect` flavor —
|
|
/// including `'default'`;
|
|
/// - `sessionType` always; `worktreeName` only for worktree and non-blank;
|
|
/// - `serviceTier` only while the codex fast tier is visible (then also
|
|
/// `'standard'`); `collaborationMode` only when not `'default'`;
|
|
/// - `copilotAgentMode` always for copilot;
|
|
/// - `startingMode` omitted = the runner's `'remote'` default (v1 fixes
|
|
/// remote; pty is deferred, matching the web create form).
|
|
///
|
|
/// Unlike web, iOS has no persistent YOLO preference to migrate: the
|
|
/// toggle only lives in the in-memory form / a draft that is deleted on
|
|
/// success, so there is nothing to bridge into `permissionMode` here.
|
|
public static func buildSpawnRequest(
|
|
form: NewSessionForm,
|
|
codexFastTierVisible: Bool
|
|
) -> SpawnRequest {
|
|
let agent = form.agent
|
|
let hasModelPicker = agent == .claude || agent == .codex
|
|
|| AgentModelCatalog.scope(for: agent) != nil
|
|
let trimmedModel = form.model.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let resolvedModel: String?
|
|
if agent == .cursor {
|
|
resolvedModel = trimmedModel.isEmpty ? "auto" : trimmedModel
|
|
} else {
|
|
resolvedModel = hasModelPicker && trimmedModel != "auto" && !trimmedModel.isEmpty
|
|
? trimmedModel
|
|
: nil
|
|
}
|
|
let sendsEffort = agent == .claude || agent == .grok || agent == .pi
|
|
let sendsReasoningEffort = agent == .codex || agent == .opencode
|
|
let trimmedWorktreeName = form.worktreeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return SpawnRequest(
|
|
directory: form.trimmedDirectory,
|
|
agent: agent,
|
|
model: resolvedModel,
|
|
effort: (sendsEffort && form.effort != "auto") ? form.effort : nil,
|
|
modelReasoningEffort: (sendsReasoningEffort && form.modelReasoningEffort != "default")
|
|
? form.modelReasoningEffort
|
|
: nil,
|
|
yolo: (agent == .dsh || usesNativePermissionSelect(agent)) ? nil : form.yolo,
|
|
permissionMode: usesNativePermissionSelect(agent) ? form.permissionMode : nil,
|
|
sessionType: form.sessionType,
|
|
worktreeName: (form.sessionType == .worktree && !trimmedWorktreeName.isEmpty)
|
|
? trimmedWorktreeName
|
|
: nil,
|
|
serviceTier: (agent == .codex && codexFastTierVisible) ? form.serviceTier : nil,
|
|
collaborationMode: (agent == .codex && form.collaborationMode != .default)
|
|
? form.collaborationMode
|
|
: nil,
|
|
copilotAgentMode: agent == .copilot ? form.copilotAgentMode : nil,
|
|
startingMode: nil
|
|
)
|
|
}
|
|
|
|
/// Restored selections the live catalog no longer offers fall back to
|
|
/// their Default sentinels (web validation effects): an unlisted model,
|
|
/// a grok/pi effort or an opencode variant the selected model lacks.
|
|
public static func reconcileAgentModelSelections(
|
|
_ form: NewSessionForm,
|
|
response: AgentModelsResponse,
|
|
variants: [String: [String]]?
|
|
) -> NewSessionForm {
|
|
let agent = form.agent
|
|
guard AgentModelCatalog.scope(for: agent) != nil else { return form }
|
|
var next = form
|
|
let models = AgentModelCatalog.newSessionModelOptions(flavor: agent, response: response)
|
|
if !models.contains(where: { $0.value == next.model }) {
|
|
next.model = "auto"
|
|
}
|
|
let efforts: [NewSessionOption]?
|
|
switch agent {
|
|
case .grok: efforts = AgentModelCatalog.grokEffortOptions(response: response, model: next.model)
|
|
case .pi: efforts = AgentModelCatalog.piEffortOptions(response: response, model: next.model)
|
|
default: efforts = nil
|
|
}
|
|
if next.effort != "auto", efforts?.contains(where: { $0.value == next.effort }) != true {
|
|
next.effort = "auto"
|
|
}
|
|
if agent == .opencode, next.modelReasoningEffort != "default",
|
|
AgentModelCatalog.opencodeReasoningEffortOptions(
|
|
variants: variants, response: response, model: next.model
|
|
)?.contains(where: { $0.value == next.modelReasoningEffort }) != true {
|
|
next.modelReasoningEffort = "default"
|
|
}
|
|
return next
|
|
}
|
|
|
|
// MARK: - Directory autocomplete
|
|
|
|
/// Parent listing target for the autocomplete dropdown.
|
|
public struct ParentQuery: Equatable, Sendable {
|
|
/// Absolute directory to `POST list-directory`.
|
|
public let parent: String
|
|
/// Typed tail the entries are prefix-filtered by (case-insensitive).
|
|
public let prefix: String
|
|
/// Separator used by the typed path; suggestions preserve it.
|
|
public let separator: String
|
|
|
|
public var includeHidden: Bool { prefix.hasPrefix(".") }
|
|
|
|
public init(parent: String, prefix: String, separator: String = "/") {
|
|
self.parent = parent
|
|
self.prefix = prefix
|
|
self.separator = separator
|
|
}
|
|
}
|
|
|
|
public enum DirectoryAutocompleteQuery: Equatable, Sendable {
|
|
case none
|
|
case roots([String])
|
|
case directory(ParentQuery)
|
|
case outsideRoots
|
|
}
|
|
|
|
/// Complete known roots without listing their forbidden ancestors. Once
|
|
/// inside a root, use the normal parent listing. `path` is home-expanded.
|
|
public static func directoryAutocompleteQuery(
|
|
path: String,
|
|
roots: [String]
|
|
) -> DirectoryAutocompleteQuery {
|
|
if path.isEmpty { return roots.isEmpty ? .none : .roots(Array(roots.prefix(8))) }
|
|
guard let query = parentQuery(for: path) else { return .none }
|
|
if roots.contains(where: {
|
|
RemoteDirectoryPath.isWithinRoot(path: path, root: $0)
|
|
&& RemoteDirectoryPath.isWithinRoot(path: $0, root: path)
|
|
}) {
|
|
return .directory(ParentQuery(parent: path, prefix: "", separator: query.separator))
|
|
}
|
|
if RemoteDirectoryPath.allowsBrowsing(path: query.parent, roots: roots) {
|
|
return .directory(query)
|
|
}
|
|
let matchingRoots = roots.filter { $0.lowercased().hasPrefix(path.lowercased()) }
|
|
return matchingRoots.isEmpty ? .outsideRoots : .roots(Array(matchingRoots.prefix(8)))
|
|
}
|
|
|
|
/// Derives the list-directory request from the typed text: list the
|
|
/// parent of the path segment being typed. Only absolute paths
|
|
/// autocomplete (the hub lists runner-local absolute paths).
|
|
///
|
|
/// `/data/gi` → list `/data`, prefix `gi`; `/data/` → list `/data`, no
|
|
/// prefix; `/` → list `/`; relative text → nil (no request).
|
|
public static func parentQuery(for input: String) -> ParentQuery? {
|
|
let text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let characters = Array(text)
|
|
let isPosix = characters.first == "/"
|
|
let isDrive = characters.count >= 3
|
|
&& characters[0].isLetter
|
|
&& characters[1] == ":"
|
|
&& isPathSeparator(characters[2])
|
|
let isUNC = characters.count >= 2
|
|
&& ((characters[0] == "\\" && characters[1] == "\\")
|
|
|| (characters[0] == "/" && characters[1] == "/"))
|
|
guard isPosix || isDrive || isUNC,
|
|
let separatorIndex = characters.lastIndex(where: isPathSeparator)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let separator = String(characters[separatorIndex])
|
|
let rawParent = String(characters[..<separatorIndex])
|
|
let parent: String
|
|
if separatorIndex == 0 {
|
|
parent = separator
|
|
} else if isDrivePrefix(rawParent) {
|
|
parent = rawParent + separator
|
|
} else if rawParent.isEmpty, isUNC {
|
|
parent = separator + separator
|
|
} else {
|
|
parent = rawParent
|
|
}
|
|
return ParentQuery(
|
|
parent: parent,
|
|
prefix: String(characters.dropFirst(separatorIndex + 1)),
|
|
separator: separator
|
|
)
|
|
}
|
|
|
|
/// Joins a listed entry back into a full suggestion path, then filters
|
|
/// to directories matching the typed prefix, capped like the web (8).
|
|
public static func buildSuggestions(
|
|
query: ParentQuery,
|
|
entries: [MachineDirectoryEntry],
|
|
limit: Int = 8
|
|
) -> [String] {
|
|
let base = query.parent.hasSuffix("/") || query.parent.hasSuffix("\\")
|
|
? query.parent
|
|
: query.parent + query.separator
|
|
let loweredPrefix = query.prefix.lowercased()
|
|
return entries
|
|
.filter { $0.type == .directory }
|
|
.filter { loweredPrefix.isEmpty || $0.name.lowercased().hasPrefix(loweredPrefix) }
|
|
.prefix(limit)
|
|
.map { "\(base)\($0.name)" }
|
|
}
|
|
|
|
private static func isPathSeparator(_ character: Character) -> Bool {
|
|
character == "/" || character == "\\"
|
|
}
|
|
|
|
private static func isDrivePrefix(_ value: String) -> Bool {
|
|
let characters = Array(value)
|
|
return characters.count == 2 && characters[0].isLetter && characters[1] == ":"
|
|
}
|
|
|
|
// MARK: - Recent paths
|
|
|
|
/// LRU cap per machine (web caps at 5; native chips fit a couple more).
|
|
public static let maxRecentPaths = 8
|
|
|
|
/// Dedupe-to-front LRU push (web `addRecentPath`). Blank input is a no-op.
|
|
public static func pushRecent(
|
|
_ existing: [String],
|
|
path: String,
|
|
cap: Int = maxRecentPaths
|
|
) -> [String] {
|
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return existing }
|
|
return Array(([trimmed] + existing.filter { $0 != trimmed }).prefix(cap))
|
|
}
|
|
|
|
// MARK: - Worktree
|
|
|
|
/// Client-side worktree-name check. The runner slugs the name to
|
|
/// `[a-z0-9-]` (`cli/src/runner/worktree.ts` `toSlug`); a name with no
|
|
/// alphanumeric characters slugs to nothing and the spawn fails server
|
|
/// side, so reject it up front. Empty is fine — the runner generates a
|
|
/// `MMDD-xxxx` default.
|
|
public static func worktreeNameError(_ name: String) -> String? {
|
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
let hasAlphanumeric = trimmed.contains { $0.isLetter || $0.isNumber }
|
|
return hasAlphanumeric ? nil : "Name needs at least one letter or digit"
|
|
}
|
|
|
|
// MARK: - Codex catalog
|
|
|
|
/// Active catalog entry for `model` (`'auto'` → the default row).
|
|
public static func resolveCodexModel(
|
|
models: [CodexModelSummary],
|
|
model: String
|
|
) -> CodexModelSummary? {
|
|
let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if normalized.isEmpty || normalized == "auto" {
|
|
return models.first { $0.isDefault } ?? models.first
|
|
}
|
|
return models.first { $0.id == normalized }
|
|
}
|
|
|
|
/// `codexModelAdvertisesFastTier`: the fast-mode control only appears
|
|
/// when the active model's catalog row advertises a fast service tier.
|
|
/// Empty catalog → hidden (no authoritative answer yet).
|
|
public static func codexModelAdvertisesFastTier(
|
|
model: String,
|
|
models: [CodexModelSummary]
|
|
) -> Bool {
|
|
guard !models.isEmpty else { return false }
|
|
let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let active: CodexModelSummary?
|
|
if !normalized.isEmpty && normalized != "auto" {
|
|
active = models.first {
|
|
$0.id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
|
}
|
|
} else {
|
|
active = models.first { $0.isDefault }
|
|
}
|
|
return active?.serviceTiers?.contains {
|
|
$0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().contains("fast")
|
|
} == true
|
|
}
|
|
|
|
/// Supported reasoning efforts of the active codex model, normalized
|
|
/// (trim/lowercase/dedupe — `getCodexModelReasoningEfforts`); nil when
|
|
/// the catalog does not advertise any (fall back to the static list).
|
|
public static func codexReasoningEfforts(
|
|
models: [CodexModelSummary],
|
|
model: String
|
|
) -> [String]? {
|
|
guard let efforts = resolveCodexModel(models: models, model: model)?
|
|
.supportedReasoningEfforts
|
|
else {
|
|
return nil
|
|
}
|
|
var seen = Set<String>()
|
|
let normalized = efforts
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
|
.filter { !$0.isEmpty && seen.insert($0).inserted }
|
|
return normalized.isEmpty ? nil : normalized
|
|
}
|
|
|
|
// MARK: - Drafts
|
|
|
|
/// Draft sanitization on restore (web `loadNewSessionFormDraft`): an
|
|
/// uncreatable/unknown agent coerces to claude and drops the
|
|
/// agent-dependent fields; a permission mode outside the flavor's
|
|
/// catalog resets to default. (The Android original also re-normalizes
|
|
/// serviceTier/collaborationMode/copilotAgentMode/sessionType strings —
|
|
/// here those are enum-constrained already.)
|
|
public static func sanitizeDraft(_ draft: NewSessionForm) -> NewSessionForm {
|
|
let creatable = AgentFlavor.creatableFlavors.contains(draft.agent)
|
|
var base = creatable ? draft : NewSessionForm(
|
|
machineId: draft.machineId,
|
|
directory: draft.directory,
|
|
agent: .claude,
|
|
yolo: draft.yolo,
|
|
sessionType: draft.sessionType,
|
|
worktreeName: draft.worktreeName
|
|
)
|
|
if !base.agent.permissionModes.contains(base.permissionMode) {
|
|
base.permissionMode = .default
|
|
}
|
|
return base
|
|
}
|
|
}
|