From 979deadf85eefb87f39c9e23d934c7b84c14bd36 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 20 Jun 2026 09:31:46 +0800 Subject: [PATCH] Add Qwen Code agent detection support Recognize Qwen Code (`qwen`) as a detectable coding agent: - Process probe: match argv0 `qwen` in the classifier - Screen heuristics: detect working/blocked/idle states via `esc to cancel`, `ctrl+c to cancel`, and Braille spinner - Unit tests for process identification and screen state detection - Update agent-detection docs to list Qwen Code Signed-off-by: Alex --- docs/components/agent-detection.md | 3 +- .../Domain/AgentDetection/DetectedAgent.swift | 1 + .../AgentDetection/AgentClassifier.swift | 2 ++ .../AgentDetection/ScreenHeuristics.swift | 28 +++++++++++++++++ supacodeTests/AgentClassifierTests.swift | 1 + supacodeTests/ScreenHeuristicsTests.swift | 30 +++++++++++++++++++ 6 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 56833727..e115784e 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -17,7 +17,8 @@ running and what state it's in. That signal drives the ## Agents it recognizes Claude (Claude Code), Codex, Gemini, Cursor, Cline, OpenCode, GitHub Copilot, -Kimi, Droid, Amp, Pi (`pi`), and Oh My Pi (`omp`, `oh-my-pi`). Detection covers +Kimi, Droid, Amp, Pi (`pi`), Oh My Pi (`omp`, `oh-my-pi`), and Qwen Code (`qwen`). +Detection covers common wrappers (node, python, bun, bash, etc.) so agents launched indirectly are still found. Oh My Pi reuses Pi-derived screen heuristics but uses its own command icon where Prowl shows detected command icons, including terminal tabs. diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift index 1872ca58..e40c2845 100644 --- a/supacode/Domain/AgentDetection/DetectedAgent.swift +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -13,6 +13,7 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { case kimi case droid case amp + case qwen var id: String { rawValue } diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 5540684b..76eb2ddd 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -24,6 +24,8 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .droid case "amp", "amp-local": return .amp + case "qwen": + return .qwen default: return nil } diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 2094ee35..8a3c9f25 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -28,6 +28,8 @@ extension DetectedAgent { return detectDroid(screen) case .amp: return detectAmp(screen) + case .qwen: + return detectQwen(screen) } } } @@ -496,3 +498,29 @@ nonisolated private func hasOpenCodeQuestionPrompt(_ content: String) -> Bool { 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 +} + +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) + } +} diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index c92bc47a..a27ce770 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -23,6 +23,7 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "droid") == .droid) #expect(identifyAgent(processName: "amp") == .amp) #expect(identifyAgent(processName: "amp-local") == .amp) + #expect(identifyAgent(processName: "qwen") == .qwen) } @Test func identifiesOhMyPiCommandNames() throws { diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 3674ab71..1e2c4e6a 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -510,4 +510,34 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.amp.detectState(in: "esc to cancel") == .working) #expect(DetectedAgent.amp.detectState(in: "done") == .idle) } + + @Test func qwenDetection() { + #expect(DetectedAgent.qwen.detectState(in: "Waiting for user confirmation...") == .blocked) + #expect( + DetectedAgent.qwen.detectState( + in: """ + Do you want to proceed? + > Yes, allow once + Always allow in this project + No (esc) + """ + ) == .blocked + ) + #expect( + DetectedAgent.qwen.detectState( + in: """ + ┌─ Shell Command Execution ──────────────────────┐ + │ Do you want to proceed? │ + │ > Yes, allow once │ + │ No (esc) │ + └────────────────────────────────────────────────┘ + """ + ) == .blocked + ) + #expect(DetectedAgent.qwen.detectState(in: "⠏ I'm Feeling Lucky (5s · esc to cancel)") == .working) + #expect(DetectedAgent.qwen.detectState(in: "Thinking... (12s · ctrl+c to cancel)") == .working) + #expect(DetectedAgent.qwen.detectState(in: "⠸ Writing file... (3s · ↓ 200 tokens · esc to cancel)") == .working) + #expect(DetectedAgent.qwen.detectState(in: "done") == .idle) + #expect(DetectedAgent.qwen.detectState(in: "> Type your message") == .idle) + } } -- 2.51.2