Replace the streaming-json CLI + generated PreToolUse-hook Grok adapter with grok's native ACP mode (JSON-RPC 2.0 over stdio). Adds GrokACPBackend/GrokACPDecoder/GrokACPDecisionMapping (a near-twin of the Codex app-server adapter); generalizes CodexJSONRPC into a shared JSONRPCConnection (jsonrpc-header flag, default off so Codex is byte-identical, plus endNotifications); deletes GrokBuildBackend, GrokStreamDecoder, GrokHookConfig and strips the /grok-hook HTTP bridge from MCPApprovalServer. Claude and Codex are left native. Preserves conflict-lock parity, native resume (session/load), and the hermetic fake-grok contract test. Full suite: 525 tests green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
246 lines
9.4 KiB
Swift
246 lines
9.4 KiB
Swift
import Foundation
|
||
|
||
// Pure, platform-agnostic value types shared by the macOS host (NucleicCore) and the
|
||
// iPhone client (NucleicRemote). These are the *exact* identifiers the sync wire layer
|
||
// projects (SYNC_PROTOCOL §5). They live here, not in NucleicCore, so the protocol layer
|
||
// compiles cleanly for iOS without dragging in GRDB / Foundation.Process / Network.
|
||
//
|
||
// Extracted from Backend.swift (identifiers), Session.swift (SessionStatus), and
|
||
// WorktreeManager.swift (DiffStat); NucleicCore re-exports this module (ProtocolExports.swift)
|
||
// so existing host code keeps referencing these unqualified.
|
||
|
||
// MARK: - Identifiers
|
||
|
||
public struct SessionID: Hashable, Sendable, Codable, CustomStringConvertible {
|
||
public let rawValue: String
|
||
|
||
public init(rawValue: String) { self.rawValue = rawValue }
|
||
public static func generate() -> SessionID { SessionID(rawValue: UUID().uuidString) }
|
||
|
||
public init(from decoder: Decoder) throws {
|
||
rawValue = try decoder.singleValueContainer().decode(String.self)
|
||
}
|
||
public func encode(to encoder: Encoder) throws {
|
||
var container = encoder.singleValueContainer()
|
||
try container.encode(rawValue)
|
||
}
|
||
public var description: String { rawValue }
|
||
/// Short prefix for logs — structural metadata only (OBSERVABILITY A.1).
|
||
public var short: String { String(rawValue.prefix(8)) }
|
||
}
|
||
|
||
public struct ApprovalID: Hashable, Sendable, Codable, CustomStringConvertible {
|
||
public let rawValue: String
|
||
|
||
public init(rawValue: String) { self.rawValue = rawValue }
|
||
public static func generate() -> ApprovalID { ApprovalID(rawValue: UUID().uuidString) }
|
||
|
||
public init(from decoder: Decoder) throws {
|
||
rawValue = try decoder.singleValueContainer().decode(String.self)
|
||
}
|
||
public func encode(to encoder: Encoder) throws {
|
||
var container = encoder.singleValueContainer()
|
||
try container.encode(rawValue)
|
||
}
|
||
public var description: String { rawValue }
|
||
}
|
||
|
||
public enum BackendID: String, Sendable, Codable {
|
||
case claudeCode
|
||
case codex
|
||
case codexExec
|
||
/// xAI's Grok coding agent over **ACP** (`grok agent stdio`) — standards JSON-RPC 2.0 over
|
||
/// stdio, baked into the binary. A sibling of `codex`: interactive approvals arrive as native
|
||
/// `session/request_permission` requests (GROK_ADAPTER §3–4). Host-only in v1.
|
||
case grok
|
||
|
||
/// Infer the backend from a model SKU so the model picker doubles as the backend
|
||
/// selector: Claude SKUs → `claudeCode`, OpenAI/Codex SKUs (`gpt-*`, `o3*`, `o4*`, or
|
||
/// anything containing `codex`) → `codex`, xAI SKUs (`grok-*`) → `grok`. `nil` when
|
||
/// unrecognized, so callers fall back to the project/app default.
|
||
public static func forModel(_ model: String?) -> BackendID? {
|
||
guard let model = model?.lowercased() else { return nil }
|
||
if model.hasPrefix("claude") { return .claudeCode }
|
||
if model.hasPrefix("grok") { return .grok }
|
||
if model.hasPrefix("gpt") || model.hasPrefix("o3") || model.hasPrefix("o4")
|
||
|| model.contains("codex")
|
||
{
|
||
return .codex
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// True for the OpenAI/Codex backends (`codex`, `codexExec`) — the GPT-family agents,
|
||
/// as opposed to `claudeCode`/`grok`. Used to bucket sessions when keeping Claude and GPT
|
||
/// agents in separate sandboxes (the two can otherwise compete and kill each other's
|
||
/// processes). Grok is not part of this family (it runs host-only in v1).
|
||
public var isCodexFamily: Bool {
|
||
switch self {
|
||
case .codex, .codexExec: return true
|
||
case .claudeCode, .grok: return false
|
||
}
|
||
}
|
||
}
|
||
|
||
public struct ProjectID: Hashable, Sendable, Codable, CustomStringConvertible {
|
||
public let rawValue: String
|
||
public init(rawValue: String) { self.rawValue = rawValue }
|
||
public static func generate() -> ProjectID { ProjectID(rawValue: UUID().uuidString) }
|
||
public init(from decoder: Decoder) throws {
|
||
rawValue = try decoder.singleValueContainer().decode(String.self)
|
||
}
|
||
public func encode(to encoder: Encoder) throws {
|
||
var container = encoder.singleValueContainer()
|
||
try container.encode(rawValue)
|
||
}
|
||
public var description: String { rawValue }
|
||
}
|
||
|
||
public struct TodoID: Hashable, Sendable, Codable, Identifiable, CustomStringConvertible {
|
||
public let rawValue: String
|
||
public init(rawValue: String) { self.rawValue = rawValue }
|
||
public static func generate() -> TodoID { TodoID(rawValue: UUID().uuidString) }
|
||
public var id: String { rawValue }
|
||
public init(from decoder: Decoder) throws {
|
||
rawValue = try decoder.singleValueContainer().decode(String.self)
|
||
}
|
||
public func encode(to encoder: Encoder) throws {
|
||
var container = encoder.singleValueContainer()
|
||
try container.encode(rawValue)
|
||
}
|
||
public var description: String { rawValue }
|
||
}
|
||
|
||
/// Lifecycle of a captured idea (the to-do inbox).
|
||
public enum TodoStatus: String, Sendable, Codable {
|
||
case open // captured, not yet acted on
|
||
case dispatched // an agent was dispatched for it
|
||
case done // manually completed / dismissed
|
||
}
|
||
|
||
public typealias WorktreePath = String
|
||
|
||
// MARK: - AgentInput (user turn payload; carried over the wire by `sendInput`)
|
||
|
||
public struct AgentInput: Sendable, Codable, Equatable {
|
||
public enum Part: Sendable, Codable, Equatable {
|
||
case text(String)
|
||
case context(label: String, body: String)
|
||
|
||
private enum CodingKeys: String, CodingKey { case type, text, label, body }
|
||
|
||
public init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
switch try c.decode(String.self, forKey: .type) {
|
||
case "text":
|
||
self = .text(try c.decode(String.self, forKey: .text))
|
||
case "context":
|
||
self = .context(
|
||
label: try c.decode(String.self, forKey: .label),
|
||
body: try c.decode(String.self, forKey: .body))
|
||
case let other:
|
||
throw DecodingError.dataCorruptedError(
|
||
forKey: .type, in: c, debugDescription: "Unknown AgentInput.Part \(other)")
|
||
}
|
||
}
|
||
|
||
public func encode(to encoder: Encoder) throws {
|
||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||
switch self {
|
||
case .text(let text):
|
||
try c.encode("text", forKey: .type)
|
||
try c.encode(text, forKey: .text)
|
||
case .context(let label, let body):
|
||
try c.encode("context", forKey: .type)
|
||
try c.encode(label, forKey: .label)
|
||
try c.encode(body, forKey: .body)
|
||
}
|
||
}
|
||
}
|
||
|
||
public let parts: [Part]
|
||
|
||
public init(parts: [Part]) { self.parts = parts }
|
||
public init(text: String) { self.parts = [.text(text)] }
|
||
|
||
/// Flattened user-facing text (for the injected `userText` transcript event).
|
||
public var plainText: String? {
|
||
let pieces = parts.map { part -> String in
|
||
switch part {
|
||
case .text(let text): return text
|
||
case .context(let label, let body): return "[\(label)]\n\(body)"
|
||
}
|
||
}
|
||
let joined = pieces.joined(separator: "\n")
|
||
return joined.isEmpty ? nil : joined
|
||
}
|
||
}
|
||
|
||
// MARK: - SessionStatus (derived state; PLAN "Core domain model", RUNTIME §2)
|
||
|
||
/// The `SessionController` is the only writer; everyone else (including the phone) observes.
|
||
public enum SessionStatus: String, Sendable, Codable {
|
||
/// Created, not yet started.
|
||
case idle
|
||
/// Worktree being created / setup script running.
|
||
case provisioning
|
||
/// Agent actively working a turn.
|
||
case running
|
||
/// Blocked on a human approval decision.
|
||
case awaitingApproval
|
||
/// Turn finished; waiting for the user's next input (interactive run still open).
|
||
case awaitingInput
|
||
/// Run completed normally.
|
||
case finished
|
||
/// Interrupted (cooperative) — resumable.
|
||
case interrupted
|
||
/// Fatal error.
|
||
case error
|
||
|
||
public var isTerminal: Bool {
|
||
switch self {
|
||
case .finished, .interrupted, .error: return true
|
||
default: return false
|
||
}
|
||
}
|
||
|
||
public var displayName: String {
|
||
switch self {
|
||
case .idle: "Idle"
|
||
case .provisioning: "Provisioning"
|
||
case .running: "Running"
|
||
case .awaitingApproval: "Awaiting approval"
|
||
case .awaitingInput: "Awaiting input"
|
||
case .finished: "Finished"
|
||
case .interrupted: "Interrupted"
|
||
case .error: "Error"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How an agent's most recent turn *ended*, refining `.awaitingInput` so the UI can tell
|
||
/// "the agent is done" from "the agent needs you". A soft, presentation-only signal (not part
|
||
/// of the state machine); `nil` = not-yet-classified.
|
||
public enum TurnDisposition: String, Sendable, Codable {
|
||
/// The turn ended by asking the user something or needing a decision from them.
|
||
case awaitingInput
|
||
/// The turn finished the requested work; nothing is required from the user.
|
||
case completed
|
||
}
|
||
|
||
// MARK: - DiffStat (worktree diff summary; carried in SessionSummary)
|
||
|
||
public struct DiffStat: Sendable, Codable, Equatable {
|
||
public let filesChanged: Int
|
||
public let added: Int
|
||
public let removed: Int
|
||
|
||
public init(filesChanged: Int, added: Int, removed: Int) {
|
||
self.filesChanged = filesChanged
|
||
self.added = added
|
||
self.removed = removed
|
||
}
|
||
|
||
public static let empty = DiffStat(filesChanged: 0, added: 0, removed: 0)
|
||
}
|