Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 81 additions & 3 deletions apps/headless/Sources/HeadlessCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,63 @@ 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()

func ping() -> CommandResponse? {
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
Expand All @@ -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("/") {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 35 additions & 4 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down
16 changes: 15 additions & 1 deletion apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions apps/headless/Tests/linux-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading