From f6efb2eca9a7dccc0c877fc21f79f93fe42b26c8 Mon Sep 17 00:00:00 2001 From: Kody Wildfeuer <1735900+kody-w@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:49:12 -0400 Subject: [PATCH 1/3] Ignore duplicate agent terminal launches within 3s A double-click on "Setup with AI" during ASC onboarding launched the same claude session in two Terminal windows, so the asc-team-key-create skill ran twice and created two API keys. TerminalLauncher.launch runs osascript synchronously on the main thread with no in-flight guard, so a second click is queued and replayed right after the first launch returns. Dedupe identical launch commands within a 3s window measured from the end of the launch. Co-Authored-By: Claude Opus 5.5 --- src/services/TerminalLauncher.swift | 39 ++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/services/TerminalLauncher.swift b/src/services/TerminalLauncher.swift index 562916d..eb977c7 100644 --- a/src/services/TerminalLauncher.swift +++ b/src/services/TerminalLauncher.swift @@ -3,6 +3,14 @@ import CoreServices /// Launches the user's configured terminal with an AI agent CLI command. enum TerminalLauncher { + /// Identical launches inside this window are treated as one (e.g. a double-click + /// on a "Setup with AI" button). The osascript launch blocks the main thread, so a + /// second click is queued and replayed right after the first launch returns — + /// without this guard it opens a second terminal running the same agent prompt. + private static let duplicateLaunchWindow: TimeInterval = 3 + private static let lastLaunchLock = NSLock() + private static var lastLaunch: (command: String, date: Date)? + static func buildAgentCommand( projectPath: String?, agent: AIAgent, @@ -68,9 +76,19 @@ enum TerminalLauncher { skipPermissions: skipPermissions ) + if case .builtIn = terminal.resolvedFallback { + return false // Handled by ContentView directly + } + guard claimLaunch(of: shellCommand) else { + return true // Same agent session was just launched; don't open it twice. + } + // Measure the window from when the (blocking) launch finishes, so a click + // queued behind a slow osascript call is still recognised as a duplicate. + defer { refreshLaunch(of: shellCommand) } + switch terminal.resolvedFallback { case .builtIn: - return false // Handled by ContentView directly + return false case .terminal: return launchTerminalApp(command: shellCommand) case .ghostty: @@ -191,6 +209,25 @@ enum TerminalLauncher { // MARK: - Helpers + /// Returns false if the identical command was launched within `duplicateLaunchWindow`. + private static func claimLaunch(of command: String, now: Date = Date()) -> Bool { + lastLaunchLock.lock() + defer { lastLaunchLock.unlock() } + if let last = lastLaunch, + last.command == command, + now.timeIntervalSince(last.date) < duplicateLaunchWindow { + return false + } + lastLaunch = (command, now) + return true + } + + private static func refreshLaunch(of command: String) { + lastLaunchLock.lock() + defer { lastLaunchLock.unlock() } + lastLaunch = (command, Date()) + } + private static func escapeForAppleScript(_ command: String) -> String { command .replacingOccurrences(of: "\\", with: "\\\\") From f6e214fa9ce2e0520ad7b46e699c0fcc202edc51 Mon Sep 17 00:00:00 2001 From: Kody Wildfeuer <1735900+kody-w@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:49:12 -0400 Subject: [PATCH 2/3] Don't swallow a retry after a failed agent terminal launch The duplicate-launch guard recorded every launch, including failures, so retrying within 3s after a failed launch was skipped and reported as success. Forget the launch when it fails. Co-Authored-By: Claude Opus 5.5 --- src/services/TerminalLauncher.swift | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/services/TerminalLauncher.swift b/src/services/TerminalLauncher.swift index eb977c7..36093d5 100644 --- a/src/services/TerminalLauncher.swift +++ b/src/services/TerminalLauncher.swift @@ -82,22 +82,24 @@ enum TerminalLauncher { guard claimLaunch(of: shellCommand) else { return true // Same agent session was just launched; don't open it twice. } - // Measure the window from when the (blocking) launch finishes, so a click - // queued behind a slow osascript call is still recognised as a duplicate. - defer { refreshLaunch(of: shellCommand) } - + let launched: Bool switch terminal.resolvedFallback { case .builtIn: - return false + launched = false case .terminal: - return launchTerminalApp(command: shellCommand) + launched = launchTerminalApp(command: shellCommand) case .ghostty: - return launchGhostty(command: shellCommand) + launched = launchGhostty(command: shellCommand) case .iterm: - return launchITerm(command: shellCommand) + launched = launchITerm(command: shellCommand) case .custom(let path): - return launchCustom(appPath: path, command: shellCommand) + launched = launchCustom(appPath: path, command: shellCommand) } + // Measure the window from when the (blocking) launch finishes, so a click + // queued behind a slow osascript call is still recognised as a duplicate. + // A failed launch is forgotten so an immediate retry isn't swallowed. + finishLaunch(of: shellCommand, succeeded: launched) + return launched } /// Launch using the settings from SettingsService @@ -222,10 +224,10 @@ enum TerminalLauncher { return true } - private static func refreshLaunch(of command: String) { + private static func finishLaunch(of command: String, succeeded: Bool) { lastLaunchLock.lock() defer { lastLaunchLock.unlock() } - lastLaunch = (command, Date()) + lastLaunch = succeeded ? (command, Date()) : nil } private static func escapeForAppleScript(_ command: String) -> String { From 2ce8a7f545c22e5379e84c28065943cd2d82bac6 Mon Sep 17 00:00:00 2001 From: Kody Wildfeuer <1735900+kody-w@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:49:12 -0400 Subject: [PATCH 3/3] Test the duplicate agent launch guard Covers: a replayed click inside the window is skipped, the same command is allowed after the window, a failed launch doesn't block a retry, and a different command is never blocked. Verified the test fails when the failed-launch reset is removed. Co-Authored-By: Claude Opus 5.5 --- ...TerminalLauncherDuplicateLaunchTests.swift | 25 +++++++++++++++++++ src/services/TerminalLauncher.swift | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 Tests/blitz_tests/TerminalLauncherDuplicateLaunchTests.swift diff --git a/Tests/blitz_tests/TerminalLauncherDuplicateLaunchTests.swift b/Tests/blitz_tests/TerminalLauncherDuplicateLaunchTests.swift new file mode 100644 index 0000000..330cfa2 --- /dev/null +++ b/Tests/blitz_tests/TerminalLauncherDuplicateLaunchTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import Blitz + +// One test so the shared launch record isn't touched by parallel tests. +@Test func testDuplicateAgentLaunchGuard() { + let command = "claude 'dup-guard \(UUID().uuidString)'" + + #expect(TerminalLauncher.claimLaunch(of: command, now: Date())) + TerminalLauncher.finishLaunch(of: command, succeeded: true) + + // A queued second click replayed right after the launch returns is skipped. + #expect(!TerminalLauncher.claimLaunch(of: command, now: Date())) + + // The same command is allowed again once the window has passed. + #expect(TerminalLauncher.claimLaunch(of: command, now: Date().addingTimeInterval(4))) + + // A failed launch is forgotten, so an immediate retry goes through. + TerminalLauncher.finishLaunch(of: command, succeeded: false) + #expect(TerminalLauncher.claimLaunch(of: command, now: Date())) + TerminalLauncher.finishLaunch(of: command, succeeded: true) + + // A different command is never blocked by the last one. + #expect(TerminalLauncher.claimLaunch(of: command + " other", now: Date())) +} diff --git a/src/services/TerminalLauncher.swift b/src/services/TerminalLauncher.swift index 36093d5..2d65bb0 100644 --- a/src/services/TerminalLauncher.swift +++ b/src/services/TerminalLauncher.swift @@ -212,7 +212,7 @@ enum TerminalLauncher { // MARK: - Helpers /// Returns false if the identical command was launched within `duplicateLaunchWindow`. - private static func claimLaunch(of command: String, now: Date = Date()) -> Bool { + static func claimLaunch(of command: String, now: Date = Date()) -> Bool { lastLaunchLock.lock() defer { lastLaunchLock.unlock() } if let last = lastLaunch, @@ -224,7 +224,7 @@ enum TerminalLauncher { return true } - private static func finishLaunch(of command: String, succeeded: Bool) { + static func finishLaunch(of command: String, succeeded: Bool) { lastLaunchLock.lock() defer { lastLaunchLock.unlock() } lastLaunch = succeeded ? (command, Date()) : nil