mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle. Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes. Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
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.launchPermissionModes.contains(base.permissionMode) {
|
|
base.permissionMode = .default
|
|
}
|
|
return base
|
|
}
|
|
}
|