diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift index 8ecd8b76..5c0e74d7 100644 --- a/supacode/CLIService/Shared/CommandEnvelope.swift +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -20,6 +20,7 @@ public enum Command: Codable, Sendable { case agentsRead(AgentReadInput) case agentsSignal(AgentSignalInput) case agentsHook(AgentNativeHookInput) + case agentsDispatch(DispatchInput) case agentsDispatchComplete(DispatchCompleteInput) case agentsDispatchAbandon(DispatchAbandonInput) case agentsWait(AgentWaitInput) @@ -42,6 +43,7 @@ public enum Command: Codable, Sendable { case .agentsRead: "agents.read" case .agentsSignal: "agents.signal" case .agentsHook: "agents._hook" + case .agentsDispatch: "agents.dispatch" case .agentsDispatchComplete: "agents.dispatch-complete" case .agentsDispatchAbandon: "agents.dispatch-abandon" case .agentsWait: "agents.wait" diff --git a/supacode/CLIService/Shared/DispatchCommandPayload.swift b/supacode/CLIService/Shared/DispatchCommandPayload.swift index e5d1cc75..d4800c5e 100644 --- a/supacode/CLIService/Shared/DispatchCommandPayload.swift +++ b/supacode/CLIService/Shared/DispatchCommandPayload.swift @@ -311,6 +311,40 @@ public struct AgentSignalsPayload: Codable, Equatable, Sendable { } } +/// `agents.dispatch` success: the immutable target snapshot and the new pending record, +/// shaped like the `create` response so coordinators consume both the same way. +public struct AgentDispatchCommandPayload: Codable, Equatable, Sendable { + public let target: TabTarget + public let dispatch: DispatchPendingRecord + + public init(target: TabTarget, dispatch: DispatchPendingRecord) { + self.target = target + self.dispatch = dispatch + } +} + +/// Governed `error.details` for a refused `agents.dispatch`: `record` carries the pane's +/// current pending dispatch (`DISPATCH_PENDING`); `observation` and `signals` carry the +/// evidence that made the pane busy or agent-less. +public struct AgentDispatchErrorDetails: Codable, Equatable, Sendable { + public let target: TabTarget + public let record: DispatchRecordPayload? + public let observation: AgentWaitObservation? + public let signals: AgentSignalsPayload? + + public init( + target: TabTarget, + record: DispatchRecordPayload? = nil, + observation: AgentWaitObservation? = nil, + signals: AgentSignalsPayload? = nil + ) { + self.target = target + self.record = record + self.observation = observation + self.signals = signals + } +} + public struct DispatchCompleteCommandPayload: Codable, Equatable, Sendable { public let target: TabTarget public let receipt: DispatchCompletedRecord diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index 2b93c2d4..8dc44c71 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -36,6 +36,10 @@ public enum CLIErrorCode { public static let dispatchAbandoned = "DISPATCH_ABANDONED" public static let dispatchNeedsInput = "DISPATCH_NEEDS_INPUT" public static let dispatchIncomplete = "DISPATCH_INCOMPLETE" + /// `agents dispatch` refused because the pane already holds a pending dispatch. + public static let dispatchPending = "DISPATCH_PENDING" + /// `agents dispatch` refused because the pane's agent is working or blocked. + public static let dispatchTargetBusy = "DISPATCH_TARGET_BUSY" public static let blockerUnreadable = "BLOCKER_UNREADABLE" public static let sessionUnresolved = "SESSION_UNRESOLVED" public static let resultNotFound = "RESULT_NOT_FOUND" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 41663d60..08badffc 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -39,7 +39,11 @@ public struct DispatchCompleteInput: Codable, Sendable { nonisolated public static let environmentKey = "PROWL_DISPATCH_ID" public static let maximumSummaryBytes = 32 * 1_024 - public let dispatchID: String + /// The launch-scoped `PROWL_DISPATCH_ID` when the caller still carries one. The app + /// resolves the receipt from the caller pane's current pending dispatch, so this value + /// is compatibility diagnostics only: a process launched with an older id still + /// completes the pane's current record. + public let dispatchID: String? public let outcome: DispatchCompletionOutcome public let summary: String @@ -49,14 +53,16 @@ public struct DispatchCompleteInput: Codable, Sendable { case summary } - public init(dispatchID: String, outcome: DispatchCompletionOutcome, summary: String) { + public init(dispatchID: String?, outcome: DispatchCompletionOutcome, summary: String) { self.dispatchID = dispatchID self.outcome = outcome self.summary = summary } public var validationErrorMessage: String? { - CLIInputTextValidator.validateDispatchID(dispatchID, name: DispatchCompleteInput.environmentKey) + dispatchID.flatMap { + CLIInputTextValidator.validateDispatchID($0, name: DispatchCompleteInput.environmentKey) + } ?? CLIInputTextValidator.validate( summary, name: "--summary", @@ -65,6 +71,42 @@ public struct DispatchCompleteInput: Codable, Sendable { } } +/// `prowl agents dispatch --prompt -`: a new pending dispatch for an agent that +/// already runs in an existing pane. The prompt reaches the runtime through the pane's +/// input path as one bracketed paste followed by Enter, so newlines and tabs survive but +/// every other control character would be stripped or reinterpreted before delivery. +public struct DispatchInput: Codable, Sendable, Equatable { + public static let maximumPromptUTF8ByteCount = CreateLaunchInput.maximumPromptUTF8ByteCount + + public let pane: String + public let prompt: String + + public init(pane: String, prompt: String) { + self.pane = pane + self.prompt = prompt + } + + public var validationErrorMessage: String? { + guard !pane.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "agents dispatch requires a pane handle (pN) or pane UUID." + } + guard !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "The dispatch prompt is empty." + } + guard prompt.utf8.count <= Self.maximumPromptUTF8ByteCount else { + return "The dispatch prompt exceeds the 256 KiB UTF-8 limit." + } + guard !prompt.unicodeScalars.contains(where: Self.isDisallowedScalar) else { + return "The dispatch prompt must not contain control characters other than newlines and tabs." + } + return nil + } + + private static func isDisallowedScalar(_ scalar: Unicode.Scalar) -> Bool { + scalar != "\n" && scalar != "\t" && CharacterSet.controlCharacters.contains(scalar) + } +} + public struct DispatchAbandonInput: Codable, Sendable { public static let maximumReasonBytes = 32 * 1_024 diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index f2057d32..150fc676 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -581,6 +581,15 @@ nonisolated enum AgentProfileLaunchPlanner { nonisolated enum AgentDispatchPrompt { static let protocolVersion = 1 + /// Origin marker on text Prowl types into a live agent, shared with `HandoffInjection`. + static let injectedPrefix = "[Prowl] " + + /// The text `agents dispatch` types into an existing agent pane: the same prompt and + /// protocol block a prompted launch passes through argv, behind the origin marker so the + /// agent (and a person reading the transcript) can tell Prowl authored the line. + static func renderInjected(userPrompt: String) -> String { + injectedPrefix + render(userPrompt: userPrompt) + } static func render(userPrompt: String) -> String { """ diff --git a/supacode/Features/Terminal/BusinessLogic/AgentDispatchStore.swift b/supacode/Features/Terminal/BusinessLogic/AgentDispatchStore.swift index 75d6f1e5..465a06ca 100644 --- a/supacode/Features/Terminal/BusinessLogic/AgentDispatchStore.swift +++ b/supacode/Features/Terminal/BusinessLogic/AgentDispatchStore.swift @@ -115,6 +115,9 @@ nonisolated enum AgentDispatchStoreError: Error, Equatable, Sendable { case sourceMismatch case alreadyCompleted case alreadyTerminal + /// The surface already holds a pending record; a second one would let two assignments + /// race for the same receipt. + case surfacePending } typealias AgentDispatchObservationStream = AsyncStream @@ -189,6 +192,9 @@ final class AgentDispatchStore { guard existing == binding else { throw AgentDispatchStoreError.alreadyBound } return } + guard pendingDispatchID(surfaceID: binding.surfaceID) == nil else { + throw AgentDispatchStoreError.surfacePending + } entry.snapshot = AgentDispatchSnapshot(record: entry.snapshot.record, binding: binding) entries[dispatchID] = entry } @@ -197,6 +203,35 @@ final class AgentDispatchStore { entries[dispatchID]?.snapshot } + /// The one pending record bound to the surface, if any. + func pendingSnapshot(surfaceID: UUID) -> AgentDispatchSnapshot? { + pendingDispatchID(surfaceID: surfaceID).flatMap { entries[$0]?.snapshot } + } + + /// Completes the caller pane's current pending record. Without one, the pane's most + /// recently issued record answers instead so an identical retry replays its receipt and + /// a conflicting one is rejected exactly as an id-addressed completion would be. + func complete( + surfaceID: UUID, + outcome: DispatchCompletionOutcome, + summary: String + ) throws -> AgentDispatchMutationResult { + guard let dispatchID = pendingDispatchID(surfaceID: surfaceID) ?? latestDispatchID(surfaceID: surfaceID) + else { throw AgentDispatchStoreError.notFound } + return try complete(dispatchID: dispatchID, outcome: outcome, summary: summary, callerSurfaceID: surfaceID) + } + + private func pendingDispatchID(surfaceID: UUID) -> String? { + issuanceOrder.first { dispatchID in + guard let entry = entries[dispatchID] else { return false } + return entry.snapshot.record.state == .pending && entry.snapshot.binding?.surfaceID == surfaceID + } + } + + private func latestDispatchID(surfaceID: UUID) -> String? { + issuanceOrder.last { entries[$0]?.snapshot.binding?.surfaceID == surfaceID } + } + func complete( dispatchID: String, outcome: DispatchCompletionOutcome, diff --git a/supacodeTests/AgentDispatchStoreTests.swift b/supacodeTests/AgentDispatchStoreTests.swift index d75759bd..3c5d60b4 100644 --- a/supacodeTests/AgentDispatchStoreTests.swift +++ b/supacodeTests/AgentDispatchStoreTests.swift @@ -263,6 +263,62 @@ struct AgentDispatchStoreTests { #expect(await waiter.next() == .changed(completed.snapshot)) } + @Test func oneSurfaceHoldsAtMostOnePendingRecord() throws { + let store = makeStore(ids: ["d1", "d2"]) + let surfaceID = UUID() + let binding = binding(surfaceID: surfaceID) + _ = try store.issue() + try store.bind(dispatchID: "d1", binding: binding) + #expect(store.pendingSnapshot(surfaceID: surfaceID)?.record.id == "d1") + #expect(store.pendingSnapshot(surfaceID: UUID()) == nil) + + _ = try store.issue() + #expect(throws: AgentDispatchStoreError.surfacePending) { + try store.bind(dispatchID: "d2", binding: binding) + } + #expect(store.pendingSnapshot(surfaceID: surfaceID)?.record.id == "d1") + #expect(store.snapshot(dispatchID: "d2")?.binding == nil) + // Rebinding the pending record itself stays idempotent. + try store.bind(dispatchID: "d1", binding: binding) + + _ = try store.complete(dispatchID: "d1", outcome: .succeeded, summary: "Done", callerSurfaceID: surfaceID) + #expect(store.pendingSnapshot(surfaceID: surfaceID) == nil) + try store.bind(dispatchID: "d2", binding: binding) + #expect(store.pendingSnapshot(surfaceID: surfaceID)?.record.id == "d2") + } + + @Test func completionBySurfaceResolvesThePendingRecordThenReplaysTheLatestOne() throws { + let store = makeStore(ids: ["d1", "d2"]) + let surfaceID = UUID() + let binding = binding(surfaceID: surfaceID) + _ = try store.issue() + try store.bind(dispatchID: "d1", binding: binding) + #expect(throws: AgentDispatchStoreError.notFound) { + try store.complete(surfaceID: UUID(), outcome: .succeeded, summary: "Done") + } + + let first = try store.complete(surfaceID: surfaceID, outcome: .succeeded, summary: "Round one") + #expect(first.snapshot.record.id == "d1") + #expect(!first.replayed) + // With no pending record left, a retry replays the pane's latest receipt and a + // conflicting retry is still rejected. + let replay = try store.complete(surfaceID: surfaceID, outcome: .succeeded, summary: "Round one") + #expect(replay.replayed) + #expect(throws: AgentDispatchStoreError.alreadyCompleted) { + try store.complete(surfaceID: surfaceID, outcome: .failed, summary: "Different") + } + + _ = try store.issue() + try store.bind( + dispatchID: "d2", + binding: AgentDispatchBinding(surfaceID: surfaceID, target: Self.target, evidenceEpoch: UUID()) + ) + let second = try store.complete(surfaceID: surfaceID, outcome: .failed, summary: "Round two") + #expect(second.snapshot.record.id == "d2") + #expect(!second.replayed) + #expect(store.snapshot(dispatchID: "d1")?.record.state == .completed) + } + @Test func storeResetIsAppLifetimeReset() throws { let first = makeStore(ids: ["d1"]) _ = try first.issue()