diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3b66ef..d1228c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ on: - "apps/headless/build-linux.sh" - "apps/headless/install-linux.sh" - "apps/headless/Package.swift" + - "apps/headless/Sources/HeadlessProtocol/ProductVersion.swift" + - "apps/headless/VERSION" + - "apps/headless/VersionSupport/**" workflow_dispatch: inputs: dry_run: @@ -31,26 +34,34 @@ jobs: version: ${{ steps.version.outputs.version }} publish: ${{ steps.version.outputs.publish }} steps: + - uses: actions/checkout@v7 - id: version + shell: bash env: DRY_RUN: ${{ inputs.dry_run }} run: | - set -eu - if [ "$GITHUB_EVENT_NAME" = "push" ]; then - case "$GITHUB_REF" in - refs/tags/v*) ;; - *) echo "release publishing requires a v* tag" >&2; exit 64 ;; - esac - echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + version="${GITHUB_REF_NAME#v}" + if ! printf '%s\n' "$version" | grep -Eq -f apps/headless/VersionSupport/semver-pattern.txt; then + echo "Release tag must be a semantic version prefixed with v: $GITHUB_REF_NAME" >&2 + exit 64 + fi + expected="$(tr -d '[:space:]' < apps/headless/VERSION)" + if [[ "$version" != "$expected" ]]; then + echo "Release tag $version does not match apps/headless/VERSION $expected" >&2 + exit 64 + fi echo "publish=true" >> "$GITHUB_OUTPUT" else - if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$DRY_RUN" != "true" ]; then + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$DRY_RUN" != "true" ]]; then echo "manual release runs must use dry_run=true; publish by pushing a v* tag" >&2 exit 64 fi - echo "version=0.0.${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + version="0.0.${GITHUB_RUN_NUMBER}" echo "publish=false" >> "$GITHUB_OUTPUT" fi + echo "version=$version" >> "$GITHUB_OUTPUT" macos: needs: version @@ -64,6 +75,8 @@ jobs: HEADLESS_VERSION: ${{ needs.version.outputs.version }} run: ./apps/headless/build.sh - name: Unit tests + env: + HEADLESS_VERSION: ${{ needs.version.outputs.version }} run: ./apps/headless/test.sh - name: E2E run: zsh ./apps/headless/Tests/macos-e2e.sh @@ -90,7 +103,10 @@ jobs: steps: - uses: actions/checkout@v7 - name: Build - run: HEADLESS_LINUX_PLATFORM=linux/amd64 ./apps/headless/build-linux.sh + env: + HEADLESS_LINUX_PLATFORM: linux/amd64 + HEADLESS_VERSION: ${{ needs.version.outputs.version }} + run: ./apps/headless/build-linux.sh - name: E2E run: ./apps/headless/Tests/linux-docker.sh - name: Package @@ -117,7 +133,10 @@ jobs: steps: - uses: actions/checkout@v7 - name: Build - run: HEADLESS_LINUX_PLATFORM=linux/arm64 ./apps/headless/build-linux.sh + env: + HEADLESS_LINUX_PLATFORM: linux/arm64 + HEADLESS_VERSION: ${{ needs.version.outputs.version }} + run: ./apps/headless/build-linux.sh - name: E2E run: ./apps/headless/Tests/linux-docker.sh - name: Package @@ -160,6 +179,7 @@ jobs: with: tag_name: ${{ github.ref_name }} name: Headless ${{ needs.version.outputs.version }} + generate_release_notes: true body: | ## Downloads diff --git a/CHANGELOG.md b/CHANGELOG.md index 65bc5ad..285ed78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ Releases are tagged `vX.Y.Z` and published by Two versions travel independently, on purpose: -- **Product version** — the git tag, flowing into the macOS `Info.plist` via - `HEADLESS_VERSION` and into release assets. -- **Protocol version** — `headlessProtocolVersion` in `Protocol.swift`, +- **Product version**: the git tag, embedded in every binary via + `HEADLESS_VERSION`, reported by the CLI, host, and MCP adapter, and used for + release assets. +- **Protocol version**: `headlessProtocolVersion` in `Protocol.swift`, currently `0.5`. It changes only when the wire contract changes, and always with an entry in [`docs/roadmap/architecture-decisions.md`](docs/roadmap/architecture-decisions.md). @@ -77,6 +78,10 @@ Cutting that release is tracked in ### Changed +- 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 + automatically for each tag while protocol versioning stays independent. - The release workflow can now exercise and verify every package without publishing, manually or on pull requests that change packaging inputs. - macOS WebKit and Linux Chromium now share one `HostCore` dispatcher and diff --git a/README.md b/README.md index 9bcbf10..0d496f0 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,15 @@ P2 uses the same CLI everywhere Headless runs: Three common agent browser paths, scored **1–5** as qualitative capability judgments (not lab benchmarks): -| Dimension | Coordinate CU (regular browser) | Scripted (PW / Puppeteer / Selenium) | Headless | -| --- | :---: | :---: | :---: | -| Targeting precision | 2 | 4 | 5 | -| Safety / blast radius | 2 | 3 | 5 | -| Evidence (shots, video, QA) | 3 | 3 | 5 | -| Agent surface (tokens / glue) | 2 | 3 | 5 | -| Setup friction | 3 | 3 | 4 | -| Platform coverage (host OS) | 5 | 5 | 4 | -| Desktop / OS reach | 5 | 1 | 1 | +| Dimension | Coordinate CU (regular browser) | Scripted (PW / Puppeteer / Selenium) | Headless | +| ----------------------------- | :-----------------------------: | :----------------------------------: | :------: | +| Targeting precision | 2 | 4 | 5 | +| Safety / blast radius | 2 | 3 | 5 | +| Evidence (shots, video, QA) | 3 | 3 | 5 | +| Agent surface (tokens / glue) | 2 | 3 | 5 | +| Setup friction | 3 | 3 | 4 | +| Platform coverage (host OS) | 5 | 5 | 4 | +| Desktop / OS reach | 5 | 1 | 1 | - **Coordinate CU** — strong when the agent needs the whole desktop; weaker on precise web targeting (pixels drift), larger screenshot/prompt cost, and a @@ -210,6 +210,10 @@ git tag v1.0.0 git push origin v1.0.0 ``` +The tag is embedded as the product version in every binary. Verify an install +with `headless --version`; wire protocol compatibility is versioned +independently. See [CHANGELOG.md](CHANGELOG.md) for release history. + Assets: macOS `Headless.app` zip, Linux amd64/arm64 tarballs, and `SHA256SUMS`. Download the manifest beside the selected package and verify it before installing: diff --git a/apps/headless/Dockerfile.linux b/apps/headless/Dockerfile.linux index 9cce5e4..a70acb0 100644 --- a/apps/headless/Dockerfile.linux +++ b/apps/headless/Dockerfile.linux @@ -1,6 +1,10 @@ FROM swift:6.3-bookworm AS builder WORKDIR /src +ARG HEADLESS_VERSION +ENV HEADLESS_BUILD_VERSION=${HEADLESS_VERSION} COPY Package.swift ./ +COPY VERSION ./ +COPY VersionSupport ./VersionSupport COPY main.swift ./ COPY Host ./Host COPY Sources ./Sources @@ -11,11 +15,15 @@ COPY docs ./docs COPY tools ./tools COPY build.sh build-linux.sh install-linux.sh benchmark.sh test.sh package.json headless.entitlements Dockerfile.linux .dockerignore ./ RUN mkdir -p build Headless.app -RUN swift build -c release --static-swift-stdlib --product headless-protocol-tests \ +RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ + swift build -c release --static-swift-stdlib --product headless-protocol-tests \ && ./.build/release/headless-protocol-tests -RUN swift build -c release --static-swift-stdlib --product headless -RUN swift build -c release --static-swift-stdlib --product headless-linux-host -RUN swift build -c release --static-swift-stdlib --product headless-mcp +RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ + swift build -c release --static-swift-stdlib --product headless +RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ + swift build -c release --static-swift-stdlib --product headless-linux-host +RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ + swift build -c release --static-swift-stdlib --product headless-mcp RUN strip --strip-unneeded .build/release/headless .build/release/headless-linux-host .build/release/headless-mcp FROM debian:bookworm-slim AS runtime-base diff --git a/apps/headless/MCP/main.swift b/apps/headless/MCP/main.swift index 8620479..a977edd 100644 --- a/apps/headless/MCP/main.swift +++ b/apps/headless/MCP/main.swift @@ -52,7 +52,7 @@ while let line = readLine() { write(["jsonrpc": "2.0", "id": id ?? NSNull(), "result": [ "protocolVersion": "2025-06-18", "capabilities": ["tools": ["listChanged": false]], - "serverInfo": ["name": "headless", "version": headlessProtocolVersion], + "serverInfo": ["name": "headless", "version": headlessProductVersion], ]]) case "notifications/initialized": continue diff --git a/apps/headless/Package.swift b/apps/headless/Package.swift index 66278fd..a56332a 100644 --- a/apps/headless/Package.swift +++ b/apps/headless/Package.swift @@ -1,6 +1,19 @@ // swift-tools-version: 5.10 import PackageDescription +import Foundation + +let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() +let fallbackProductVersion = try String( + contentsOf: packageDirectory.appendingPathComponent("VERSION"), encoding: .utf8 +).trimmingCharacters(in: .whitespacesAndNewlines) +let productVersion = ProcessInfo.processInfo.environment["HEADLESS_VERSION"] ?? fallbackProductVersion +let semanticVersionPattern = try String( + contentsOf: packageDirectory.appendingPathComponent("VersionSupport/semver-pattern.txt"), encoding: .utf8 +).trimmingCharacters(in: .whitespacesAndNewlines) +guard productVersion.range(of: semanticVersionPattern, options: .regularExpression) != nil else { + fatalError("HEADLESS_VERSION must be a semantic version, received: \(productVersion)") +} let package = Package( name: "Headless", @@ -15,8 +28,16 @@ let package = Package( .library(name: "HeadlessProtocol", targets: ["HeadlessProtocol"]), ], targets: [ + .target( + name: "CHeadlessVersion", + path: "VersionSupport", + exclude: ["semver-pattern.txt"], + publicHeadersPath: "include", + cSettings: [.define("HEADLESS_PRODUCT_VERSION", to: "\"\(productVersion)\"")] + ), .target( name: "HeadlessProtocol", + dependencies: ["CHeadlessVersion"], resources: [.process("Resources")] ), .executableTarget( @@ -38,7 +59,7 @@ let package = Package( dependencies: ["HeadlessProtocol"], path: ".", exclude: [ - "Package.swift", "Sources", "Tests", "tools", "build.sh", + "Package.swift", "Sources", "Tests", "tools", "VersionSupport", "VERSION", "build.sh", "package.json", "headless.entitlements", "build", "docs", "test.sh", "LinuxHost", "Dockerfile.linux", "Headless.app", "build-linux.sh", "install-linux.sh", "benchmark.sh", ".dockerignore", "MCP", "node_modules", diff --git a/apps/headless/Sources/HeadlessCLI/main.swift b/apps/headless/Sources/HeadlessCLI/main.swift index ea1d886..816cee6 100644 --- a/apps/headless/Sources/HeadlessCLI/main.swift +++ b/apps/headless/Sources/HeadlessCLI/main.swift @@ -107,6 +107,8 @@ do { switch local { case .help: print(agentHelp) + case .version: + print("headless \(headlessProductVersion)") case .capabilities: printJSON(capabilitiesDocument) case .runtime: diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index 5942dff..e4232f2 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -2,6 +2,7 @@ import Foundation public enum LocalCommand: Equatable, Sendable { case help + case version case capabilities case runtime case start @@ -74,6 +75,9 @@ public struct CLIParser { case "help", "--help", "-h": try requireEmpty(arguments) return CLIInvocation(local: .help, jsonOutput: jsonOutput) + case "version", "--version", "-V": + try requireEmpty(arguments) + return CLIInvocation(local: .version, jsonOutput: jsonOutput) case "capabilities": try requireEmpty(arguments) return CLIInvocation(local: .capabilities, jsonOutput: true) @@ -638,6 +642,7 @@ Core workflow: headless --session qa capture-info Commands: + version | --version start | status | stop | runtime session create [NAME] | session list | session close NAME visit URL diff --git a/apps/headless/Sources/HeadlessProtocol/HostCore.swift b/apps/headless/Sources/HeadlessProtocol/HostCore.swift index e68d723..772e937 100644 --- a/apps/headless/Sources/HeadlessProtocol/HostCore.swift +++ b/apps/headless/Sources/HeadlessProtocol/HostCore.swift @@ -234,6 +234,7 @@ public final class HostCore: @unchecked Sendable { "pid": .number(Double(ProcessInfo.processInfo.processIdentifier)), "engine": .string(engine.name), "platform": .string(engine.platform), + "productVersion": .string(headlessProductVersion), "protocolVersion": .string(headlessProtocolVersion), "capabilities": engine.capabilities.document, "recordingAvailable": .bool(BrowserRecording.isAvailable()), diff --git a/apps/headless/Sources/HeadlessProtocol/ProductVersion.swift b/apps/headless/Sources/HeadlessProtocol/ProductVersion.swift new file mode 100644 index 0000000..3c0760c --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/ProductVersion.swift @@ -0,0 +1,5 @@ +import CHeadlessVersion + +/// The product release version embedded at compile time. This is independent +/// from the wire protocol version used for compatibility checks. +public let headlessProductVersion = String(cString: headless_product_version()) diff --git a/apps/headless/Tests/HeadlessMCPTests/main.swift b/apps/headless/Tests/HeadlessMCPTests/main.swift index a1fbd40..41b6a15 100644 --- a/apps/headless/Tests/HeadlessMCPTests/main.swift +++ b/apps/headless/Tests/HeadlessMCPTests/main.swift @@ -24,8 +24,8 @@ func integer(_ value: Any?, _ message: String) throws -> Int { } func run() throws { - guard CommandLine.arguments.count == 2 else { - throw TestFailure(description: "usage: headless-mcp-tests /path/to/headless-mcp") + guard CommandLine.arguments.count == 3 else { + throw TestFailure(description: "usage: headless-mcp-tests /path/to/headless-mcp EXPECTED_VERSION") } try LocalRuntime.preparePrivateDirectory() @@ -84,6 +84,7 @@ func run() throws { try expect(initialize["protocolVersion"] as? String == "2025-06-18", "initialize protocol version changed") let serverInfo = try object(initialize["serverInfo"], "initialize server info was absent") try expect(serverInfo["name"] as? String == "headless", "initialize server name changed") + try expect(serverInfo["version"] as? String == CommandLine.arguments[2], "MCP product version changed") let list = try object(responses[1]["result"], "tools/list result was absent") guard let tools = list["tools"] as? [[String: Any]], tools.count == 1 else { diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index e2f6436..91f3197 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -725,6 +725,9 @@ struct ProtocolTests { (["start"], .start), (["help"], .help), (["--help"], .help), + (["version"], .version), + (["--version"], .version), + (["-V"], .version), ] for (arguments, command) in localCommands { let invocation = try CLIParser().parse(arguments) @@ -1819,6 +1822,7 @@ struct ProtocolTests { } try expect(pingResult["engine"] == .string("fake"), "ping should identify the engine") try expect(pingResult["platform"] == .string("test"), "ping should identify the platform") + try expect(pingResult["productVersion"] == .string(headlessProductVersion), "ping should identify the product version") try expect(pingResult["adapter"] == .string("test-adapter"), "engine ping details should be merged") try expect(pingResult["capabilities"] != nil, "ping should publish the active engine profile") diff --git a/apps/headless/VERSION b/apps/headless/VERSION new file mode 100644 index 0000000..6d7de6e --- /dev/null +++ b/apps/headless/VERSION @@ -0,0 +1 @@ +1.0.2 diff --git a/apps/headless/VersionSupport/headless_version.c b/apps/headless/VersionSupport/headless_version.c new file mode 100644 index 0000000..90a6f35 --- /dev/null +++ b/apps/headless/VersionSupport/headless_version.c @@ -0,0 +1,9 @@ +#include "headless_version.h" + +#ifndef HEADLESS_PRODUCT_VERSION +#error "HEADLESS_PRODUCT_VERSION must be defined by Package.swift" +#endif + +const char *headless_product_version(void) { + return HEADLESS_PRODUCT_VERSION; +} diff --git a/apps/headless/VersionSupport/include/headless_version.h b/apps/headless/VersionSupport/include/headless_version.h new file mode 100644 index 0000000..a55c263 --- /dev/null +++ b/apps/headless/VersionSupport/include/headless_version.h @@ -0,0 +1,6 @@ +#ifndef HEADLESS_VERSION_H +#define HEADLESS_VERSION_H + +const char *headless_product_version(void); + +#endif diff --git a/apps/headless/VersionSupport/semver-pattern.txt b/apps/headless/VersionSupport/semver-pattern.txt new file mode 100644 index 0000000..4a91152 --- /dev/null +++ b/apps/headless/VersionSupport/semver-pattern.txt @@ -0,0 +1 @@ +^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$ diff --git a/apps/headless/build-linux.sh b/apps/headless/build-linux.sh index 351c12b..a79de49 100755 --- a/apps/headless/build-linux.sh +++ b/apps/headless/build-linux.sh @@ -9,10 +9,11 @@ command -v docker >/dev/null 2>&1 || { } IMAGE="headless-linux-build" +VERSION="${HEADLESS_VERSION:-$(tr -d '[:space:]' < VERSION)}" if [ -n "${HEADLESS_LINUX_PLATFORM:-}" ]; then - docker build --platform "$HEADLESS_LINUX_PLATFORM" --target production -f Dockerfile.linux -t "$IMAGE" . + docker build --build-arg HEADLESS_VERSION="$VERSION" --platform "$HEADLESS_LINUX_PLATFORM" --target production -f Dockerfile.linux -t "$IMAGE" . else - docker build --target production -f Dockerfile.linux -t "$IMAGE" . + docker build --build-arg HEADLESS_VERSION="$VERSION" --target production -f Dockerfile.linux -t "$IMAGE" . fi mkdir -p build/linux CONTAINER="$(docker create "$IMAGE")" diff --git a/apps/headless/build.sh b/apps/headless/build.sh index b6d0a50..8bb47b0 100755 --- a/apps/headless/build.sh +++ b/apps/headless/build.sh @@ -13,7 +13,7 @@ done APP="Headless.app" ARCH="$(uname -m)" ICON="build/Headless.icns" -VERSION="${HEADLESS_VERSION:-1.0.0}" +VERSION="${HEADLESS_VERSION:-$(tr -d '[:space:]' < VERSION)}" mkdir -p build/module-cache build/swiftpm-module-cache build/bin # Select an SDK the installed Swift compiler can read. Apple occasionally ships diff --git a/apps/headless/package.json b/apps/headless/package.json index 70e404f..00d7b1e 100644 --- a/apps/headless/package.json +++ b/apps/headless/package.json @@ -1,7 +1,7 @@ { "name": "@headless/app", "private": true, - "version": "0.0.0", + "version": "1.0.2", "scripts": { "build": "./build.sh", "build:linux": "./build-linux.sh", diff --git a/apps/headless/test.sh b/apps/headless/test.sh index dd41443..86d66a6 100755 --- a/apps/headless/test.sh +++ b/apps/headless/test.sh @@ -41,9 +41,28 @@ fi TEST_SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/headless-tests.XXXXXX")" trap 'rm -rf "$TEST_SCRATCH"' EXIT +EXPECTED_VERSION="${HEADLESS_VERSION:-$(tr -d '[:space:]' < VERSION)}" +for INVALID_VERSION in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-.beta 1.2.3-beta. 1.2.3+build..1; do + if HEADLESS_VERSION="$INVALID_VERSION" swift package dump-package >/dev/null 2>&1; then + echo "headless tests: invalid product version was accepted: $INVALID_VERSION" >&2 + exit 1 + fi +done +for manifest in package.json ../web/package.json ../../package.json; do + MANIFEST_VERSION="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "$manifest")" + [[ "$MANIFEST_VERSION" == "$(tr -d '[:space:]' < VERSION)" ]] || { + echo "headless tests: $manifest version does not match VERSION" >&2 + exit 1 + } +done BIN_PATH="$(swift build "${SDK_ARGS[@]}" --scratch-path "$TEST_SCRATCH" --show-bin-path)" swift build "${SDK_ARGS[@]}" --product headless-protocol-tests --scratch-path "$TEST_SCRATCH" +swift build "${SDK_ARGS[@]}" --product headless --scratch-path "$TEST_SCRATCH" swift build "${SDK_ARGS[@]}" --product headless-mcp --scratch-path "$TEST_SCRATCH" swift build "${SDK_ARGS[@]}" --product headless-mcp-tests --scratch-path "$TEST_SCRATCH" "$BIN_PATH/headless-protocol-tests" -"$BIN_PATH/headless-mcp-tests" "$BIN_PATH/headless-mcp" +[[ "$("$BIN_PATH/headless" --version)" == "headless $EXPECTED_VERSION" ]] || { + echo "headless tests: CLI product version does not match $EXPECTED_VERSION" >&2 + exit 1 +} +"$BIN_PATH/headless-mcp-tests" "$BIN_PATH/headless-mcp" "$EXPECTED_VERSION" diff --git a/apps/web/package.json b/apps/web/package.json index 763f0d7..28ee7c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@headless/web", "private": true, - "version": "0.1.0", + "version": "1.0.2", "scripts": { "dev": "next dev", "build": "next build", diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 27edb27..855edfe 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -16,12 +16,13 @@ the CLI, the MCP server, and both hosts. No Rust/Go rewrite. it). **Rationale:** + - The investment is already amortized: ~7.5k lines of working, tested Swift spanning both platforms, with zero third-party dependencies and a QA evidence trail proving behavior. A rewrite resets all of that for a benefit that is mostly hypothetical. - The macOS host is irreducibly Swift (Cocoa/WebKit). A Rust/Go core would - *add* a language boundary (FFI or IPC between the Swift app and the new + _add_ a language boundary (FFI or IPC between the Swift app and the new core) rather than remove one. - Swift on Linux is genuinely fine here and proven in this repo: static stdlib builds in Docker (`Dockerfile.linux`), stripped binaries, no runtime @@ -34,6 +35,7 @@ it). contract docs matter more than language familiarity. **Costs accepted:** + - Windows: Swift-on-Windows exists (the Browser Company ships it) but the toolchain is rougher than Rust/Go. Accepted because Windows is a stretch goal (roadmap Phase W), and Phase 2's engine split confines the port to @@ -41,7 +43,7 @@ it). - Binary distribution stays per-platform build scripts rather than `cargo`/`goreleaser` conveniences. Phase 3 does this work once. -**Revisit trigger:** if Windows-native is ever promoted to must-have *and* a +**Revisit trigger:** if Windows-native is ever promoted to must-have _and_ a spike shows Swift-on-Windows cannot pass the Linux E2E scenario within ~2 weeks of effort, revisit with a concrete proposal: keep the WKWebView app in Swift, move `HeadlessProtocol` + Chromium host to Rust, talk over the existing @@ -77,7 +79,7 @@ imports data instead of transcribing it. replacing the `message.contains("ELEMENT_NOT_FOUND")` string matching on both hosts and in `AgentBridge.swift:416-418`; - single definitions for the constants currently written 2–4×: blocked/caution - extension sets (Swift *and* the JS copy get a cross-check test), screenshot + extension sets (Swift _and_ the JS copy get a cross-check test), screenshot bounds, artifact charset, local-address list, inspect/console/storage/scroll enums, numeric bounds (CLI and validator currently disagree — e.g. scroll amount `>0` vs `>=0.1`). @@ -88,8 +90,8 @@ already happened (report `page` shape, capture-info shape, tour timeout, JPEG quality path, PDF raster-vs-vector). This refactor is the precondition for Windows and for keeping principle "one contract" true. -**Non-goal:** merging the engines' *capabilities*. Divergent capability stays -explicit (`UNSUPPORTED_CAPABILITY`); the point is that the *common* path is +**Non-goal:** merging the engines' _capabilities_. Divergent capability stays +explicit (`UNSUPPORTED_CAPABILITY`); the point is that the _common_ path is single-sourced and the divergent one is declared, generated into `capabilities`, and asserted by tests. @@ -109,6 +111,7 @@ neutrality is what keeps both the Windows port and the (rejected-for-now) Rust option cheap. **Amendments planned (backlog §A5, §G3):** + - Response-side bounding: `qa report` and `artifact.list` can exceed the 1 MiB frame today and surface a misleading `INVALID_REQUEST`. Add pagination (`--limit/--cursor`) or server-side truncation with `truncated: true`, @@ -166,7 +169,7 @@ two engines" discipline that keeps the protocol honest. **Acknowledged limits (stay documented, not "fixed"):** diagnostics are best-effort (no full network event stream), no network emulation/mocking, raster PDF. If agent demand ever requires full-fidelity diagnostics on macOS, -the answer is offering the Chromium engine on macOS as an *additional* +the answer is offering the Chromium engine on macOS as an _additional_ runtime behind the same CLI (the Linux host already builds on macOS-adjacent Foundation APIs) — not hacking WKWebView. That would be a new decision entry. @@ -180,7 +183,7 @@ inspection remain in `WKContentWorld`. **Decision:** today `click`/`fill`/`press` are synthetic DOM events from the isolated world (`AgentRuntime.swift:492-537`) on both engines — no trusted- event semantics, no hover/drag, `press` only special-cases Enter/Space. -Keep this as the *portable baseline*, and in Phase 4 add real input on the +Keep this as the _portable baseline_, and in Phase 4 add real input on the Chromium engine via CDP `Input.dispatchKeyEvent`/`dispatchMouseEvent`, exposed as the same verbs (upgrade, not new commands), with WKWebView staying on the synthetic path as a declared capability difference. @@ -204,6 +207,7 @@ Add palettegen to the GIF path (quality, cheap). **Decision:** keep the single shared `agentRuntimeJavaScript` string as the one implementation of page-side semantics for every engine (it is what makes "same contract" real). Fix the delivery mechanics (backlog §B7): + - Linux re-creates the isolated world and re-sends ~30 KB of JS on **every** evaluate — 3 CDP round trips per command, polled at 20 Hz by `wait` (`BrowserProcess.swift:777-834`). Cache the world/context per navigation and @@ -234,6 +238,8 @@ WKWebView engine would declare `UNSUPPORTED_CAPABILITY` or use non-persistent ## 12. Versioning: unify on the git tag (change, Phase 3) +**Status:** implemented 2026-08-12. + **Decision:** the git tag becomes the single version source: injected at build time (already works via `HEADLESS_VERSION`), reported by a new `headless --version`/`version` command and in `ping`, matched by `package.json`, MCP @@ -352,18 +358,18 @@ override, the 500-event bound, and truncation reporting. ## Decision log -| # | Decision | Status | Date | -| --- | --- | --- | --- | -| 1 | Keep Swift core; Rust only via revisit trigger | Decided | 2026-08-04 | -| 3 | Extract HostCore + BrowserEngine, typed errors | Implemented | 2026-08-10 | -| 5 | Remote stays SSH-only; no cloud offering | Decided (owner) | 2026-08-04 | -| 6 | Windows = stretch via Chromium engine; WSL2/Docker interim | Decided (owner) | 2026-08-04 | -| 8 | Real CDP input on Linux as capability upgrade | Planned (Phase 4) | 2026-08-04 | -| 12 | Version unification on git tag | Planned (Phase 3) | 2026-08-04 | -| 14 | Run one conformance scenario against every engine | Implemented | 2026-08-10 | -| 15 | Package-manager distribution set | Decided (owner) | 2026-08-04 | -| 16 | Preserve CLI value boundaries with `--` and shell quoting | Decided | 2026-08-10 | -| 17 | Keep full MCP surface; annotate its maximum risk | Decided | 2026-08-10 | -| 18 | Treat WebKit page diagnostics as bounded untrusted evidence | Decided | 2026-08-10 | +| # | Decision | Status | Date | +| --- | ----------------------------------------------------------- | ----------------- | ---------- | +| 1 | Keep Swift core; Rust only via revisit trigger | Decided | 2026-08-04 | +| 3 | Extract HostCore + BrowserEngine, typed errors | Implemented | 2026-08-10 | +| 5 | Remote stays SSH-only; no cloud offering | Decided (owner) | 2026-08-04 | +| 6 | Windows = stretch via Chromium engine; WSL2/Docker interim | Decided (owner) | 2026-08-04 | +| 8 | Real CDP input on Linux as capability upgrade | Planned (Phase 4) | 2026-08-04 | +| 12 | Version unification on git tag | Implemented | 2026-08-04 | +| 14 | Run one conformance scenario against every engine | Implemented | 2026-08-10 | +| 15 | Package-manager distribution set | Decided (owner) | 2026-08-04 | +| 16 | Preserve CLI value boundaries with `--` and shell quoting | Decided | 2026-08-10 | +| 17 | Keep full MCP surface; annotate its maximum risk | Decided | 2026-08-10 | +| 18 | Treat WebKit page diagnostics as bounded untrusted evidence | Decided | 2026-08-10 | New decisions append here with the same format. diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 3aebea1..6147d62 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -74,9 +74,9 @@ burning a core while silently refusing every agent. map is reset on each `snapshot()` (`HP/AgentRuntime.swift:376`), so a `--context summary` (max 8 elements) invalidates all refs from a prior `full`; the agent later gets a bare `ELEMENT_NOT_FOUND`. Meanwhile -`currentRegions` is *never* reset and grows for the page lifetime. ~~Decide the +`currentRegions` is _never_ reset and grows for the page lifetime. ~~Decide the contract (likely: refs from the latest inspection only — already the skill's -teaching), then (a) make the error say *why* ("ref expired; re-inspect"), +teaching), then (a) make the error say _why_ ("ref expired; re-inspect"), (b) reset regions consistently on navigation, (c) document in P1.md. Test: inspect-full → inspect-summary → click stale `@eN` asserts the new error.~~ **Done.** The contract is now explicit and asymmetric on purpose: `@eN` is @@ -115,6 +115,7 @@ message buffer scans each appended region once and amortizes prefix compaction; protocol coverage feeds it a 30 MiB message in the host's 8 KiB read chunks. **A9. Misc hardening (smaller, same phase).** ([#20](https://github.com/LockInTime/headless/issues/20)) + - ~~`ChromiumChildProcess.stop()` can busy-wait forever post-SIGKILL (`LinuxHost/BrowserProcess.swift:38`); bound it.~~ - ~~`SO_PEERCRED` hard-coded as `17` + hand-rolled `ucred` @@ -198,7 +199,7 @@ branch, and hid the backward-compatible no-op `--json` parser flag from help. **B5. `pruneToBudget` quality.** ([#25](https://github.com/LockInTime/headless/issues/25)) ~~Hand-rolled 2-pass fixed point (`HP/AgentRuntime.swift:348-352`), O(n²) re-encoding per trim, pop-largest- -*last*-element heuristic misses large mid-array items +_last_-element heuristic misses large mid-array items (`AgentRuntime.swift:367-369`), text-chop fallback untested. Rework with a size-estimating single pass; add unit tests in the jsdom suite.~~ **Done:** each candidate is measured once, largest entries are pruned regardless of @@ -269,7 +270,7 @@ oversized line / local-command rejection (`MCP/main.swift:64-66`).~~ **Done:** uses a private local socket server to verify a real browser-command round trip. **C4. Machine-accurate `capabilities`** ([#32](https://github.com/LockInTime/headless/issues/32)) — generate from `CommandName.allCases` -+ engine matrix (see B6) so agents can trust it. +and the engine matrix (see B6) so agents can trust it. **C5. Harness onboarding [exists: skill content].** ([#33](https://github.com/LockInTime/headless/issues/33)) Root `AGENTS.md` + `CLAUDE.md` (added with this doc set); mirror the skill into `.claude/skills/` @@ -374,7 +375,7 @@ Owner-decided scope: package managers, no hosted service. regular package files, generates `SHA256SUMS` atomically, verifies it, and attaches the manifest to the release. Cosign remains optional future work. - **E5.** ([#43](https://github.com/LockInTime/headless/issues/43)) npm wrapper package (binary download shim) for `npx` reach. -- **E6.** ([#44](https://github.com/LockInTime/headless/issues/44)) Version unification + `headless --version` + CHANGELOG + release +- **E6.** [x] ([#44](https://github.com/LockInTime/headless/issues/44)) Version unification + `headless --version` + CHANGELOG + release automation (architecture §12). `package.json` says 0.0.0, tags say 1.0.x, default `HEADLESS_VERSION` is 1.0.0. - **E7.** ([#45](https://github.com/LockInTime/headless/issues/45)) Cut a release: everything since v1.0.2 (capture formats, context @@ -384,7 +385,7 @@ Owner-decided scope: package managers, no hosted service. ## §F — Website & docs (Phase 5) -- **F1. Deploy pipeline is invisible to the repo** ([#47](https://github.com/LockInTime/headless/issues/47)) — the site *is* live at +- **F1. Deploy pipeline is invisible to the repo** ([#47](https://github.com/LockInTime/headless/issues/47)) — the site _is_ live at `https://headless-web-pi.vercel.app` (set as the repo homepage) via Vercel's GitHub integration, but nothing in the tree records that: no `vercel.json`, no deploy docs, no preview-URL comment on PRs, and the temporary diff --git a/package.json b/package.json index 86c72c5..f3445a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "headless", "private": true, + "version": "1.0.2", "scripts": { "build": "pnpm --filter @headless/app build && pnpm --filter @headless/web build", "build:linux": "pnpm --filter @headless/app build:linux",