Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
0325A44358904B44C2237CCE /* ApprovalQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BAE224C1C435C9FFD05BF65 /* ApprovalQueue.swift */; };
0329E76BA4A8FEEA8A6D76E7 /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */; };
065FCE817315454DE3F85F25 /* IslandViewContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA02B08886939FF6FE0475DD /* IslandViewContent.swift */; };
06BF5FD95E03010CAFDBF2D0 /* NotchBuddyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */; };
Expand Down Expand Up @@ -45,6 +46,7 @@
78FCEDC8F78B683AB10E6529 /* NotionPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */; };
878924960FC0F2B3524D48AC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192844E7299DEED5C2372067 /* AppDelegate.swift */; };
8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; };
8C5CF7C82640DD6A0685A0D5 /* ApprovalQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BAE224C1C435C9FFD05BF65 /* ApprovalQueue.swift */; };
8D1C01F283FA7806119A1C85 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; };
967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; };
9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */; };
Expand Down Expand Up @@ -76,6 +78,7 @@
/* Begin PBXFileReference section */
15A1A8206F73376F4CAF2057 /* IslandRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandRootView.swift; sourceTree = "<group>"; };
192844E7299DEED5C2372067 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
1BAE224C1C435C9FFD05BF65 /* ApprovalQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalQueue.swift; sourceTree = "<group>"; };
1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEngine.swift; sourceTree = "<group>"; };
2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripePoller.swift; sourceTree = "<group>"; };
3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcomPoller.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -116,6 +119,7 @@
children = (
192844E7299DEED5C2372067 /* AppDelegate.swift */,
9F6D7015D6A77AA59323C0E7 /* AppLog.swift */,
1BAE224C1C435C9FFD05BF65 /* ApprovalQueue.swift */,
C2E4F18AA940EFF7E047B9F7 /* AppState.swift */,
A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */,
D4540F630F8BA04AFFFA3979 /* BotEngine.swift */,
Expand Down Expand Up @@ -289,6 +293,7 @@
C940B651C6CA952D99388E04 /* AppDelegate.swift in Sources */,
4B6AC00F22EC75E90E0157D5 /* AppLog.swift in Sources */,
1F11EC4E6F604C612A1ADAD1 /* AppState.swift in Sources */,
0325A44358904B44C2237CCE /* ApprovalQueue.swift in Sources */,
CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */,
C24D863967D8D32D828ED4B1 /* BotEngine.swift in Sources */,
4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */,
Expand Down Expand Up @@ -326,6 +331,7 @@
878924960FC0F2B3524D48AC /* AppDelegate.swift in Sources */,
61CB7049490F61CFA1B36B64 /* AppLog.swift in Sources */,
61F73B27A7E3AFF584B2CC22 /* AppState.swift in Sources */,
8C5CF7C82640DD6A0685A0D5 /* ApprovalQueue.swift in Sources */,
651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */,
0E9CE475EC53C67DAB00F8E5 /* BotEngine.swift in Sources */,
1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */,
Expand Down
82 changes: 82 additions & 0 deletions NotchBuddy/Sources/App/ApprovalQueue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Foundation

// MARK: - ApprovalQueue
// Pure FIFO queue of pending PermissionRequests. No AppKit, no SwiftUI, no I/O:
// HookServer owns the sockets, timers and UI, and mutates this value on the main actor only.
// The head is the request currently shown on the approval card.

struct QueuedApproval: Equatable, Sendable {
let id: UUID
/// Client socket, held open until the user decides. Closed by the owner's dispatch source.
let fd: Int32
let sessionId: String
/// "integration_claude", "agent_cursor" or "agent_codex".
let pillId: String
let projectName: String
let cwd: String
let tool: String
let command: String
/// tool_input serialized with sortedKeys, "" if absent. Used to match PostToolUse.
let inputKey: String
}

struct ApprovalQueue: Sendable {
/// Upper bound of simultaneously pending requests. Each one holds a socket and a 115 s timer
/// and the card only shows one at a time, so beyond this the newest falls back to its terminal.
static let maxPending = 5

enum EnqueueResult: Equatable, Sendable { case accepted, rejectedFull }

private(set) var entries: [QueuedApproval] = []

var head: QueuedApproval? { entries.first }
var count: Int { entries.count }
var isEmpty: Bool { entries.isEmpty }

func contains(id: UUID) -> Bool { entries.contains { $0.id == id } }

/// 1-based position of the entry in the queue, nil if absent.
func position(of id: UUID) -> Int? {
entries.firstIndex { $0.id == id }.map { $0 + 1 }
}

/// True when the queued requests do not all come from the same session and agent.
var hasMixedSources: Bool {
guard let first = entries.first else { return false }
return entries.contains { $0.sessionId != first.sessionId || $0.pillId != first.pillId }
}

func hasEntries(forPill pillId: String) -> Bool {
entries.contains { $0.pillId == pillId }
}

mutating func enqueue(_ entry: QueuedApproval, maxPending: Int = ApprovalQueue.maxPending) -> EnqueueResult {
guard entries.count < maxPending else { return .rejectedFull }
entries.append(entry)
return .accepted
}

/// Removes only the entry with this id. Other entries keep their order.
@discardableResult
mutating func remove(id: UUID) -> QueuedApproval? {
guard let idx = entries.firstIndex(where: { $0.id == id }) else { return nil }
return entries.remove(at: idx)
}

/// Entries made moot by a later hook event of the same agent. Matching is per request
/// (session + agent, plus tool and input for PostToolUse), never by pill alone.
func resolved(byEvent name: String, sessionId: String, pillId: String,
tool: String, inputKey: String) -> [QueuedApproval] {
switch name {
case "PostToolUse", "PostToolUseFailure":
return entries.filter {
$0.pillId == pillId && $0.sessionId == sessionId
&& $0.tool == tool && $0.inputKey == inputKey
}
case "Stop", "StopFailure", "UserPromptSubmit", "SessionEnd", "Interrupt":
return entries.filter { $0.pillId == pillId && $0.sessionId == sessionId }
default:
return []
}
}
}
Loading