diff --git a/CHANGELOG.md b/CHANGELOG.md index 3119e10..6beaf4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,9 @@ Cutting that release is tracked in ### Changed +- macOS agent startup now opens browser windows without activating Headless or + covering the user's current app. The startup presentation can be configured + persistently or overridden for one launch. - Product versions now come from the release tag at build time and are reported consistently by `headless --version`, host `ping`, MCP `serverInfo`, package metadata, and the website. Release notes are generated diff --git a/README.md b/README.md index 0d496f0..a74d4f5 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,14 @@ headless --session qa styles get --role button --name Continue --property displa headless artifacts list ``` +On macOS, agent startup opens visible browser windows behind the app currently +in use. Change the persistent default with `headless config set +startup-presentation foreground` or restore background startup with `headless +config set startup-presentation background`; inspect it with `headless config +get startup-presentation`. `headless start --foreground` and `headless start +--background` are one-launch overrides. Settings and overrides apply only when +launching a new host and do not reorder an already-running host. + Inspection is progressively disclosed instead of forcing an entire page into an agent prompt. Start with `--context summary`, use `--context outline` to receive structural region references such as `@r4`, then inspect only that region with diff --git a/apps/headless/Sources/HeadlessCLI/main.swift b/apps/headless/Sources/HeadlessCLI/main.swift index 816cee6..f2d45b9 100644 --- a/apps/headless/Sources/HeadlessCLI/main.swift +++ b/apps/headless/Sources/HeadlessCLI/main.swift @@ -16,6 +16,52 @@ private func printResponse(_ response: CommandResponse) throws { FileHandle.standardOutput.write(try ProtocolCodec.encodeLine(response)) } +private enum StartupPresentationPreference { + static let builtInDefault = AgentStartupPresentation.background + private static let domain = "com.headless.app" + private static let key = "AgentStartupPresentation" + + static var configured: AgentStartupPresentation? { + guard let defaults = UserDefaults(suiteName: domain), + let value = defaults.string(forKey: key) else { return nil } + return AgentStartupPresentation(rawValue: value) + } + + static var effective: AgentStartupPresentation { + configured ?? builtInDefault + } + + static func requireSupportedPlatform() throws { + #if !os(macOS) + throw StartupPresentationPreferenceError.unsupported + #endif + } + + static func set(_ presentation: AgentStartupPresentation) throws { + guard let defaults = UserDefaults(suiteName: domain) else { + throw StartupPresentationPreferenceError.unavailable + } + defaults.set(presentation.rawValue, forKey: key) + guard defaults.synchronize() else { + throw StartupPresentationPreferenceError.writeFailed + } + } +} + +private enum StartupPresentationPreferenceError: Error, Equatable, CustomStringConvertible { + case unsupported + case unavailable + case writeFailed + + var description: String { + switch self { + case .unsupported: return "Startup presentation preferences are supported only on macOS." + case .unavailable: return "Could not open the Headless preferences domain." + case .writeFailed: return "Could not persist the startup presentation preference." + } + } +} + private struct HostLauncher { let client = LocalSocketClient() @@ -23,7 +69,10 @@ private struct HostLauncher { try? client.send(CommandRequest(command: .ping), timeout: 0.5) } - func start() throws -> CommandResponse { + func start(presentation: AgentStartupPresentation? = nil) throws -> CommandResponse { + #if !os(macOS) + if presentation != nil { throw StartupPresentationPreferenceError.unsupported } + #endif if let response = ping(), response.ok { return response } #if os(Linux) // Report an unsupported browser directly to the operator instead of @@ -36,6 +85,12 @@ private struct HostLauncher { process.arguments = [] var environment = ProcessInfo.processInfo.environment environment["HEADLESS_AGENT_HOST"] = "1" + #if os(macOS) + let effectivePresentation = presentation ?? StartupPresentationPreference.effective + #else + let effectivePresentation = AgentStartupPresentation.background + #endif + environment["HEADLESS_START_FOREGROUND"] = effectivePresentation == .foreground ? "1" : "0" process.environment = environment process.standardInput = FileHandle.nullDevice if let hostLog = environment["HEADLESS_HOST_LOG"], hostLog.hasPrefix("/") { @@ -120,8 +175,23 @@ do { "supported": .bool(true), "transport": .string("native-webkit"), ])) #endif - case .start: - try printResponse(try HostLauncher().start()) + case .start(let presentation): + try printResponse(try HostLauncher().start(presentation: presentation)) + case .getStartupPresentation: + try StartupPresentationPreference.requireSupportedPlatform() + let configured = StartupPresentationPreference.configured + printJSON(.object([ + "builtInDefault": .string(StartupPresentationPreference.builtInDefault.rawValue), + "configured": configured.map { .string($0.rawValue) } ?? .null, + "startupPresentation": .string(StartupPresentationPreference.effective.rawValue), + ])) + case .setStartupPresentation(let presentation): + try StartupPresentationPreference.requireSupportedPlatform() + try StartupPresentationPreference.set(presentation) + printJSON(.object([ + "startupPresentation": .string(presentation.rawValue), + "takesEffect": .string("next-host-start"), + ])) } } else if let request = invocation.request { let launcher = HostLauncher() @@ -164,6 +234,14 @@ do { ) try? printResponse(response) exit(69) +} catch let error as StartupPresentationPreferenceError { + let response = CommandResponse.failure( + id: "unknown", + code: error == .unsupported ? "UNSUPPORTED_CAPABILITY" : "CONFIGURATION_FAILED", + message: error.description + ) + try? printResponse(response) + exit(69) } catch { fputs("headless: \(error)\n", stderr) exit(70) diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index e4232f2..e5df7cd 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -1,11 +1,18 @@ import Foundation +public enum AgentStartupPresentation: String, Equatable, Sendable { + case background + case foreground +} + public enum LocalCommand: Equatable, Sendable { case help case version case capabilities case runtime - case start + case start(presentation: AgentStartupPresentation?) + case getStartupPresentation + case setStartupPresentation(AgentStartupPresentation) } public struct CLIInvocation: Equatable, Sendable { @@ -85,8 +92,30 @@ public struct CLIParser { try requireEmpty(arguments) return CLIInvocation(local: .runtime, jsonOutput: true) case "start": - try requireEmpty(arguments) - return CLIInvocation(local: .start, jsonOutput: jsonOutput) + switch arguments { + case []: + return CLIInvocation(local: .start(presentation: nil), jsonOutput: jsonOutput) + case ["--background"]: + return CLIInvocation(local: .start(presentation: .background), jsonOutput: jsonOutput) + case ["--foreground"]: + return CLIInvocation(local: .start(presentation: .foreground), jsonOutput: jsonOutput) + default: + throw CLIParseError.invalidOption(arguments.first ?? "start") + } + case "config": + switch arguments { + case ["get", "startup-presentation"]: + return CLIInvocation(local: .getStartupPresentation, jsonOutput: true) + case let values where values.count == 3 + && values[0] == "set" && values[1] == "startup-presentation": + let value = values[2] + guard let presentation = AgentStartupPresentation(rawValue: value) else { + throw CLIParseError.invalidOption(value) + } + return CLIInvocation(local: .setStartupPresentation(presentation), jsonOutput: true) + default: + throw CLIParseError.invalidOption(arguments.first ?? "config") + } case "status": try requireEmpty(arguments) return remote(.ping, session: session, jsonOutput: jsonOutput) @@ -643,7 +672,9 @@ Core workflow: Commands: version | --version - start | status | stop | runtime + start [--background|--foreground] | status | stop | runtime + config get startup-presentation + config set startup-presentation background|foreground session create [NAME] | session list | session close NAME visit URL inspect [--context summary|outline|text|actions|full] [--task TEXT] diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 91f3197..304b5cd 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -722,7 +722,12 @@ struct ProtocolTests { } let localCommands: [([String], LocalCommand)] = [ - (["start"], .start), + (["start"], .start(presentation: nil)), + (["start", "--background"], .start(presentation: .background)), + (["start", "--foreground"], .start(presentation: .foreground)), + (["config", "get", "startup-presentation"], .getStartupPresentation), + (["config", "set", "startup-presentation", "background"], .setStartupPresentation(.background)), + (["config", "set", "startup-presentation", "foreground"], .setStartupPresentation(.foreground)), (["help"], .help), (["--help"], .help), (["version"], .version), @@ -736,6 +741,15 @@ struct ProtocolTests { "\(arguments.joined(separator: " ")) should stay local" ) } + try expectThrows("start presentation flags must be exclusive") { + _ = try CLIParser().parse(["start", "--foreground", "--background"]) + } + try expectThrows("start should reject unknown options") { + _ = try CLIParser().parse(["start", "--front"]) + } + try expectThrows("startup presentation should reject unknown values") { + _ = try CLIParser().parse(["config", "set", "startup-presentation", "automatic"]) + } let sessionCreate = try CLIParser().parse(["session", "create", "qa"]) try expect(sessionCreate.request?.parameters["name"] == .string("qa"), "session create name should parse") diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index 47ca8c3..5e5ed0f 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -52,7 +52,23 @@ if RELATIVE_RUNTIME="$(HEADLESS_CHROMIUM_EXECUTABLE=relative/chromium headless r fi echo "$RELATIVE_RUNTIME" | grep -q 'must be absolute' +if PRESENTATION_CONFIG="$(headless config get startup-presentation 2>&1)"; then + echo "macOS startup presentation configuration was accepted on Linux" >&2 + exit 1 +fi +echo "$PRESENTATION_CONFIG" | grep -q 'UNSUPPORTED_CAPABILITY' +if PRESENTATION_START="$(headless start --foreground 2>&1)"; then + echo "macOS startup presentation override was accepted on Linux" >&2 + exit 1 +fi +echo "$PRESENTATION_START" | grep -q 'UNSUPPORTED_CAPABILITY' + headless start | grep -q '"ready":true' +if RUNNING_PRESENTATION_START="$(headless start --foreground 2>&1)"; then + echo "macOS startup presentation override was accepted by a running Linux host" >&2 + exit 1 +fi +echo "$RUNNING_PRESENTATION_START" | grep -q 'UNSUPPORTED_CAPABILITY' # The fixture server is the only TCP listener. Chromium control must stay on # its inherited DevTools pipe rather than exposing a loopback debugging port. diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 4684ec2..cef244f 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -2,7 +2,7 @@ set -euo pipefail cd "${0:a:h}/.." -for tool in node curl defaults lsof; do +for tool in node curl defaults lsof osascript; do command -v "$tool" >/dev/null 2>&1 || { echo "macOS E2E tests require $tool" >&2; exit 69; } done @@ -25,11 +25,17 @@ export HEADLESS_HOST_LOG="$HOST_LOG" STEP="boot" RESTORE_PID="" DEFAULTS_DOMAIN="com.headless.app" +PRESENTATION_KEY="AgentStartupPresentation" DEFAULTS_HAD_LAST_URL=0 DEFAULTS_LAST_URL="" +DEFAULTS_HAD_PRESENTATION=0 +DEFAULTS_PRESENTATION="" if DEFAULTS_LAST_URL="$(defaults read "$DEFAULTS_DOMAIN" LastURL 2>/dev/null)"; then DEFAULTS_HAD_LAST_URL=1 fi +if DEFAULTS_PRESENTATION="$(defaults read "$DEFAULTS_DOMAIN" "$PRESENTATION_KEY" 2>/dev/null)"; then + DEFAULTS_HAD_PRESENTATION=1 +fi restore_last_url() { if [[ "$DEFAULTS_HAD_LAST_URL" == 1 ]]; then @@ -39,6 +45,14 @@ restore_last_url() { fi } +restore_startup_presentation() { + if [[ "$DEFAULTS_HAD_PRESENTATION" == 1 ]]; then + defaults write "$DEFAULTS_DOMAIN" "$PRESENTATION_KEY" -string "$DEFAULTS_PRESENTATION" + else + defaults delete "$DEFAULTS_DOMAIN" "$PRESENTATION_KEY" >/dev/null 2>&1 || true + fi +} + fail() { trap - ERR print -r -u2 -- "macOS E2E failed during: $STEP" @@ -52,6 +66,11 @@ fail() { } trap 'fail' ERR +frontmost_pid() { + osascript -l JavaScript \ + -e 'ObjC.import("AppKit"); Number($.NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier)' +} + node Tests/fixture-server.mjs >"$LOG" 2>&1 & FIXTURE_PID=$! @@ -62,6 +81,7 @@ cleanup() { fi kill "$FIXTURE_PID" >/dev/null 2>&1 || true restore_last_url + restore_startup_presentation rm -rf "$HEADLESS_ARTIFACT_DIR" rm -f "$LOG" "$HOST_LOG" "$RESTORE_LOG" } @@ -120,6 +140,8 @@ restore_last_url echo "▸ unavailable restored URL fell back to the start page" STEP="start-host" +"$CLI" config set startup-presentation background | grep -q '"startupPresentation":"background"' +"$CLI" config get startup-presentation | grep -q '"startupPresentation":"background"' START_RESULT="$("$CLI" start)" || { print -r -u2 -- "headless start failed:" print -r -u2 -- "$START_RESULT" @@ -133,6 +155,16 @@ echo "▸ host ready" STEP="tcp-check" HOST_PID="$(echo "$START_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$HOST_PID" +if [[ "$(frontmost_pid)" == "$HOST_PID" ]]; then + echo "default agent startup stole focus" >&2 + fail +fi +RUNNING_START_RESULT="$("$CLI" start --foreground)" +echo "$RUNNING_START_RESULT" | grep -q "\"pid\":$HOST_PID" +if [[ "$(frontmost_pid)" == "$HOST_PID" ]]; then + echo "a launch-time foreground flag reordered an existing host" >&2 + fail +fi if lsof -nP -a -p "$HOST_PID" -iTCP -sTCP:LISTEN 2>/dev/null | grep -q LISTEN; then echo "Headless host opened an unexpected TCP listener" >&2 fail @@ -144,6 +176,10 @@ HEADLESS_CONFORMANCE_BASE_URL="http://127.0.0.1:$PORT" \ Tests/conformance.sh STEP="session-visit" "$CLI" session create qa | grep -q '"session":"qa"' +if [[ "$(frontmost_pid)" == "$HOST_PID" ]]; then + echo "agent session creation stole focus" >&2 + fail +fi "$CLI" session list | grep -q '"qa"' "$CLI" --session qa visit "http://127.0.0.1:$PORT/designers/dashboard" | grep -q 'Designers Dashboard' STEP="inspect-diagnostics" @@ -341,4 +377,43 @@ if echo "$HOSTILE_DIAGNOSTICS" | grep -q 'hostile-claims-trusted'; then fi "$CLI" session close qa | grep -q '"closed":"qa"' +STEP="configured-foreground-start" +"$CLI" stop >/dev/null +for _ in {1..100}; do + ! "$CLI" status >/dev/null 2>&1 && break + sleep 0.05 +done +"$CLI" config set startup-presentation foreground | grep -q '"startupPresentation":"foreground"' +"$CLI" config get startup-presentation | grep -q '"startupPresentation":"foreground"' +FOREGROUND_RESULT="$("$CLI" start)" +FOREGROUND_PID="$(echo "$FOREGROUND_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$FOREGROUND_PID" +FOREGROUND_ACTIVE=0 +for _ in {1..100}; do + if [[ "$(frontmost_pid)" == "$FOREGROUND_PID" ]]; then + FOREGROUND_ACTIVE=1 + break + fi + sleep 0.05 +done +if [[ "$FOREGROUND_ACTIVE" != 1 ]]; then + echo "configured foreground startup did not activate Headless" >&2 + fail +fi +"$CLI" stop >/dev/null +for _ in {1..100}; do + ! "$CLI" status >/dev/null 2>&1 && break + sleep 0.05 +done + +STEP="background-override" +BACKGROUND_RESULT="$("$CLI" start --background)" +BACKGROUND_PID="$(echo "$BACKGROUND_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$BACKGROUND_PID" +if [[ "$(frontmost_pid)" == "$BACKGROUND_PID" ]]; then + echo "background launch override did not override the configured foreground default" >&2 + fail +fi +"$CLI" stop >/dev/null + echo "macOS P2 end-to-end flow passed" diff --git a/apps/headless/docs/P0.md b/apps/headless/docs/P0.md index 3827dc0..e19bb57 100644 --- a/apps/headless/docs/P0.md +++ b/apps/headless/docs/P0.md @@ -27,6 +27,17 @@ local Unix socket. Both browser engines use the same command contract: P0 opens no TCP listener or Chromium debugger port. Remote control is deferred until it has authentication, authorization, and transport security. +## Window presentation + +On macOS, CLI and automatic agent startup show browser windows without +activating Headless, so the user's current app stays in front. `headless config +set startup-presentation foreground|background` changes the persistent default, +and `headless config get startup-presentation` reports the effective value. +`headless start --foreground` and `--background` override that default for one +new host. Settings and overrides never reorder an already-running host. Direct +GUI launches and windows created from the app's menu retain normal foreground +behavior. Startup presentation configuration is unsupported on Linux. + ## Security boundaries - Socket access is limited to the current operating-system user. diff --git a/apps/headless/main.swift b/apps/headless/main.swift index a136211..52c803b 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -37,6 +37,16 @@ let hasPasskeyEntitlement: Bool = { /// browsed page (including a local file) into the agent's default session. let isAgentHost = ProcessInfo.processInfo.environment["HEADLESS_AGENT_HOST"] == "1" +private enum WindowPresentation { + case foreground + case background +} + +private let agentWindowPresentation: WindowPresentation = + isAgentHost && ProcessInfo.processInfo.environment["HEADLESS_START_FOREGROUND"] != "1" + ? .background + : .foreground + // MARK: - URL smarts func smartURL(_ input: String) -> URL? { @@ -842,14 +852,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { restoredStartupURL: restoredStartupURL, size: launchOptions.size, snap: launchOptions.snap, - isPrimary: true + isPrimary: true, + presentation: agentWindowPresentation ) let engine = WebKitBrowserEngine( create: { [weak self] in guard let self else { throw HostError(code: .operationFailed, message: "Headless host is stopping.") } - return onAgentMain { self.openWindow(url: nil) } + return onAgentMain { + self.openWindow(url: nil, presentation: agentWindowPresentation) + } }, close: { controller in onAgentMain { controller.close() } } ) @@ -869,8 +882,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } catch { fputs("headless: agent socket failed: \(error)\n", stderr) } - NSApp.activate(ignoringOtherApps: true) - if launchOptions.snap != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 30) { fputs("headless: --snap timed out\n", stderr) @@ -880,12 +891,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } @discardableResult - func openWindow( + private func openWindow( url: URL?, restoredStartupURL: URL? = nil, size: NSSize? = nil, snap: SnapJob? = nil, - isPrimary: Bool = false + isPrimary: Bool = false, + presentation: WindowPresentation = .foreground ) -> BrowserWindowController { let controller = BrowserWindowController( url: url, @@ -900,8 +912,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.hostCore?.sessionDidClose(controller) } controllers.append(controller) - controller.showWindow(nil) - controller.window?.makeKeyAndOrderFront(nil) + switch presentation { + case .foreground: + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + case .background: + controller.window?.orderBack(nil) + } return controller } diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 29ba4f8..c2254d2 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -37,6 +37,7 @@ export default function DocsPage() {
Start the host, create a session, then visit the app. The session stays isolated until you close it.
On macOS, agent startup leaves your current app in front. Use headless config set startup-presentation foreground to make the old foreground behavior persistent, or set it to background to restore the built-in default. Inspect it with headless config get startup-presentation. The headless start --foreground and --background flags override the setting for one new host; they do not reorder a running host.