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
7 changes: 4 additions & 3 deletions .agents/skills/headless-computer-use/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ controls the desktop, native applications, OS chrome, microphone, or system audi
selection, Docker access, visible-browser mode, or MCP configuration matters.

Run `headless capabilities` once before relying on optional features. Network
emulation, request mocking, and file upload are Linux Chromium capabilities;
macOS WebKit reports them as unsupported.
idle waits, network emulation, request mocking, and file upload are Linux
Chromium capabilities; macOS WebKit reports them as unsupported.

## Follow the mandatory interaction loop

Expand All @@ -33,7 +33,8 @@ macOS WebKit reports them as unsupported.
scoped text, or scoped actions only when the task needs them.
5. Prefer role/name targeting; otherwise use a ref from the latest inspection.
6. After navigation or a substantial rerender, wait for the expected URL, text,
or settled state and inspect again before the next interaction.
settled state, or Chromium network idle and inspect again before the next
interaction.
7. Capture evidence and diagnostics in proportion to the task.
8. Close the session. Stop the host only when this skill started it and no other
task is using it.
Expand Down
9 changes: 7 additions & 2 deletions .agents/skills/headless-computer-use/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ headless --session NAME press KEY
headless --session NAME scroll up|down|top|bottom --amount PIXELS
headless --session NAME back
headless --session NAME reload
headless --session NAME wait --settled --url PATTERN --text TEXT --timeout MS
headless --session NAME wait --settled --network-idle --url PATTERN --text TEXT --timeout MS
headless --session NAME tour --full-page --pace PIXELS_PER_SECOND
```

Expand All @@ -59,7 +59,12 @@ Use `wait` with the strongest expected condition available:
1. expected URL plus expected text;
2. expected text;
3. settled state;
4. a bounded timeout only when no semantic condition exists.
4. Chromium network idle when asynchronous requests must finish;
5. a bounded timeout only when no semantic condition exists.

Network idle requires zero qualifying Chromium requests for 500 ms. Persistent
WebSocket and EventSource connections are excluded, but ordinary long polling
can time out. Check capabilities first because WebKit rejects this predicate.

Never replace a semantic wait with a long blind sleep.

Expand Down
6 changes: 6 additions & 0 deletions apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ extension BrowserWindowController {
}

func agentWait(parameters: [String: JSONValue]) throws -> JSONValue {
if parameters["networkIdle"]?.boolValue == true {
throw HostError(
code: .unsupportedCapability,
message: "Network-idle wait requires the Chromium CDP engine."
)
}
let timeoutMs = min(120_000, max(100, parameters["timeoutMs"]?.numberValue ?? 20_000))
let expectedURL = parameters["url"]?.stringValue
let expectedText = parameters["text"]?.stringValue
Expand Down
14 changes: 13 additions & 1 deletion apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
let diagnostics = QADiagnosticStore()
private let diagnosticsLock = NSLock()
private var requestContexts: [String: (method: String?, url: String?, headers: [String: String])] = [:]
private let networkIdleTracker = NetworkIdleTracker()
private let recordingLock = NSLock()
private var recordingPausedUntil = Date.distantPast
private let navigationLock = NSLock()
Expand Down Expand Up @@ -827,6 +828,8 @@ final class LinuxBrowserSession: @unchecked Sendable {
let expectedURL = parameters["url"]?.stringValue
let expectedText = parameters["text"]?.stringValue
let requireSettled = parameters["settled"]?.boolValue ?? false
let requireNetworkIdle = parameters["networkIdle"]?.boolValue ?? false
let networkWaitStartedAt = networkIdleTracker.beginWait()
let deadline = Date().addingTimeInterval(timeoutMs / 1_000)
var state: JSONValue = .object([:])
repeat {
Expand All @@ -846,7 +849,10 @@ final class LinuxBrowserSession: @unchecked Sendable {
let ready = object["readyState"]?.stringValue == "complete"
let animations = object["runningAnimations"]?.numberValue ?? 0
let quiet = object["mutationQuietMs"]?.numberValue ?? 0
if urlMatches && textMatches && (!requireSettled || (ready && animations == 0 && quiet >= 300)) { return state }
let settled = !requireSettled || (ready && animations == 0 && quiet >= 300)
let networkIdle = !requireNetworkIdle
|| networkIdleTracker.snapshot(since: networkWaitStartedAt).isIdle
if urlMatches && textMatches && settled && networkIdle { return state }
Thread.sleep(forTimeInterval: 0.05)
} while Date() < deadline
throw CDPError.timedOut
Expand Down Expand Up @@ -1190,6 +1196,10 @@ final class LinuxBrowserSession: @unchecked Sendable {
case "Network.requestWillBeSent":
guard let requestID = parameters["requestId"] as? String,
let request = parameters["request"] as? [String: Any] else { return }
networkIdleTracker.requestDidStart(
identifier: requestID,
resourceType: parameters["type"] as? String
)
diagnosticsLock.lock()
requestContexts[requestID] = (
request["method"] as? String,
Expand All @@ -1207,12 +1217,14 @@ final class LinuxBrowserSession: @unchecked Sendable {
responseHeaders: stringHeaders(response["headers"] as? [String: Any]), source: "chromium-cdp")
case "Network.loadingFailed":
let requestID = parameters["requestId"] as? String ?? ""
networkIdleTracker.requestDidFinish(identifier: requestID)
diagnosticsLock.lock(); let request = requestContexts.removeValue(forKey: requestID); diagnosticsLock.unlock()
diagnostics.append(kind: "request-failed", message: parameters["errorText"] as? String,
url: request?.url, method: request?.method, requestID: requestID,
requestHeaders: request?.headers, source: "chromium-cdp")
case "Network.loadingFinished":
if let requestID = parameters["requestId"] as? String {
networkIdleTracker.requestDidFinish(identifier: requestID)
diagnosticsLock.lock(); requestContexts.removeValue(forKey: requestID); diagnosticsLock.unlock()
}
default: break
Expand Down
8 changes: 6 additions & 2 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,12 @@ public struct CLIParser {
let text = try removeOption("--text", from: &args)
let timeoutText = try removeOption("--timeout", from: &args)
let settled = removeFlag("--settled", from: &args)
let networkIdle = removeFlag("--network-idle", from: &args)
try requireEmpty(args)
var parameters: [String: JSONValue] = ["settled": .bool(settled || (url == nil && text == nil))]
var parameters: [String: JSONValue] = [
"settled": .bool(settled || (url == nil && text == nil && !networkIdle)),
]
if networkIdle { parameters["networkIdle"] = .bool(true) }
if let url { parameters["url"] = .string(url) }
if let text { parameters["text"] = .string(text) }
if let timeoutText {
Expand Down Expand Up @@ -855,7 +859,7 @@ Commands:
upload REF --artifact FILE | upload --role ROLE [--name NAME] --artifact FILE
scroll [up|down|top|bottom] [--amount PX]
back | reload
wait [--settled] [--url PATTERN] [--text TEXT] [--timeout MS]
wait [--settled] [--network-idle] [--url PATTERN] [--text TEXT] [--timeout MS]
tour [--full-page] [--pace PX_PER_SECOND]
capture-info
screenshot [REF | --role ROLE --name NAME | --full-page] [--format png|jpg|jpeg] [--output FILE] [--clipboard]
Expand Down
4 changes: 4 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/Capabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public struct BrowserEngineCapabilities: Sendable {
public let qaDiagnosticSynchronization: String
public let screenshotClipboard: Bool
public let inputDispatch: String
public let networkIdleWait: Bool
public let normalProfileStorage: String
public let fileUpload: Bool

Expand Down Expand Up @@ -68,6 +69,7 @@ public struct BrowserEngineCapabilities: Sendable {
"screenshotClipboard": .bool(screenshotClipboard),
"tourTimeoutMs": .number(65_000),
"inputDispatch": .string(inputDispatch),
"networkIdleWait": .bool(networkIdleWait),
"fileUpload": .bool(fileUpload),
"normalProfile": .object([
"persistent": .bool(true),
Expand Down Expand Up @@ -113,6 +115,7 @@ public struct BrowserEngineCapabilities: Sendable {
qaDiagnosticSynchronization: "best-effort-page-world-observer",
screenshotClipboard: true,
inputDispatch: "synthetic-dom",
networkIdleWait: false,
normalProfileStorage: "persistent-wkwebsite-data-store",
fileUpload: false
)
Expand All @@ -136,6 +139,7 @@ public struct BrowserEngineCapabilities: Sendable {
qaDiagnosticSynchronization: "runtime-round-trip-flush",
screenshotClipboard: false,
inputDispatch: "trusted-cdp",
networkIdleWait: true,
normalProfileStorage: "private-xdg-data-directory",
fileUpload: true
)
Expand Down
86 changes: 86 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/NetworkIdle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

public struct NetworkIdleSnapshot: Equatable, Sendable {
public let activeRequestCount: Int
public let quietMilliseconds: Int
public let overflowed: Bool

public var isIdle: Bool {
!overflowed && activeRequestCount == 0
&& quietMilliseconds >= NetworkIdleTracker.quietPeriodMilliseconds
}
}

/// Tracks only browser-owned request identifiers. Request metadata and page
/// URLs never leave the engine adapter or enter the wait response.
public final class NetworkIdleTracker: @unchecked Sendable {
public static let quietPeriodMilliseconds = 500
public static let maximumTrackedRequests = 4_096

private static let persistentResourceTypes: Set<String> = ["eventsource", "websocket"]

private let lock = NSLock()
private let monotonicNow: @Sendable () -> TimeInterval
private var activeRequestIDs: Set<String> = []
private var lastActivity: TimeInterval
private var overflowed = false

public init(
monotonicNow: @escaping @Sendable () -> TimeInterval = {
ProcessInfo.processInfo.systemUptime
}
) {
self.monotonicNow = monotonicNow
self.lastActivity = monotonicNow()
}

public func beginWait() -> TimeInterval {
monotonicNow()
}

public func requestDidStart(identifier: String, resourceType: String?) {
lock.lock()
defer { lock.unlock() }

let now = monotonicNow()
let persistent = resourceType.map {
Self.persistentResourceTypes.contains($0.lowercased())
} ?? false
if persistent {
if activeRequestIDs.remove(identifier) != nil { lastActivity = now }
return
}

lastActivity = now
guard identifier.utf8.count <= 512 else {
overflowed = true
return
}
if activeRequestIDs.contains(identifier) { return }
guard activeRequestIDs.count < Self.maximumTrackedRequests else {
overflowed = true
return
}
activeRequestIDs.insert(identifier)
}

public func requestDidFinish(identifier: String) {
lock.lock()
defer { lock.unlock() }
if activeRequestIDs.remove(identifier) != nil {
lastActivity = monotonicNow()
}
}

public func snapshot(since waitStartedAt: TimeInterval) -> NetworkIdleSnapshot {
lock.lock()
defer { lock.unlock() }
let quietStart = max(waitStartedAt, lastActivity)
let quietMilliseconds = max(0, Int((monotonicNow() - quietStart) * 1_000))
return NetworkIdleSnapshot(
activeRequestCount: activeRequestIDs.count,
quietMilliseconds: quietMilliseconds,
overflowed: overflowed
)
}
}
1 change: 1 addition & 0 deletions apps/headless/Sources/HeadlessProtocol/Protocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ public struct CommandRequest: Codable, Equatable, Sendable {
)
case .wait:
try boolean("settled")
try boolean("networkIdle")
_ = try string("url")
_ = try string("text", maximumBytes: 30_000)
_ = try number("timeoutMs", minimum: 100, maximum: 120_000)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,8 @@ public let protocolCommandDefinitions: [CommandName: ProtocolCommandDefinition]
command(.back, untrusted: true),
command(.reload, untrusted: true),
command(.wait, [
boolean("settled"), string("url"), string("text", maximumBytes: 30_000),
boolean("settled"), boolean("networkIdle"), string("url"),
string("text", maximumBytes: 30_000),
number("timeoutMs", minimum: 100, maximum: 120_000),
], untrusted: true),
command(.tour, [boolean("fullPage"), number("pace", minimum: 100, maximum: 5_000)], untrusted: true),
Expand Down
27 changes: 27 additions & 0 deletions apps/headless/Tests/Fixtures/network-idle.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Network idle fixture</title>
</head>
<body>
<main>
<h1>Network idle fixture</h1>
<button type="button">Start network request</button>
<output aria-label="Network request state">not started</output>
</main>
<script>
const button = document.querySelector('button');
const output = document.querySelector('output');
button.addEventListener('click', async () => {
output.textContent = 'request pending';
try {
await fetch('/api/diagnostic?network-idle=1', {cache: 'no-store'});
output.textContent = 'request complete';
} catch (_) {
output.textContent = 'request failed';
}
});
</script>
</body>
</html>
Loading
Loading