native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
29 kB · 789 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790import Foundation
nonisolated let agentDetectionRecentLineLimit = 24nonisolated let piAgentDetectionRecentLineLimit = 32
extension DetectedAgent { nonisolated func detectState(in screen: String) -> AgentRawState { detectScreen(in: screen).state }
/// The slice of the active screen this agent's detector consumes. /// /// Claude reads the full active screen: its rules are region-anchored (the /// live status block is walked bottom-up from the composer by row shape, /// blockers around the prompt, viewer chrome from the bottom lines), so a /// bottom-measured line budget added no false-positive guard — it only cut /// the live signal off when a long todo list plus a multi-line status line /// pushed the spinner row past the limit and a working agent read as idle. /// /// Every other detector keeps a bounded tail as its guard against transcript /// history. Pi retains 32 non-blank lines so pi-subagents' adaptive widget can /// keep its header and live job row together; the remaining detectors keep 24. /// For the legacy scanners the tail is load-bearing — they match with whole-text /// scans. Codex's structured profile anchors its windows inside the tail instead; /// it shares Claude's bounded-bottom exposure in principle and keeps the tail /// until its regions are bounded by shape the same way. nonisolated func detectionScreenText(from screen: String) -> String { switch self { case .claude: screen case .pi: agentDetectionRecentLines(screen, limit: piAgentDetectionRecentLineLimit) default: agentDetectionRecentText(screen) } }
/// The one production entry point for building a profile snapshot. State /// detection and blocker extraction must read the same slice; going through /// this keeps a call site from pairing a profile with the wrong one. nonisolated func detectionSnapshot(from screen: String) -> AgentScreenSnapshot { AgentScreenSnapshot(text: detectionScreenText(from: screen)) }
nonisolated func detectScreen(in screen: String) -> AgentScreenDetection { let text = detectionScreenText(from: screen) let state: AgentRawState switch self { case .pi: state = detectPi(text) case .omp: state = detectOMP(text) case .claude: return ClaudeScreenProfile.detect(in: AgentScreenSnapshot(text: text)) case .codex: return CodexScreenProfile.detect(in: AgentScreenSnapshot(text: text)) case .gemini: state = detectGemini(text) case .cursor: state = detectCursor(text) case .cline: state = detectCline(text) case .opencode: state = detectOpenCode(text) case .copilot: state = detectCopilot(text) case .kimi: state = detectKimi(text) case .droid: state = detectDroid(text) case .amp: state = detectAmp(text) case .qoder: state = detectQoder(text) case .qwen: state = detectQwen(text) case .grok: state = detectGrok(text) } return AgentScreenDetection(state: state, reason: .legacyDetector) }}
nonisolated func agentDetectionRecentText(_ content: String) -> String { agentDetectionRecentLines(content, limit: agentDetectionRecentLineLimit)}
nonisolated func agentDetectionRecentLines(_ content: String, limit: Int) -> String { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var remainingNonBlankLines = limit var startIndex = lines.startIndex
for index in lines.indices.reversed() { guard !lines[index].trimmingCharacters(in: .whitespaces).isEmpty else { continue } remainingNonBlankLines -= 1 if remainingNonBlankLines == 0 { startIndex = index break } }
return lines[startIndex...].joined(separator: "\n")}
nonisolated private func detectPi(_ content: String) -> AgentRawState { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) } let liveFooterLines = lines.filter { !$0.isEmpty }.suffix(5) return lines.contains(where: isPiWorkingText) || liveFooterLines.contains(where: isPiFramedWorkingFooter) || hasPiRunningAsyncSubagentCard(lines) ? .working : .idle}
nonisolated private func hasPiRunningAsyncSubagentCard(_ lines: [String]) -> Bool { if lines.contains(where: isPiAdaptiveAsyncSubagentRunningLine) { return true } guard lines.count >= 2 else { return false }
return lines.indices.dropLast().contains { index in guard let headerName = piAsyncSubagentName(from: lines[index]) else { return false } return isPiAsyncSubagentJobLine(lines[index + 1], matching: headerName) }}
nonisolated private func piAsyncSubagentName(from header: String) -> (name: String, isPrefix: Bool)? { let prefix = "async subagent " let suffix = " · background" guard header.hasPrefix(prefix) else { return nil }
let title: Substring let isPrefix: Bool if header.hasSuffix(suffix) { title = header.dropFirst(prefix.count).dropLast(suffix.count) isPrefix = false } else { guard header.hasSuffix("…") else { return nil } let truncatedTitle = header.dropFirst(prefix.count).dropLast() if let separator = truncatedTitle.range(of: " ·") { title = truncatedTitle[..<separator.lowerBound] isPrefix = false } else { title = truncatedTitle isPrefix = true } }
let components = title.split(separator: " ") let countToken = components.last let completeCount = countToken?.dropFirst().dropLast() let hasCompleteCount = countToken?.first == "(" && countToken?.last == ")" && completeCount?.isEmpty == false && completeCount?.allSatisfy(\.isNumber) == true let partialCount = countToken?.dropFirst() let hasTruncatedCount = isPrefix && countToken?.first == "(" && countToken?.last != ")" && partialCount?.allSatisfy(\.isNumber) == true let hasTrailingCount = hasCompleteCount || hasTruncatedCount let name = hasTrailingCount ? components.dropLast().joined(separator: " ") : String(title) let nameIsPrefix = isPrefix && !hasTrailingCount return name.contains(where: \.isLetter) ? (name, nameIsPrefix) : nil}
nonisolated private func isPiAsyncSubagentJobLine( _ line: String, matching headerName: (name: String, isPrefix: Bool)) -> Bool { guard let content = labeledBrailleSpinnerContent(line), content.hasPrefix(headerName.name) else { return false } if headerName.isPrefix { return true }
let remainder = content.dropFirst(headerName.name.count) if remainder.isEmpty || remainder.hasPrefix(" ·") { return true } return [" [fresh]", " [fork]", " [mixed]"].contains { badge in remainder == badge || remainder.hasPrefix("\(badge) ·") }}
nonisolated private func isPiAdaptiveAsyncSubagentRunningLine(_ line: String) -> Bool { guard let content = labeledBrailleSpinnerContent(line) else { return false } if content.hasSuffix("…") { let visiblePrefix = content.dropLast() if visiblePrefix.hasPrefix("subagents (") || visiblePrefix.hasPrefix("Async agents · ") { return true } } if content == "Async agents · background" { return true } if content.hasPrefix("Async agents · ") { return isPiProgressiveAsyncSummary(String(content.dropFirst("Async agents · ".count))) } return isPiSingleLineAsyncSummary(content)}
nonisolated private func isPiProgressiveAsyncSummary(_ summary: String) -> Bool { let components = summary.split(separator: " ", maxSplits: 2) guard components.count == 3, let count = Int(components[0]), count > 0, components[1] == (count == 1 ? "agent" : "agents") else { return false }
let state = components[2] if state == "running" { return true } guard state.hasPrefix("running, ") else { return false } return isPiAsyncCountStatus(state.dropFirst("running, ".count), expected: "queued")}
nonisolated private func isPiSingleLineAsyncSummary(_ content: String) -> Bool { let prefix = "subagents (" guard content.hasPrefix(prefix), content.hasSuffix(")") else { return false }
let summary = content.dropFirst(prefix.count).dropLast() let components = summary.split(separator: ",", omittingEmptySubsequences: false) guard let runningSummary = components.first else { return false } let running = runningSummary.split(separator: " ") guard running.count == 2, running[1] == "running" else { return false }
let counts = running[0].split(separator: "/", omittingEmptySubsequences: false) guard counts.count == 2, let activeCount = Int(counts[0]), let totalCount = Int(counts[1]), activeCount > 0, totalCount >= activeCount else { return false }
let terminalStates = ["queued", "failed", "stopped", "paused"] return components.dropFirst().allSatisfy { component in terminalStates.contains { state in isPiAsyncCountStatus(component.drop(while: \.isWhitespace), expected: state) } }}
nonisolated private func isPiAsyncCountStatus(_ summary: Substring, expected: String) -> Bool { let components = summary.split(separator: " ") return components.count == 2 && Int(components[0]).map { $0 > 0 } == true && components[1] == Substring(expected)}
nonisolated private func detectOMP(_ content: String) -> AgentRawState { if hasOMPAskPrompt(content) { return .blocked } return hasOMPWorkingLine(content) ? .working : .idle}
nonisolated private func hasOMPAskPrompt(_ content: String) -> Bool { let lines = content.split(separator: "\n", omittingEmptySubsequences: false) return lines.contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) return trimmed.contains("Enter select") && trimmed.contains("Esc cancel") }}
nonisolated private func hasOMPWorkingLine(_ content: String) -> Bool { let lines = content.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } return lines.contains { line in isPiWorkingText(line) || hasOMPInterruptHint(line) || hasLabeledBrailleSpinner(line) } || lines.suffix(5).contains(where: hasOMPLeadingEscapeHint)}
nonisolated private func hasOMPLeadingEscapeHint(_ line: String) -> Bool { // OMP 18.1.10 puts its theme's Esc symbol before the loader message. let parts = line.split(maxSplits: 1, whereSeparator: \.isWhitespace) guard parts.count == 2, ["", "⎋", "esc"].contains(String(parts[0])) else { return false } return piWorkingMessages.contains(String(parts[1]))}
nonisolated private let piWorkingMessages: Set<String> = ["Working...", "Working…", "Interrupting…"]
nonisolated private func isPiWorkingText(_ line: String) -> Bool { if piWorkingMessages.contains(line) { return true }
guard let spinner = line.unicodeScalars.first, (0x2800...0x28FF).contains(Int(spinner.value)) else { return false } let message = String(line.unicodeScalars.dropFirst()).trimmingCharacters(in: .whitespaces) return piWorkingMessages.contains(message)}
nonisolated private func isPiFramedWorkingFooter(_ line: String) -> Bool { guard line.hasPrefix("── "), line.hasSuffix("──") else { return false } let content = line.trimmingCharacters(in: CharacterSet(charactersIn: "─ ")) return labeledBrailleSpinnerContent(content) == "Working"}
nonisolated private func hasOMPInterruptHint(_ line: String) -> Bool { let interruptHints = ["⟦esc⟧", "⟨esc⟩", "[esc]"] return interruptHints.contains { hint in guard line.hasSuffix(hint) else { return false } return !line.dropLast(hint.count).trimmingCharacters(in: .whitespaces).isEmpty }}
nonisolated private func hasLabeledBrailleSpinner(_ line: String) -> Bool { labeledBrailleSpinnerContent(line) != nil}
nonisolated private func labeledBrailleSpinnerContent(_ line: String) -> String? { guard let first = line.unicodeScalars.first, (0x2800...0x28FF).contains(Int(first.value)) else { return nil }
let rest = String(line.unicodeScalars.dropFirst()) guard rest.hasPrefix(" ") else { return nil } let content = rest.dropFirst().trimmingCharacters(in: .whitespaces) return content.contains(where: \.isLetter) ? content : nil}
nonisolated private func detectGemini(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("waiting for user confirmation") || content.contains("│ Apply this change") || content.contains("│ Allow execution") || content.contains("│ Do you want to proceed") || hasConfirmationPrompt(lower) { return .blocked } if lower.contains("esc to cancel") { return .working } return .idle}
nonisolated private func detectCursor(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("workspace trust required") || lower.contains("trust this workspace") || hasCursorPermissionPrompt(content: content, lower: lower) { return .blocked } if lower.contains("trusting workspace") || lower.contains("ctrl+c to stop") || hasCursorSpinner(content) { return .working } return .idle}
nonisolated private func detectCline(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("let cline use this tool") || ((lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes")) || hasClineNumberedChoicePrompt(content) { return .blocked } if hasInterruptPattern(lower) { return .working } return .idle}
nonisolated private func detectOpenCode(_ content: String) -> AgentRawState { if content.contains("△ Permission required") || hasOpenCodeQuestionPrompt(content) { return .blocked } if hasInterruptPattern(content.lowercased()) { return .working } return .idle}
nonisolated private func detectCopilot(_ content: String) -> AgentRawState { let lower = content.lowercased() let lines = lower.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } if hasCopilotSelectionPrompt(lines) || lower.contains("│ do you want") || (lower.contains("confirm with") && lower.contains("enter")) { return .blocked } let hasWorkingFooter = lines.suffix(5).contains(where: isCopilotWorkingFooter) if hasWorkingFooter || lower.contains("esc to cancel") { return .working } return .idle}
nonisolated private func isCopilotWorkingFooter(_ line: String) -> Bool { // Copilot 1.0.83 uses these frames in its normal and alternate-screen animations. // Streaming response size is optional and appears before the interrupt shortcut. let pattern = #"^[∙∘○◎◉]\s+working(?:\s+·\s+\d+(?:\.\d+)?\s+[kmgt]?i?b)?\s+esc\s+interrupt(?:\s|$)"# return line.range(of: pattern, options: .regularExpression) != nil}
nonisolated private func hasCopilotSelectionPrompt(_ lines: [String]) -> Bool { // The trust/permission picker shares "esc to cancel" with the legacy working footer. // Require live dialog chrome so a completed choice in transcript history does not block. guard lines.last?.hasPrefix("╰") == true, lines.suffix(3).contains(where: { $0.contains("enter to select") && $0.contains("esc to cancel") }) else { return false } return lines.suffix(16).contains { line in line.range(of: #"^│\s*❯\s*\d+\."#, options: .regularExpression) != nil }}
nonisolated private func detectKimi(_ content: String) -> AgentRawState { let lower = content.lowercased() let blockedPatterns = [ "allow?", "confirm?", "approve?", "proceed?", "[y/n]", "(y/n)", ] if blockedPatterns.contains(where: lower.contains) || hasConfirmationPrompt(lower) || hasKimiApprovalPanel(content: content, lower: lower) { return .blocked }
let workingPatterns = [ "thinking", "processing", "generating", "waiting for response", "ctrl+c to cancel", "ctrl-c to cancel", ] if workingPatterns.contains(where: lower.contains) || hasKimiMoonSpinner(content) || hasKimiToolSpinner(content: content, lower: lower) { return .working } return .idle}
nonisolated private func detectDroid(_ content: String) -> AgentRawState { let lower = content.lowercased() let hasExecute = content.contains("EXECUTE") let hasSelectionChrome = lower.contains("enter to select") || lower.contains("↑↓ to navigate") || lower.contains("esc to cancel") let hasSelectionOptions = lower.contains("> yes, allow") || lower.contains("> no, cancel")
if hasExecute && (hasSelectionChrome || hasSelectionOptions) { return .blocked } if hasSelectionChrome && hasSelectionOptions { return .blocked } if hasDroidSpinner(content) || lower.contains("esc to stop") { return .working } return .idle}
nonisolated private func detectAmp(_ content: String) -> AgentRawState { let lower = content.lowercased() let hasWaitingForApproval = lower.contains("waiting for approval") let hasApprovalHeader = lower.contains("invoke tool") || lower.contains("run this command?") || lower.contains("allow editing file:") || lower.contains("allow creating file:") || lower.contains("confirm tool call") let hasApprovalActions = lower.contains("approve") && (lower.contains("allow all for this session") || lower.contains("allow all for every session") || lower.contains("allow file for every session") || lower.contains("deny with feedback"))
if hasApprovalActions && (hasWaitingForApproval || hasApprovalHeader) { return .blocked } if lower.contains("esc to cancel") { return .working } return .idle}
nonisolated func isNumberedChoice(_ option: String) -> Bool { guard let firstToken = option.split(whereSeparator: { $0.isWhitespace }).first, firstToken.last == "." else { return false } let number = firstToken.dropLast() return !number.isEmpty && number.allSatisfy(\.isNumber)}
nonisolated private func hasCursorPermissionPrompt(content: String, lower: String) -> Bool { if lower.contains("(y) (enter)") { return true }
let hasPermissionHeader = lower.contains("run this command?") || lower.contains("run command?") || lower.contains("not in allowlist") || lower.contains("to allowlist?") || lower.contains("allow execution") guard hasPermissionHeader else { return false }
let hasConfirmAction = content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() guard trimmed.contains("(y)") else { return false } return trimmed.contains("run") || trimmed.contains("allow") } let hasCancelAction = lower.contains("skip (esc or n)") || lower.contains("keep (n)")
return hasConfirmAction || hasCancelAction}
nonisolated private func hasClineNumberedChoicePrompt(_ content: String) -> Bool { content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in guard let suffix = line.range(of: " or type)") else { return false } let prefix = line[..<suffix.lowerBound] guard let openParen = prefix.lastIndex(of: "(") else { return false } let between = prefix[prefix.index(after: openParen)...] return between.contains("-") && between.allSatisfy { $0.isNumber || $0 == "-" } }}
nonisolated private func hasKimiApprovalPanel(content: String, lower: String) -> Bool { lower.contains("requesting approval") || (lower.contains("approve once") && lower.contains("approve for this session") && lower.contains("reject")) || (content.contains("─ approval") && content.contains("↵ confirm"))}
nonisolated private func hasKimiMoonSpinner(_ content: String) -> Bool { let moonSpinners: Set<Character> = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"] return content.contains { moonSpinners.contains($0) }}
nonisolated private func hasKimiToolSpinner(content: String, lower: String) -> Bool { guard lower.contains("using ") else { return false } return content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let first = trimmed.unicodeScalars.first else { return false } return (0x2800...0x28FF).contains(Int(first.value)) }}
nonisolated func hasConfirmationPrompt(_ lower: String) -> Bool { guard let range = lower.range(of: "do you want") ?? lower.range(of: "would you like") else { return false } let after = lower[range.lowerBound...] return after.contains("yes") || after.contains("❯")}
nonisolated private func hasInterruptPattern(_ lower: String) -> Bool { lower.contains("esc to interrupt") || lower.contains("ctrl+c to interrupt") || (lower.contains("esc") && lower.contains("interrupt"))}
private nonisolated let agentSpinnerScalars: Set<UnicodeScalar> = [ "·", "✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", "❀", "❁", "❂", "❃", "❇", "❈", "❉", "❊", "❋", "✢", "✣", "✤", "✥", "✦", "✧", "✨", "⊛", "⊕", "⊙", "◉", "◎", "◍", "⁂", "⁕", "※", "⍟", "☼", "★", "☆",]
nonisolated func isAgentSpinnerScalar(_ scalar: UnicodeScalar) -> Bool { agentSpinnerScalars.contains(scalar)}
nonisolated func hasSpinnerActivity(_ content: String) -> Bool { content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let first = trimmed.unicodeScalars.first else { return false } let rest = String(trimmed.unicodeScalars.dropFirst()) return isAgentSpinnerScalar(first) && rest.hasPrefix(" ") && rest.contains("…") && rest.contains(where: \.isLetter) }}
nonisolated private func hasCursorSpinner(_ content: String) -> Bool { content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() return (trimmed.hasPrefix("⬡") || trimmed.hasPrefix("⬢")) && trimmed.contains("ing") }}
nonisolated private func hasDroidSpinner(_ content: String) -> Bool { content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() guard let first = trimmed.unicodeScalars.first else { return false } return (0x2800...0x28FF).contains(Int(first.value)) && trimmed.contains("esc to stop") }}
nonisolated private func hasOpenCodeQuestionPrompt(_ content: String) -> Bool { let lower = content.lowercased() let hasEnterAction = lower.contains("enter confirm") || lower.contains("enter submit") || lower.contains("enter toggle") let hasQuestionNavigation = content.contains("↑↓ select") || content.contains("⇆ tab")
return lower.contains("esc dismiss") && hasEnterAction && hasQuestionNavigation}
nonisolated private func detectQwen(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("waiting for user confirmation") || lower.contains("do you want to proceed?") || hasConfirmationPrompt(lower) { return .blocked } if lower.contains("esc to cancel") || lower.contains("ctrl+c to cancel") || hasBrailleSpinner(content) { return .working } return .idle}
// Qoder CLI (verified 1.0.48, live session): blocked dialogs are ephemeral// overlays that vanish once answered, so full-window matching is safe.// Permission menus always pair "Allow once" with "Reject and type something"// (the second row varies by tool: "Allow for this session" for edits,// "Always allow \"<cmd>\" for future sessions" for exec/MCP). Ask-user// questions render an "Asking User" header with a fixed "Type Something"// row; the plan-ready dialog pairs "Yes, start executing" with "Reject// plan". An active turn shows a braille spinner footer ending in// "(esc to cancel, <elapsed>)". Multi-token matches only — single labels// like "Allow once" or "No" can appear in transcript text.nonisolated private func detectQoder(_ content: String) -> AgentRawState { let lower = content.lowercased() if hasQoderPermissionMenu(lower) || hasQoderQuestionPrompt(lower) || hasQoderPlanReadyPrompt(lower) { return .blocked } if hasQoderWorkingFooter(content) { return .working } return .idle}
nonisolated private func hasQoderPermissionMenu(_ lower: String) -> Bool { lower.contains("allow once") && lower.contains("reject and type something")}
nonisolated private func hasQoderQuestionPrompt(_ lower: String) -> Bool { lower.contains("asking user") && lower.contains("type something")}
nonisolated private func hasQoderPlanReadyPrompt(_ lower: String) -> Bool { lower.contains("yes, start executing") && lower.contains("reject plan")}
nonisolated private func hasQoderWorkingFooter(_ content: String) -> Bool { content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let first = trimmed.unicodeScalars.first else { return false } return (0x2800...0x28FF).contains(Int(first.value)) && trimmed.lowercased().contains("(esc to cancel,") }}
// Grok Build (verified 0.2.101): cancel mid-turn is Ctrl+C (Esc is a no-op).// Approval dialogs pair numbered yes-rows ("Yes, proceed", "Yes, allow all// edits during this session") with a reject row ("No, reject (type to add// feedback)") above a "Ctrl+o:always-approve" footer; ask-user dialogs render// "Waiting on answers for …" with a "Type your answer here" row. Working// turns show a braille spinner line ("⠧ Thinking… 0.2s"). Multi-token matches// only — single words like "approve" or "loading" appear in transcript text.nonisolated private func detectGrok(_ content: String) -> AgentRawState { if hasGrokPermissionPrompt(content) || hasGrokQuestionPrompt(content) { return .blocked } if hasGrokWorkingSignal(content) { return .working } return .idle}
nonisolated private func hasGrokPermissionPrompt(_ content: String) -> Bool { let lower = content.lowercased() // Tool approval dialog (verified on-screen, 0.2.101): a yes-row and a // reject-row are always rendered together. Requiring the pair keeps // transcript prose containing one of the phrases from matching. let hasApprovalYesRow = lower.contains("yes, proceed") || lower.contains("yes, allow") || lower.contains("yes, always allow") || lower.contains("(always-approve mode)") let hasApprovalNoRow = lower.contains("no, reject") || lower.contains("no, and tell grok") if hasApprovalYesRow && hasApprovalNoRow { return true } // Footer rendered only while an approval dialog is pending. if lower.contains("ctrl+o:always-approve") { return true } let hasAllowOnce = lower.contains("allow once") let hasAlwaysAllow = lower.contains("always allow this command") || lower.contains("always allow on all sessions") || lower.contains("always allow this exact command") let hasReject = lower.contains("reject") if hasAllowOnce && (hasAlwaysAllow || hasReject) { return true } if hasAlwaysAllow && hasReject { return true } if lower.contains("yes, and always allow this exact command") || lower.contains("yes, allow all edits") { return true } return false}
nonisolated private func hasGrokQuestionPrompt(_ content: String) -> Bool { let lower = content.lowercased() // Ask-user dialog (verified on-screen, 0.2.101): "◆ Waiting on answers for // <question>" header plus a free-form "z (○) Type your answer here" row. return lower.contains("waiting on answers for") || lower.contains("type your answer here") || lower.contains("pending: question") || lower.contains("pending: other (type your own answer") || (lower.contains("awaiting your input") && (lower.contains("?") || lower.contains("select") || lower.contains("enter")))}
nonisolated private func hasGrokWorkingSignal(_ content: String) -> Bool { let lower = content.lowercased() if lower.contains("tool calls in flight") || lower.contains("working tools") || lower.contains("still running:") { return true } // Status flag rendered while a turn is in progress (distinct from the // idle "Awaiting input" / "Awaiting your input" flags). if content.split(separator: "\n", omittingEmptySubsequences: false).contains(where: { line in let trimmed = line.trimmingCharacters(in: .whitespaces) return trimmed == "Loading" || trimmed.hasPrefix("Loading…") || trimmed.hasPrefix("Loading...") }) { return true } if hasBrailleSpinner(content) || hasSpinnerActivity(content) { return true } return false}
nonisolated private func hasBrailleSpinner(_ content: String) -> Bool { content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let first = trimmed.unicodeScalars.first else { return false } return (0x2800...0x28FF).contains(Int(first.value)) && trimmed.contains(where: \.isLetter) }}