mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
* refactor(web): route create-form permission control through one native-select predicate Extract usesNativePermissionSelect(flavor) (grok || codex-family, matching the existing iOS/Android predicate of the same name) and route PermissionField's select-vs-toggle gate through it instead of an inline condition. Rename the codex-family-only state codexFamilyPermissionMode to nativePermissionMode since it now backs a shared predicate, not just the codex family. Behavior is unchanged: any stale sessionStorage draft written under the old codexFamilyPermissionMode key has no value under the new key and falls back to 'default', which only matters within a single browser tab's lifetime. * feat(web): let Claude pick a permission mode when creating a session Claude was the only create-form flavor still on the global HAPI YOLO toggle while grok and the codex family got the native permission select, so there was no way to start a session in Plan Mode without creating it first and switching the mode from the composer. usesNativePermissionSelect now gates the control for claude as well, and the spawn body carries permissionMode (including 'default') instead of yolo, which is the shape the other native-select flavors already send. The stored hapi:newSession:yolo preference is bridged into the select rather than dropped, but only for the flavors that have actually moved onto it (LEGACY_YOLO_BRIDGE_AGENTS = codex, claude). copilot, gemini, kimi and opencode moved earlier and settled on 'default'; re-enabling Yolo for them now would widen permissions rather than migrate a preference. This narrows the sessionStorage draft bridge too, which until now fired for the whole codex family with no allow-list, so their draft restores yield 'default' instead of 'yolo' — same-tab-lifetime state only. Claude and the codex family share one nativePermissionMode state and their mode sets do not overlap, so the existing agent-change reset plus the flavor filters in the draft loader and the stored launch settings are what keep a codex mode out of a Claude spawn. Adds the regression test that pins it: pick a mode under codex, switch to Claude, create, assert the payload carries 'default'. * feat(ios): let Claude pick a permission mode when creating a session Extend usesNativePermissionSelect to include claude alongside grok and the codex family, matching the web change. buildSpawnRequest now derives both yolo and permissionMode from that single predicate instead of two separate local flags, so claude sends permissionMode (including 'default') and no longer sends yolo. Unlike web, iOS carries no persistent YOLO preference across sessions to migrate — the toggle only lives in the in-memory form or a draft deleted on success — so there is no bridging logic to add here. * feat(android): let Claude pick a permission mode when creating a session Extend usesNativePermissionSelect to include claude alongside grok and the codex family, matching the web and iOS changes. buildSpawnRequest derives both yolo and permissionMode from that single predicate, so claude sends permissionMode (including 'default') and no longer sends yolo. Unlike web, Android has no persistent YOLO preference to migrate: the toggle only lives in the form draft, which is deleted once a session is created. The agent-switch test asserted claude renders the YOLO toggle; it now checks the native select for claude and keeps the toggle assertion on cursor, which still carries it.
395 lines
17 KiB
Swift
395 lines
17 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 catalog ids).
|
|
public var model: String
|
|
/// Claude launch effort; `'auto'` = omit.
|
|
public var effort: String
|
|
/// Codex reasoning effort; `'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`/`effort` only for flavors whose picker exists in this v1
|
|
/// (claude static list, codex machine catalog; others send no model);
|
|
/// - `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
|
|
// v1 model pickers: claude (static presets) and codex (machine
|
|
// catalog). Other flavors' discovery endpoints are TODO(M4+), so
|
|
// their model is never sent.
|
|
let resolvedModel: String? = ((agent == .claude || agent == .codex) && form.model != "auto")
|
|
? form.model
|
|
: nil
|
|
let trimmedWorktreeName = form.worktreeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return SpawnRequest(
|
|
directory: form.trimmedDirectory,
|
|
agent: agent,
|
|
model: resolvedModel,
|
|
effort: (agent == .claude && form.effort != "auto") ? form.effort : nil,
|
|
modelReasoningEffort: (agent == .codex && 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
|
|
)
|
|
}
|
|
|
|
// 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 init(parent: String, prefix: String, separator: String = "/") {
|
|
self.parent = parent
|
|
self.prefix = prefix
|
|
self.separator = separator
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|