diff --git a/.github/actions/linux-core-artifact/action.yml b/.github/actions/linux-core-artifact/action.yml new file mode 100644 index 0000000000..2739440995 --- /dev/null +++ b/.github/actions/linux-core-artifact/action.yml @@ -0,0 +1,38 @@ +name: Checked Linux core artifact +description: Restore the same architecture, commit and version that passed Zig/glibc validation in this run. +inputs: + arch: + description: x64 or arm64 artifact architecture + required: true + rust-target: + description: Expected native GNU Linux target + required: true + probe-version: + description: Expected embedded core version + required: true +runs: + using: composite + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ci-linux-core-${{ inputs.arch }}-${{ github.sha }} + path: apps/desktop/probe-build + - name: Verify the checked core and restore executable mode + shell: bash + working-directory: apps/desktop + env: + EXPECTED_TARGET: ${{ inputs.rust-target }} + EXPECTED_ARCH: ${{ inputs.arch }} + EXPECTED_VERSION: ${{ inputs.probe-version }} + EXPECTED_REVISION: ${{ github.sha }} + run: | + case "$EXPECTED_ARCH:$EXPECTED_TARGET" in + x64:x86_64-unknown-linux-gnu|arm64:aarch64-unknown-linux-gnu) ;; + *) echo 'Invalid Linux core architecture/target pair' >&2; exit 1 ;; + esac + test "$(cat probe-build/source-revision.txt)" = "$EXPECTED_REVISION" + test "$(cat probe-build/rust-target.txt)" = "$EXPECTED_TARGET" + test "$(cat probe-build/probe-version.txt)" = "$EXPECTED_VERSION" + (cd probe-build && sha256sum --check agent-relay-probe.sha256) + # Artifact downloads deliberately normalize permissions to 0644. + chmod 0755 probe-build/agent-relay-probe diff --git a/.github/actions/rust-lane-cache/action.yml b/.github/actions/rust-lane-cache/action.yml new file mode 100644 index 0000000000..ebb590378c --- /dev/null +++ b/.github/actions/rust-lane-cache/action.yml @@ -0,0 +1,76 @@ +name: Rust lane cache +description: Save parallel build lanes independently and seed a new lane from the old shared cache. +inputs: + legacy-key: + description: Complete former key plus job name (shared-key replaces both) + required: true + lane: + description: Package or tests lane + required: true + discard-profile: + description: Unused Cargo profile to drop from restored seeds (debug, release or none) + required: true +runs: + using: composite + steps: + - name: Restore the lane cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + with: + shared-key: ${{ inputs.legacy-key }}-${{ inputs.lane }} + workspaces: apps/desktop/probe + # This marker distinguishes any restored lane cache, including a + # partial lockfile match, from a complete miss. cache-hit alone only + # reports exact matches and cannot safely guard the legacy restore. + cache-directories: ${{ runner.temp }}/relay-rust-cache/${{ inputs.legacy-key }}-${{ inputs.lane }} + - name: Check whether the lane needs its legacy seed + id: seed + shell: bash + env: + CACHE_MARKER: ${{ runner.temp }}/relay-rust-cache/${{ inputs.legacy-key }}-${{ inputs.lane }}/restored + run: | + if [ -f "$CACHE_MARKER" ]; then + echo 'needed=false' >> "$GITHUB_OUTPUT" + else + echo 'needed=true' >> "$GITHUB_OUTPUT" + fi + - name: Seed a new lane from the previous shared cache + if: steps.seed.outputs.needed == 'true' + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + with: + # Match the old cache namespace AND path list. It is read-only: + # parallel lanes must never compete to save this shared entry. + shared-key: ${{ inputs.legacy-key }} + workspaces: apps/desktop/probe + save-if: 'false' + - name: Prune the restored profile this lane never uses + shell: bash + working-directory: apps/desktop + env: + DISCARD_PROFILE: ${{ inputs.discard-profile }} + run: | + # Only known, ignored Cargo build directories may be removed. Never + # accept arbitrary paths or profiles from callers, and reject symlinks. + case "$DISCARD_PROFILE" in + debug|release) + for target_dir in probe/target \ + probe/target/aarch64-apple-darwin \ + probe/target/x86_64-apple-darwin \ + probe/target/aarch64-unknown-linux-gnu \ + probe/target/x86_64-unknown-linux-gnu; do + if [ -L "$target_dir" ] || ! git check-ignore --quiet -- "$target_dir/$DISCARD_PROFILE"; then + echo "Refusing to prune an untrusted build directory: $target_dir" >&2 + exit 1 + fi + rm -rf -- "${target_dir:?}/${DISCARD_PROFILE:?}" + done + ;; + none) ;; + *) echo "Unknown discard-profile: $DISCARD_PROFILE" >&2; exit 1 ;; + esac + - name: Mark the lane cache for future restores + shell: bash + env: + CACHE_MARKER: ${{ runner.temp }}/relay-rust-cache/${{ inputs.legacy-key }}-${{ inputs.lane }}/restored + run: | + mkdir -p "$(dirname "$CACHE_MARKER")" + echo 'lane-cache-v1' > "$CACHE_MARKER" diff --git a/.github/actions/swift-cache/action.yml b/.github/actions/swift-cache/action.yml new file mode 100644 index 0000000000..7be83920c7 --- /dev/null +++ b/.github/actions/swift-cache/action.yml @@ -0,0 +1,25 @@ +name: Swift package cache +description: Reuse SwiftPM builds within one architecture, lane and installed SDK/toolchain. +inputs: + arch: + description: Runner architecture + required: true + lane: + description: Isolated package or tests build lane + required: true +runs: + using: composite + steps: + - name: Fingerprint Swift and the SDK + id: toolchain + shell: bash + run: | + identity=$( { swift --version; xcodebuild -version; xcrun --show-sdk-version; } | shasum -a 256 | cut -d ' ' -f 1) + echo "identity=$identity" >> "$GITHUB_OUTPUT" + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: apps/desktop/macos/.build + key: swift-v1-${{ inputs.arch }}-${{ inputs.lane }}-${{ steps.toolchain.outputs.identity }}-${{ hashFiles('apps/desktop/macos/Package.swift', 'apps/desktop/macos/Package.resolved') }}-${{ github.sha }} + restore-keys: | + swift-v1-${{ inputs.arch }}-${{ inputs.lane }}-${{ steps.toolchain.outputs.identity }}-${{ hashFiles('apps/desktop/macos/Package.swift', 'apps/desktop/macos/Package.resolved') }}- + swift-v1-${{ inputs.arch }}-${{ inputs.lane }}-${{ steps.toolchain.outputs.identity }}- diff --git a/.github/actions/windows-build-cache-prune/action.yml b/.github/actions/windows-build-cache-prune/action.yml new file mode 100644 index 0000000000..5230152259 --- /dev/null +++ b/.github/actions/windows-build-cache-prune/action.yml @@ -0,0 +1,9 @@ +name: Prune Windows Cargo workspace products +description: Keep reusable dependencies while removing probe binaries and tests before the build cache is saved. +inputs: + profile: + description: Cargo build profile directory (debug or release) + required: true +runs: + using: node20 + main: main.cjs diff --git a/.github/actions/windows-build-cache-prune/main.cjs b/.github/actions/windows-build-cache-prune/main.cjs new file mode 100644 index 0000000000..9cc97a32bd --- /dev/null +++ b/.github/actions/windows-build-cache-prune/main.cjs @@ -0,0 +1,139 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +function inspect(filename) { + try { + return fs.lstatSync(filename); + } catch (error) { + if (error.code === 'ENOENT') return undefined; + throw error; + } +} + +function directory(filename) { + const info = inspect(filename); + if (!info) return false; + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error(`Refusing an unsafe Cargo build directory: ${filename}`); + } + return true; +} + +function packageName(workspace) { + const manifest = path.join(workspace, 'probe', 'Cargo.toml'); + const info = inspect(manifest); + if (!info || info.isSymbolicLink() || !info.isFile()) { + throw new Error('Expected a regular probe/Cargo.toml manifest'); + } + let inPackage = false; + for (const line of fs.readFileSync(manifest, 'utf8').split(/\r?\n/)) { + if (/^\s*\[/.test(line)) { + inPackage = /^\s*\[package\]\s*(?:#.*)?$/.test(line); + continue; + } + if (!inPackage) continue; + const name = line.match(/^\s*name\s*=\s*(["'])([A-Za-z0-9][A-Za-z0-9_-]*)\1\s*(?:#.*)?$/); + if (name) return name[2]; + } + throw new Error('Could not read a safe name from probe/Cargo.toml [package]'); +} + +function sizeWithoutSymlinks(filename) { + const info = fs.lstatSync(filename); + if (info.isSymbolicLink()) { + throw new Error(`Refusing a symlink in Cargo workspace products: ${filename}`); + } + if (info.isFile()) return info.size; + if (!info.isDirectory()) { + throw new Error(`Refusing a special Cargo workspace product: ${filename}`); + } + return fs + .readdirSync(filename) + .reduce((bytes, name) => bytes + sizeWithoutSymlinks(path.join(filename, name)), 0); +} + +function pruneWorkspaceArtifacts({ workspace, profile }) { + if (!['debug', 'release'].includes(profile)) { + throw new Error('Cargo cache profile must be debug or release'); + } + if (typeof workspace !== 'string' || !path.isAbsolute(workspace)) { + throw new Error('GITHUB_WORKSPACE must be an absolute repository path'); + } + const root = path.resolve(workspace); + if (!directory(root) || !directory(path.join(root, 'probe'))) { + throw new Error('Expected the checked-out probe package'); + } + const name = packageName(root); + const summary = { fingerprints: 0, buildDirectories: 0, dependencyFiles: 0, bytes: 0 }; + // Validate every parent/container before collecting a deletion plan. Never + // follow a target, profile or artifact-directory symlink outside this tree. + const target = path.join(root, 'probe', 'target'); + if (!directory(target)) return summary; + const buildProfile = path.join(target, profile); + if (!directory(buildProfile)) return summary; + const containers = {}; + for (const container of ['.fingerprint', 'build', 'deps']) { + const filename = path.join(buildProfile, container); + if (directory(filename)) containers[container] = filename; + } + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const packageHash = new RegExp(`^${escapedName}-([0-9a-f]{8,64})$`); + const hashes = new Set(); + const plan = []; + for (const entry of containers['.fingerprint'] ? fs.readdirSync(containers['.fingerprint']) : []) { + const match = entry.match(packageHash); + if (!match) continue; + const filename = path.join(containers['.fingerprint'], entry); + directory(filename); + hashes.add(match[1]); + plan.push({ filename, kind: 'fingerprints' }); + } + for (const entry of containers.build ? fs.readdirSync(containers.build) : []) { + if (!packageHash.test(entry)) continue; + const filename = path.join(containers.build, entry); + directory(filename); + plan.push({ filename, kind: 'buildDirectories' }); + } + for (const entry of containers.deps ? fs.readdirSync(containers.deps) : []) { + // Cargo puts the target hash immediately before the extension(s), e.g. + // libprobe-HASH.rlib, hosting-HASH.exe or probe-HASH.pdb. The target name + // alone cannot distinguish workspace tests from dependency build tools. + const match = entry.match(/^[^.]+-([0-9a-f]{8,64})(?:\..*)?$/); + if (!match || !hashes.has(match[1])) continue; + const filename = path.join(containers.deps, entry); + const info = fs.lstatSync(filename); + if (info.isSymbolicLink() || !info.isFile()) { + throw new Error(`Refusing an unsafe Cargo workspace artifact: ${filename}`); + } + plan.push({ filename, kind: 'dependencyFiles' }); + } + // Validate nested products before the first deletion, including symlinks + // inside workspace build-script outputs. Dependency directories are untouched. + for (const product of plan) summary.bytes += sizeWithoutSymlinks(product.filename); + for (const product of plan) { + fs.rmSync(product.filename, { recursive: true }); + summary[product.kind] += 1; + } + return summary; +} + +module.exports = { pruneWorkspaceArtifacts }; + +if (require.main === module) { + try { + const result = pruneWorkspaceArtifacts({ + workspace: path.join(process.env.GITHUB_WORKSPACE, 'apps', 'desktop'), + profile: process.env.INPUT_PROFILE, + }); + console.log( + `Pruned Cargo workspace products: ${result.fingerprints} fingerprints, ` + + `${result.buildDirectories} build directories, ${result.dependencyFiles} artifact files, ` + + `${result.bytes} bytes. Dependency build scripts and proc-macros are retained.` + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/.github/actions/windows-build-cache-prune/main.test.cjs b/.github/actions/windows-build-cache-prune/main.test.cjs new file mode 100644 index 0000000000..7c80b3b148 --- /dev/null +++ b/.github/actions/windows-build-cache-prune/main.test.cjs @@ -0,0 +1,127 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { pruneWorkspaceArtifacts } = require('./main.cjs'); + +const LIB = '1111111111111111'; +const APP = '2222222222222222'; +const HOSTING = '3333333333333333'; +const BUILD = '4444444444444444'; +const DEPENDENCY = 'aaaaaaaaaaaaaaaa'; + +function fixture(t, profile = 'debug') { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-cache-prune-')); + t.after(() => fs.rmSync(workspace, { recursive: true, force: true })); + function write(relative, contents = 'artifact') { + const filename = path.join(workspace, relative); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, contents); + return filename; + } + write('probe/Cargo.toml', '[package]\nname = "agent-relay-probe"\nversion = "0.0.0"\n'); + const prefix = `probe/target/${profile}`; + return { workspace, prefix, write, exists: (relative) => fs.existsSync(path.join(workspace, relative)) }; +} + +test('removes workspace libraries, binaries and tests while preserving dependency products', (t) => { + const f = fixture(t); + for (const hash of [LIB, APP, HOSTING, BUILD]) { + f.write(`${f.prefix}/.fingerprint/agent-relay-probe-${hash}/marker`); + } + f.write(`${f.prefix}/build/agent-relay-probe-${BUILD}/build-script-build.exe`); + const removed = [ + `libagent_relay_probe-${LIB}.rlib`, + `libagent_relay_probe-${LIB}.rmeta`, + `agent_relay_probe-${LIB}.d`, + `agent_relay-${APP}.exe`, + `agent_relay-${APP}.pdb`, + `linux_app_hosting-${HOSTING}.exe`, + `linux_app_hosting-${HOSTING}.d`, + ]; + for (const name of removed) f.write(`${f.prefix}/deps/${name}`); + const retained = [ + `.fingerprint/gtk4-${DEPENDENCY}/marker`, + `build/gtk4-sys-${DEPENDENCY}/build-script-build.exe`, + `deps/libgtk4-${DEPENDENCY}.rlib`, + `deps/glib_macros-${DEPENDENCY}.dll`, + `deps/build_script_build-${DEPENDENCY}.exe`, + // Similar package names and non-Cargo-looking names are not ours. + `.fingerprint/agent-relay-probe-helper-${DEPENDENCY}/marker`, + `deps/unrelated-${DEPENDENCY}.exe`, + `deps/readme-${LIB}-extra.txt`, + ]; + for (const name of retained) f.write(`${f.prefix}/${name}`); + f.write('probe/src/keep.rs', 'source'); + f.write(`probe/target/release/deps/agent_relay-${APP}.exe`); + const result = pruneWorkspaceArtifacts({ workspace: f.workspace, profile: 'debug' }); + assert.deepEqual(result, { fingerprints: 4, buildDirectories: 1, dependencyFiles: 7, bytes: 96 }); + for (const name of removed) assert.equal(f.exists(`${f.prefix}/deps/${name}`), false); + for (const name of retained) assert.equal(f.exists(`${f.prefix}/${name}`), true); + assert.equal(f.exists('probe/src/keep.rs'), true); + assert.equal(f.exists(`probe/target/release/deps/agent_relay-${APP}.exe`), true); +}); + +test('release profile accepts orphaned workspace build outputs without deleting unidentified dependencies', (t) => { + const f = fixture(t, 'release'); + f.write(`${f.prefix}/build/agent-relay-probe-${BUILD}/out/resource.o`); + f.write(`${f.prefix}/deps/agent_relay_probe-${LIB}.exe`); + const result = pruneWorkspaceArtifacts({ workspace: f.workspace, profile: 'release' }); + assert.equal(result.buildDirectories, 1); + assert.equal(result.dependencyFiles, 0); + assert.equal(f.exists(`${f.prefix}/deps/agent_relay_probe-${LIB}.exe`), true); +}); + +test('missing build or artifact directories are valid', (t) => { + const f = fixture(t); + assert.deepEqual(pruneWorkspaceArtifacts({ workspace: f.workspace, profile: 'debug' }), { + fingerprints: 0, + buildDirectories: 0, + dependencyFiles: 0, + bytes: 0, + }); + fs.mkdirSync(path.join(f.workspace, f.prefix), { recursive: true }); + assert.equal(pruneWorkspaceArtifacts({ workspace: f.workspace, profile: 'debug' }).bytes, 0); +}); + +test('invalid profiles and unsafe manifest names are refused', (t) => { + const f = fixture(t); + for (const profile of ['', '../src', 'debug/../../src', 'Debug', undefined]) { + assert.throws(() => pruneWorkspaceArtifacts({ workspace: f.workspace, profile }), /profile/); + } + f.write('probe/Cargo.toml', '[package]\nname = "../outside"\n'); + assert.throws(() => pruneWorkspaceArtifacts({ workspace: f.workspace, profile: 'debug' }), /safe name/); +}); + +for (const parent of ['probe', 'probe/target', 'probe/target/debug', 'probe/target/debug/deps']) { + test(`refuses symlink build parent ${parent}`, (t) => { + const f = fixture(t); + const outside = path.join(f.workspace, 'outside'); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'keep'), 'outside'); + const filename = path.join(f.workspace, parent); + fs.rmSync(filename, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.symlinkSync(outside, filename, 'junction'); + assert.throws( + () => pruneWorkspaceArtifacts({ workspace: f.workspace, profile: 'debug' }), + /unsafe|checked-out/ + ); + assert.equal(fs.readFileSync(path.join(outside, 'keep'), 'utf8'), 'outside'); + }); +} + +test('refuses symlink products before removing any planned workspace files', (t) => { + const f = fixture(t); + const marker = f.write(`${f.prefix}/.fingerprint/agent-relay-probe-${LIB}/marker`); + const outside = f.write('outside', 'keep'); + const product = path.join(f.workspace, f.prefix, 'deps', `agent_relay_probe-${LIB}.exe`); + fs.mkdirSync(path.dirname(product)); + fs.symlinkSync(outside, product, 'file'); + assert.throws(() => pruneWorkspaceArtifacts({ workspace: f.workspace, profile: 'debug' }), /unsafe/); + assert.equal(fs.existsSync(marker), true); + assert.equal(fs.readFileSync(outside, 'utf8'), 'keep'); +}); diff --git a/.github/actions/windows-build-cache/action.yml b/.github/actions/windows-build-cache/action.yml new file mode 100644 index 0000000000..16af3a6aa1 --- /dev/null +++ b/.github/actions/windows-build-cache/action.yml @@ -0,0 +1,48 @@ +name: Windows Cargo dependency cache +description: Reuse dependencies across commits without accumulating workspace binaries or test executables. +inputs: + arch: + description: Runner architecture + required: true + profile: + description: Cargo output directory (debug or release) + required: true + toolchain: + description: Installed compiler and native dependency fingerprint + required: true +outputs: + cache-hit: + description: Whether the exact dependency cache already exists + value: ${{ steps.cache.outputs.cache-hit }} +runs: + using: composite + steps: + - name: Fingerprint Cargo profile settings + id: settings + shell: msys2 {0} + run: | + identity=$(env | LC_ALL=C sort | sed -n '/^CARGO_PROFILE_/p; /^CARGO_INCREMENTAL=/p; /^RUSTFLAGS=/p; /^CARGO_ENCODED_RUSTFLAGS=/p' | sha256sum | cut -d ' ' -f 1) + echo "identity=$identity" >> "$(cygpath -u "$GITHUB_OUTPUT")" + - name: Restore reusable dependencies + id: cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # No source SHA: workspace products are pruned before the first save, + # so an unchanged dependency/toolchain/profile graph reuses this entry. + # Callers must run windows-build-cache-prune after a successful build + # on a miss, before this action's automatic post-job save. + path: | + apps/desktop/probe/target/${{ inputs.profile }}/build + apps/desktop/probe/target/${{ inputs.profile }}/deps + apps/desktop/probe/target/${{ inputs.profile }}/.fingerprint + key: windows-deps-v3-${{ inputs.arch }}-${{ inputs.profile }}-${{ inputs.toolchain }}-${{ steps.settings.outputs.identity }}-${{ hashFiles('apps/desktop/probe/Cargo.lock', 'apps/desktop/probe/Cargo.toml') }} + restore-keys: | + windows-deps-v3-${{ inputs.arch }}-${{ inputs.profile }}-${{ inputs.toolchain }}-${{ steps.settings.outputs.identity }}- + - name: Report dependency cache reuse + shell: bash + env: + CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} + CACHE_ARCH: ${{ inputs.arch }} + CACHE_PROFILE: ${{ inputs.profile }} + run: | + echo "Windows $CACHE_ARCH $CACHE_PROFILE dependencies: exact cache hit=${CACHE_HIT:-false}" | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/windows-toolchain/action.yml b/.github/actions/windows-toolchain/action.yml new file mode 100644 index 0000000000..d4a20ad183 --- /dev/null +++ b/.github/actions/windows-toolchain/action.yml @@ -0,0 +1,44 @@ +name: Windows Rust and GTK toolchain +description: Install MSYS2 and cache downloaded Cargo sources independently of build profiles. +inputs: + msystem: + description: MSYS2 environment matching the runner architecture + required: true +outputs: + identity: + description: Fingerprint of the installed compiler and native dependencies + value: ${{ steps.identity.outputs.value }} +runs: + using: composite + steps: + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 + with: + msystem: ${{ inputs.msystem }} + update: true + install: zip unzip git + pacboy: >- + toolchain:p + rust:p + gtk4:p + pkgconf:p + librsvg:p + ntldd:p + - name: Fingerprint the toolchain and allow long paths + id: identity + shell: msys2 {0} + run: | + git config --global core.longpaths true + rustc -vV + cargo -V + pkgconf --modversion gtk4 + identity=$( { rustc -vV; clang --version; pacman -Q; } | sha256sum | cut -d ' ' -f 1) + echo "value=$identity" >> "$(cygpath -u "$GITHUB_OUTPUT")" + - name: Cache downloaded Cargo sources + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + C:\ch\registry\index + C:\ch\registry\cache + C:\ch\git\db + key: windows-cargo-sources-v2-${{ hashFiles('apps/desktop/probe/Cargo.lock') }} + restore-keys: windows-cargo-sources-v2- diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml new file mode 100644 index 0000000000..ce27b04d92 --- /dev/null +++ b/.github/workflows/desktop-ci.yml @@ -0,0 +1,1159 @@ +name: Desktop CI + +# Ordinary changes use representative native architectures. Release calls +# this workflow for the full test matrix before building signed packages. + +on: + pull_request: + # Default types only: an `edited` event whose jobs all skip would replace a + # failed run's checks on the same commit. A PR retargeted onto main gets CI + # on its next push, or by closing and reopening it. + branches: + - main + paths: + - 'apps/desktop/**' + - '.github/actions/**' + - '.github/workflows/desktop-ci.yml' + - '.github/workflows/desktop-release.yml' + - '.github/workflows/ios-testflight.yml' + - '.github/workflows/mobile-ci.yml' + push: + branches: + - main + paths: + - 'apps/desktop/**' + - '.github/actions/**' + - '.github/workflows/desktop-ci.yml' + - '.github/workflows/desktop-release.yml' + - '.github/workflows/ios-testflight.yml' + - '.github/workflows/mobile-ci.yml' + + workflow_call: + inputs: + full-matrix: + description: Validate both supported architectures on every platform + type: boolean + default: false + force-all: + description: Run every component regardless of changed paths + type: boolean + default: false + skip-packages: + description: Run tests only; the Release caller builds the real packages afterwards + type: boolean + default: false + probe-version: + description: Version embedded in the Linux core shared with package and test jobs + type: string + default: '0.0.0' + secrets: + POSTHOG_API_KEY: + description: Analytics ingestion key embedded only in release cores + required: false + POSTHOG_HOST: + description: Analytics ingestion host embedded only in release cores + required: false + workflow_dispatch: + inputs: + full-matrix: + description: Validate both supported architectures on every platform + type: boolean + default: false + force-all: + description: Run every component regardless of changed paths + type: boolean + default: false + skip-packages: + description: Run tests only; the Release caller builds the real packages afterwards + type: boolean + default: false + probe-version: + description: Version embedded in the Linux core shared with package and test jobs + type: string + default: '0.0.0' + +concurrency: + # PR runs collapse per pull request; push runs key off the commit sha so two + # merges landing together cannot cancel each other. + # github.workflow is the caller name for reusable runs: keep this group + # distinct from the caller's release lock and from ordinary CI. + group: ci-checks-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('{0}-{1}', github.event_name, github.sha) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Nothing in CI writes to the repository. +permissions: + contents: read + +env: + CI_PROBE_VERSION: ${{ inputs.probe-version || '0.0.0' }} + +defaults: + run: + working-directory: apps/desktop + +jobs: + # Runs for every pull request, including forks: GitHub-hosted, read-only + # and without secrets. + open-source: + name: Public source check + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Check nothing internal is in the public source + run: bash scripts/check-open-source.sh + + # Never run untrusted fork code on repository-configured runners. This small + # GitHub-hosted lane still gives outside contributors useful checks. + fork-checks: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + name: Public contribution checks + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + + - name: Check the relay core + run: cargo check --manifest-path probe/Cargo.toml --locked + + - name: Test the release notes tooling + run: python3 scripts/test_release_notes.py + + changes: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + name: Changed components + runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + outputs: + shell: ${{ steps.changes.outputs.shell }} + probe: ${{ steps.changes.outputs.probe }} + macos: ${{ steps.changes.outputs.macos }} + macos_core: ${{ steps.changes.outputs.macos_core }} + macos_package: ${{ steps.changes.outputs.macos_package }} + macos-matrix: ${{ steps.matrices.outputs.macos }} + linux-matrix: ${{ steps.matrices.outputs.linux }} + linux-probe-matrix: ${{ steps.matrices.outputs.linux_probe }} + windows-matrix: ${{ steps.matrices.outputs.windows }} + linux: ${{ steps.changes.outputs.linux }} + linux_core: ${{ steps.changes.outputs.linux_core }} + linux_integration: ${{ steps.changes.outputs.linux_integration }} + linux_package: ${{ steps.changes.outputs.linux_package }} + windows: ${{ steps.changes.outputs.windows }} + windows_package: ${{ steps.changes.outputs.windows_package }} + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + # Fetch authenticated history during checkout, before credentials are removed. + fetch-depth: 0 + + - name: Select native architectures + id: matrices + env: + FULL_MATRIX: ${{ inputs.full-matrix }} + LINUX_RUNNER: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + LINUX_ARM64_RUNNER: ${{ vars.CI_LINUX_ARM64_RUNNER || 'ubuntu-24.04-arm' }} + LINUX_2204_RUNNER: ${{ vars.CI_LINUX_2204_RUNNER || 'ubuntu-22.04' }} + LINUX_2204_ARM64_RUNNER: ${{ vars.CI_LINUX_2204_ARM64_RUNNER || 'ubuntu-22.04-arm' }} + run: | + python3 - <<'PY_MATRIX' + import json + import os + + full = os.environ["FULL_MATRIX"] == "true" + matrices = { + "macos": [ + {"runner": "macos-15", "arch": "arm64", "rust_target": "aarch64-apple-darwin"}, + {"runner": "macos-15-intel", "arch": "x64", "rust_target": "x86_64-apple-darwin"}, + ], + "linux": [ + {"runner": os.environ["LINUX_2204_RUNNER"], "arch": "x64", "rust_target": "x86_64-unknown-linux-gnu"}, + {"runner": os.environ["LINUX_2204_ARM64_RUNNER"], "arch": "arm64", "rust_target": "aarch64-unknown-linux-gnu"}, + ], + "linux_probe": [ + {"runner": os.environ["LINUX_RUNNER"], "arch": "x64", "rust_target": "x86_64-unknown-linux-gnu", "zig_host": "x86_64-linux", "zig_sha256": "d45312e61ebcc48032b77bc4cf7fd6915c11fa16e4aad116b66c9468211230ea"}, + {"runner": os.environ["LINUX_ARM64_RUNNER"], "arch": "arm64", "rust_target": "aarch64-unknown-linux-gnu", "zig_host": "aarch64-linux", "zig_sha256": "041ac42323837eb5624068acd8b00cd5777dac4cf91179e8dad7a7e90dd0c556"}, + ], + "windows": [ + {"runner": "windows-latest", "arch": "x64", "msystem": "CLANG64"}, + {"runner": "windows-11-arm", "arch": "arm64", "msystem": "CLANGARM64"}, + ], + } + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + for name, rows in matrices.items(): + selected = rows if full else rows[:1] + print(f"{name}={json.dumps(selected, separators=(',', ':'))}", file=output) + PY_MATRIX + + - name: Detect changed components + id: changes + working-directory: ${{ github.workspace }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.sha }} + FORCE_ALL: ${{ inputs.force-all }} + run: | + # Compare the event base with the tested commit (the merge commit on + # PRs). Missing or unavailable history conservatively runs all jobs. + if [ "$FORCE_ALL" = true ]; then + python3 apps/desktop/scripts/ci-changes.py --all >> "$GITHUB_OUTPUT" + elif [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ && "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + python3 apps/desktop/scripts/ci-changes.py --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT" + else + python3 apps/desktop/scripts/ci-changes.py --base unavailable --head unavailable >> "$GITHUB_OUTPUT" + fi + + shellcheck: + needs: changes + if: needs.changes.outputs.shell == 'true' + name: Shell scripts + runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + permissions: + contents: read + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install shellcheck + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends shellcheck + + - name: Lint the packaging scripts + run: shellcheck scripts/*.sh linux/agent-relay.postinst + + - name: Test probe bridge validation + run: bash scripts/test-probe-bridge.sh + + - name: Test CI change detection + run: python3 scripts/test_ci_changes.py + + - name: Test the release notes tooling + run: python3 scripts/test_release_notes.py + + - name: Test the Linux probe glibc floor check + run: bash scripts/test-glibc-floor.sh + + - name: Test the Linux installer + run: bash scripts/test-linux-install.sh + + probe: + needs: changes + if: needs.changes.outputs.probe == 'true' + name: Probe + # The native identity and filesystem-permission tests intentionally reject + # root-owned sessions. Keep this suite on GitHub's non-root hosted runner; + # repository-configured build runners may execute their jobs as uid 0. + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + + - name: Test Cargo binary target selection + run: python3 scripts/test_cargo_target_selection.py + + - name: Cache the probe build + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + with: + workspaces: apps/desktop/probe + + - name: Set up Node for Pi compatibility tests + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + + - name: Test dependency cache pruning + run: node --test "$GITHUB_WORKSPACE/.github/actions/windows-build-cache-prune/main.test.cjs" + + - name: Test live Pi sessions (legacy and current packages) + run: bash scripts/test-pi-live.sh + + - name: Test the native Claude mobile bridge + run: node --test scripts/test-claude-mobile-plugin.mjs scripts/mobile-build-inventory.test.mjs + + - name: Test the probe + run: cargo test --manifest-path probe/Cargo.toml --locked + + # Code-quality ratchet: probe/rust-oleum.toml grandfathers existing + # violations; a new one, or a regression past its ceiling, fails here. + - name: Install rust-oleum + run: cargo install rust-oleum --version 0.1.3 --locked + + - name: Check the code-quality ratchet + working-directory: apps/desktop/probe + run: rust-oleum --github-summary "$GITHUB_STEP_SUMMARY" + + # The Swift contract types are generated from the Rust registry; a + # stale checked-in file fails here. + - name: Check the generated contract + run: | + scripts/generate-contract.sh + git diff --exit-code + + linux-core: + needs: changes + if: needs.changes.outputs.linux_core == 'true' + name: Linux core (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.linux-probe-matrix) }} + env: + GLIBC_FLOOR: '2.34' + ZIG_VERSION: '0.13.0' + CARGO_ZIGBUILD_VERSION: '0.23.4' + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + targets: ${{ matrix.rust_target }} + + - name: Cache the Zig-built probe + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + with: + # Retain the old key+job namespace after renaming the producer. + shared-key: ci-linux-probe-zig-v1-${{ matrix.arch }}-linux-probe-glibc + workspaces: apps/desktop/probe + + - name: Install zig and cargo-zigbuild + env: + ZIG_HOST: ${{ matrix.zig_host }} + ZIG_SHA256: ${{ matrix.zig_sha256 }} + run: | + set -euo pipefail + archive="zig-linux-${ZIG_HOST%-linux}-$ZIG_VERSION.tar.xz" + curl -fsSL "https://ziglang.org/download/$ZIG_VERSION/$archive" -o "$RUNNER_TEMP/$archive" + echo "$ZIG_SHA256 $RUNNER_TEMP/$archive" | sha256sum -c - + tar -xJf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/${archive%.tar.xz}" >> "$GITHUB_PATH" + cargo install --locked cargo-zigbuild --version "$CARGO_ZIGBUILD_VERSION" + + - name: Build the probe against glibc 2.34 + env: + # Set only when Desktop Release calls this workflow; PR and fork + # builds embed no analytics configuration. + AGENT_RELAY_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + AGENT_RELAY_POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} + run: | + scripts/build-probe.sh \ + --target '${{ matrix.rust_target }}' \ + --version "$CI_PROBE_VERSION" \ + --glibc "$GLIBC_FLOOR" \ + --out probe-build + + - name: Check the probe's glibc floor + run: scripts/check-glibc-floor.sh probe-build/agent-relay-probe --max "$GLIBC_FLOOR" + + - name: Record the checked core identity + env: + RUST_TARGET: ${{ matrix.rust_target }} + run: | + printf '%s\n' "$GITHUB_SHA" > probe-build/source-revision.txt + printf '%s\n' "$RUST_TARGET" > probe-build/rust-target.txt + + - name: Upload the checked Linux core + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + # Prefix avoids the release publisher's linux-* package pattern. + name: ci-linux-core-${{ matrix.arch }}-${{ github.sha }} + path: apps/desktop/probe-build/ + if-no-files-found: error + overwrite: true + retention-days: 7 + + macos-package: + needs: changes + if: needs.changes.outputs.macos_package == 'true' && !inputs.skip-packages + name: macOS package (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.macos-matrix) }} + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Cache the Swift release build + uses: ./.github/actions/swift-cache + with: + arch: ${{ matrix.arch }} + lane: package + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + targets: ${{ matrix.rust_target }} + + - name: Cache the probe build + uses: ./.github/actions/rust-lane-cache + with: + legacy-key: probe-${{ matrix.arch }}-macos + lane: package + discard-profile: debug + + - name: Build the probe + run: scripts/build-probe.sh --target '${{ matrix.rust_target }}' --version 0.0.0 --out probe-build + + # No signing secrets in CI: build-app.sh ad-hoc signs and says so. + - name: Assemble the app bundle + run: | + set -euo pipefail + scripts/build-app.sh \ + --arch '${{ matrix.arch }}' \ + --version 0.0.0 \ + --probe probe-build \ + --out dist + + - name: Set up Python for disk image tooling + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + + - name: Install disk image tooling + run: | + python3 -m venv "$RUNNER_TEMP/dmg-tools" + "$RUNNER_TEMP/dmg-tools/bin/python" -m pip install -r scripts/dmg-requirements.txt + echo "$RUNNER_TEMP/dmg-tools/bin" >> "$GITHUB_PATH" + + - name: Package a disk image (smoke test) + run: | + set -euo pipefail + scripts/package-dmg.sh \ + --app 'dist/Agent Relay.app' \ + --out 'dist/AgentRelay-CI-${{ matrix.arch }}.dmg' + + - name: Zip the app bundle + run: ditto -c -k --keepParent 'dist/Agent Relay.app' "AgentRelay-macOS-${{ matrix.arch }}.zip" + + - name: Upload the app bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: AgentRelay-macOS-${{ matrix.arch }} + path: apps/desktop/AgentRelay-macOS-${{ matrix.arch }}.zip + if-no-files-found: error + retention-days: 7 + + macos-swift-tests: + needs: changes + if: needs.changes.outputs.macos == 'true' + name: macOS Swift tests (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.macos-matrix) }} + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Cache the Swift test build + uses: ./.github/actions/swift-cache + with: + arch: ${{ matrix.arch }} + lane: tests + + # RelayDesktopTests depends on the executable target, so this also + # compiles the app entrypoint when unsigned packaging is not selected. + - name: Test the Swift package + env: + REQUEST_RENDER_DIR: ${{ runner.temp }}/desktop-ui-fixtures + SESSION_COLUMNS_PREVIEW: ${{ runner.temp }}/desktop-ui-fixtures + run: | + mkdir -p "$SESSION_COLUMNS_PREVIEW" + swift test --package-path macos + + - name: Test the shared session overview package + run: swift test --package-path shared/RelaySessionOverview + + - name: Upload Swift rendering fixtures + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: Swift-rendering-${{ matrix.arch }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/desktop-ui-fixtures + if-no-files-found: ignore + retention-days: 1 + + macos-core-tests: + needs: changes + if: needs.changes.outputs.macos_core == 'true' + name: macOS core tests (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.macos-matrix) }} + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + targets: ${{ matrix.rust_target }} + + - name: Cache the probe build + uses: ./.github/actions/rust-lane-cache + with: + legacy-key: probe-${{ matrix.arch }}-macos + lane: tests + discard-profile: release + + - name: Test managed Muse delivery + run: cargo test --manifest-path probe/Cargo.toml --locked --lib relay::harness::muse + + - name: Test core diagnostic logs + run: cargo test --manifest-path probe/Cargo.toml --locked --lib relay::diagnostics + + - name: Set up Node for Pi compatibility tests + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + + - name: Test live Pi sessions (legacy and current packages) + run: bash scripts/test-pi-live.sh + + windows-package: + needs: changes + if: needs.changes.outputs.windows_package == 'true' && !inputs.skip-packages + name: Windows package (${{ matrix.arch }}) + # The Linux GTK app, built natively with MSYS2's clang toolchain and GTK 4 + # for each architecture. Windows runners have a desktop session, so the + # GTK harness runs without a virtual display. + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.windows-matrix) }} + defaults: + run: + shell: msys2 {0} + working-directory: apps/desktop + env: + # A short cargo home: RelayHistory's fixtures hold paths close to + # Windows' 260-character limit, which a cargo home inside the + # workspace pushed over it when cargo checked the dependency out. + CARGO_HOME: C:\ch + # Git itself fetches the git dependencies, honouring core.longpaths. + CARGO_NET_GIT_FETCH_WITH_CLI: 'true' + CARGO_TERM_COLOR: always + # These caches omit incremental state; do not spend time producing it. + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + steps: + # Scripts and fixtures keep their LF line endings, and long paths + # check out. + - name: Keep line endings and allow long paths + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + git config --global core.autocrlf false + git config --global core.longpaths true + + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the Windows toolchain + id: toolchain + uses: ./.github/actions/windows-toolchain + with: + msystem: ${{ matrix.msystem }} + + - name: Cache the Cargo release dependencies + id: build-cache + uses: ./.github/actions/windows-build-cache + with: + arch: ${{ matrix.arch }} + profile: release + toolchain: ${{ steps.toolchain.outputs.identity }} + + # The Setup .exe is compiled with Inno Setup 6. windows-latest carries + # it; windows-11-arm may not, so it is installed there. + - name: Install Inno Setup + shell: pwsh + run: | + $iscc = 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' + if (-not (Test-Path $iscc)) { + if (Get-Command choco -ErrorAction SilentlyContinue) { + choco install innosetup --no-progress -y + } else { + winget install --id JRSoftware.InnoSetup -e --scope machine --silent --accept-source-agreements --accept-package-agreements --disable-interactivity + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + Get-Item $iscc | Format-List FullName, VersionInfo + + # Builds the release core and app (icon and version embedded) and + # packages them with the GTK runtime, as zips and the Setup .exe. + - name: Build the Windows package + run: scripts/build-windows.sh --version 0.0.0 --arch '${{ matrix.arch }}' --out out --installer + + - name: Check the core's bridge commands + run: bash scripts/check-probe-bridge.sh probe/target/release/agent-relay-probe.exe + + # The packaged core runs with only the DLLs the zip carries. + - name: Smoke test the packaged core + run: | + unzip -l 'out/AgentRelay-0.0.0-Windows-${{ matrix.arch }}.zip' | tail -n 1 + unpacked="$(cygpath -u "$RUNNER_TEMP")/unpacked" + rm -rf "$unpacked" && mkdir -p "$unpacked" + unzip -q 'out/AgentRelay-0.0.0-Windows-${{ matrix.arch }}.zip' -d "$unpacked" + PATH=/c/Windows/System32 "$unpacked/AgentRelay-Windows-${{ matrix.arch }}/bin/agent-relay-probe.exe" --version + + # Setup installs per user where the updater expects the tree, and its + # uninstaller removes the install, the shortcut and the entry. + - name: Install and uninstall with Setup + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $setup = "out\AgentRelay-0.0.0-Windows-${{ matrix.arch }}-Setup.exe" + Start-Process $setup -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait + $root = "$env:LOCALAPPDATA\Programs\AgentRelay" + $shortcut = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Agent Relay.lnk" + $entry = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{C5377993-AE99-466F-A519-676A299A6C6D}_is1' + foreach ($path in "$root\0.0.0\AgentRelay-Windows-${{ matrix.arch }}\bin\agent-relay-desktop.exe", "$root\unins000.exe", $shortcut, $entry) { + if (-not (Test-Path $path)) { throw "Setup did not create $path" } + } + Get-ItemProperty $entry | Format-List DisplayName, DisplayVersion, InstallLocation + Start-Process "$root\unins000.exe" -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait + # The uninstaller finishes removing its own folder just after it exits. + for ($i = 0; $i -lt 30 -and (Test-Path $root); $i++) { Start-Sleep -Seconds 1 } + foreach ($path in $root, $shortcut, $entry, 'HKCU:\Software\Classes\agentrelay') { + if (Test-Path $path) { throw "The uninstaller left $path" } + } + + - name: Upload the Windows package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: AgentRelay-Windows-${{ matrix.arch }} + path: | + apps/desktop/out/*.zip + apps/desktop/out/*.zip.sha256 + apps/desktop/out/*-Setup.exe + apps/desktop/out/*-Setup.exe.sha256 + if-no-files-found: error + retention-days: 7 + + - name: Keep only reusable release dependencies in the cache + if: steps.build-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/windows-build-cache-prune + with: + profile: release + + windows-tests: + needs: changes + if: needs.changes.outputs.windows == 'true' + name: Windows tests (${{ matrix.arch }}) + # The Linux GTK app, built natively with MSYS2's clang toolchain and GTK 4 + # for each architecture. Windows runners have a desktop session, so the + # GTK harness runs without a virtual display. + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.windows-matrix) }} + defaults: + run: + shell: msys2 {0} + working-directory: apps/desktop + env: + # A short cargo home: RelayHistory's fixtures hold paths close to + # Windows' 260-character limit, which a cargo home inside the + # workspace pushed over it when cargo checked the dependency out. + CARGO_HOME: C:\ch + # Git itself fetches the git dependencies, honouring core.longpaths. + CARGO_NET_GIT_FETCH_WITH_CLI: 'true' + CARGO_TERM_COLOR: always + # These caches omit incremental state; do not spend time producing it. + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + # Keep runtime checks while avoiding debug-symbol generation/linking and + # large symbol archives in CI. Local developer profiles are unchanged. + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_PROFILE_TEST_DEBUG: '0' + CARGO_PROFILE_DEV_DEBUG_ASSERTIONS: 'true' + CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true' + CARGO_PROFILE_DEV_OVERFLOW_CHECKS: 'true' + CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true' + steps: + # Scripts and fixtures keep their LF line endings, and long paths + # check out. + - name: Keep line endings and allow long paths + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + git config --global core.autocrlf false + git config --global core.longpaths true + + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the Windows toolchain + id: toolchain + uses: ./.github/actions/windows-toolchain + with: + msystem: ${{ matrix.msystem }} + + - name: Cache the Cargo test dependencies + id: build-cache + uses: ./.github/actions/windows-build-cache + with: + arch: ${{ matrix.arch }} + profile: debug + toolchain: ${{ steps.toolchain.outputs.identity }} + + - name: Test the platform file helpers + run: 'cargo test --manifest-path probe/Cargo.toml --locked --no-default-features --features gtk --lib platform::' + + - name: Test the app's wording and state + run: cargo test --manifest-path probe/Cargo.toml --locked --no-default-features --features gtk --lib linux_app + + - name: Test the app's window + run: cargo test --manifest-path probe/Cargo.toml --locked --no-default-features --features gtk --test linux_app_gtk + + - name: Test the app's hosting of the core + run: cargo test --manifest-path probe/Cargo.toml --locked --no-default-features --features gtk --test linux_app_hosting + + # Packaging already compiles and links the entrypoint when selected. + # Otherwise keep all Windows entrypoints checked, without + # making integration tests link production binaries they never execute. + - name: Check the Windows entrypoints + if: needs.changes.outputs.windows_package != 'true' || inputs.skip-packages + run: cargo check --manifest-path probe/Cargo.toml --locked --features gtk --bins + + - name: Keep only reusable test dependencies in the cache + if: steps.build-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/windows-build-cache-prune + with: + profile: debug + + linux-package: + needs: [changes, linux-core] + if: needs.changes.outputs.linux_package == 'true' && !inputs.skip-packages + name: Linux package (${{ matrix.arch }}) + # Ubuntu 22.04 ships GTK 4.6, the oldest the app runs on: the app is + # built and tested against it, natively for each architecture. + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.linux-matrix) }} + env: + GLIBC_FLOOR: '2.34' + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install GTK 4 and Xvfb + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libgtk-4-dev librsvg2-common pkg-config xvfb + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + targets: ${{ matrix.rust_target }} + + - name: Cache the probe build + uses: ./.github/actions/rust-lane-cache + with: + legacy-key: linux-gtk4-${{ matrix.arch }}-linux + lane: package + discard-profile: debug + + - name: Download the checked Linux core + uses: ./.github/actions/linux-core-artifact + with: + arch: ${{ matrix.arch }} + rust-target: ${{ matrix.rust_target }} + probe-version: ${{ env.CI_PROBE_VERSION }} + + - name: Build the app + env: + AGENT_RELAY_PROBE_VERSION: ${{ env.CI_PROBE_VERSION }} + run: cargo build --manifest-path probe/Cargo.toml --locked --release --features gtk --bin agent-relay-desktop + + - name: Check the app's glibc floor + run: scripts/check-glibc-floor.sh probe/target/release/agent-relay-desktop --max "$GLIBC_FLOOR" + + - name: Build Linux packages + run: | + scripts/build-linux.sh --version "$CI_PROBE_VERSION" --arch '${{ matrix.arch }}' \ + --app probe/target/release/agent-relay-desktop --probe probe-build/agent-relay-probe --out out + + - name: Upload Linux packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: AgentRelay-Linux-${{ matrix.arch }} + path: | + apps/desktop/out/*.tar.gz + apps/desktop/out/*.tar.gz.sha256 + apps/desktop/out/*.deb + apps/desktop/out/*.deb.sha256 + if-no-files-found: error + retention-days: 7 + linux-tests: + needs: [changes, linux-core] + # Unlike the default needs-success rule, allow a deliberately skipped core + # for harness-only changes. Never run after a failed selector/core build. + if: >- + always() && !cancelled() && needs.changes.result == 'success' && + (needs.changes.outputs.linux == 'true' || needs.changes.outputs.linux_integration == 'true') && + ( + (needs.changes.outputs.linux_core == 'true' && needs.linux-core.result == 'success') || + (needs.changes.outputs.linux_core == 'false' && needs.linux-core.result == 'skipped' && needs.changes.outputs.linux_integration == 'false') + ) + name: Linux tests (${{ matrix.arch }}) + # Ubuntu 22.04 ships GTK 4.6, the oldest the app runs on: the app is + # tested against it. Selected GTK and integration checks share one build. + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.linux-matrix) }} + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install GTK 4 and Xvfb + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libgtk-4-dev librsvg2-common pkg-config xvfb + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + targets: ${{ matrix.rust_target }} + + - name: Cache the probe build + uses: ./.github/actions/rust-lane-cache + with: + legacy-key: linux-gtk4-${{ matrix.arch }}-linux + lane: tests + discard-profile: release + + - name: Download the checked Linux core + if: needs.changes.outputs.linux_integration == 'true' + uses: ./.github/actions/linux-core-artifact + with: + arch: ${{ matrix.arch }} + rust-target: ${{ matrix.rust_target }} + probe-version: ${{ env.CI_PROBE_VERSION }} + + # Integration-selected runs use the same default-feature GTK graph for + # all test targets. Harness-only runs opt out of unused production bins. + - name: Test the app's wording and state + if: needs.changes.outputs.linux == 'true' + run: cargo test --manifest-path probe/Cargo.toml --locked ${{ needs.changes.outputs.linux_integration != 'true' && '--no-default-features' || '' }} --features gtk --lib linux_app + + - name: Test the app's window + if: needs.changes.outputs.linux == 'true' + run: xvfb-run -a cargo test --manifest-path probe/Cargo.toml --locked ${{ needs.changes.outputs.linux_integration != 'true' && '--no-default-features' || '' }} --features gtk --test linux_app_gtk + + # The real core binary, driven by the app's bridge client against fake + # Relaycast and Cloud in an empty home. + # Cargo builds enabled binaries for this integration target, including + # the app and fake core that the following startup smoke check executes. + - name: Test the app against the real core + if: needs.changes.outputs.linux_integration == 'true' + env: + RELAY_E2E_PROBE: ${{ github.workspace }}/apps/desktop/probe-build/agent-relay-probe + run: cargo test --manifest-path probe/Cargo.toml --locked --features gtk --test linux_app_core_e2e -- --nocapture + + # The app starts with no display server of its own, hosts the fake + # core, and subscribes to it. + - name: Smoke test the app's startup + if: needs.changes.outputs.linux_integration == 'true' + env: + RELAY_DESKTOP_PROBE: ${{ github.workspace }}/apps/desktop/probe/target/debug/agent-relay-fake-core + AGENT_RELAY_CORE_SOCKET: ${{ runner.temp }}/gtk-core/core.sock + FAKE_CORE_LOG: ${{ runner.temp }}/gtk-fake-core-calls.txt + # The app runs in its own process group with its display, and the + # group stops afterwards: an app left running keeps + # com.agentrelay.desktop on the session bus, so the next app to start + # hands its command line to it and exits. The hosted core has its own + # group and stops when the app's end closes its stdin. + run: | + setsid xvfb-run --auto-servernum ./probe/target/debug/agent-relay-desktop --background & + app=$! + for _ in $(seq 240); do + grep -qx subscribe "$FAKE_CORE_LOG" 2>/dev/null && grep -qx telemetry.record "$FAKE_CORE_LOG" && break + kill -0 "$app" || break + sleep 0.25 + done + cat "$FAKE_CORE_LOG" || true + grep -qx subscribe "$FAKE_CORE_LOG" + grep -qx telemetry.record "$FAKE_CORE_LOG" + # Still running: startup did not crash after the handshake. + sleep 2 + kill -0 "$app" + kill -- -"$app" + wait "$app" || true + # The app is xvfb-run's child: it ends, and is reaped, after + # xvfb-run does. + for _ in $(seq 40); do + pgrep -f 'probe/target/debug/agent-relay-desktop' >/dev/null || break + sleep 0.25 + done + ! pgrep -f 'probe/target/debug/agent-relay-desktop' + + macos: + # Legacy architecture labels are compatibility completion gates over the + # SELECTED matrix; the summary states actual and deferred native coverage. + name: macOS ${{ matrix.arch }} + needs: [changes, macos-package, macos-swift-tests, macos-core-tests] + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + arch: [arm64, x64] + steps: + - name: Check all selected macOS lanes + working-directory: ${{ github.workspace }} + env: + CHANGES_RESULT: ${{ needs.changes.result }} + CHANGED: ${{ needs.changes.outputs.macos }} + CORE_CHANGED: ${{ needs.changes.outputs.macos_core }} + PACKAGE_CHANGED: ${{ needs.changes.outputs.macos_package }} + SKIP_PACKAGES: ${{ inputs.skip-packages && 'true' || 'false' }} + SELECTED_ARCHITECTURES: ${{ inputs.full-matrix && 'arm64, x64' || 'arm64' }} + DEFERRED_ARCHITECTURES: ${{ inputs.full-matrix && 'none' || 'x64 (pre-release)' }} + MACOS_PACKAGE_RESULT: ${{ needs.macos-package.result }} + MACOS_SWIFT_TESTS_RESULT: ${{ needs.macos-swift-tests.result }} + MACOS_CORE_TESTS_RESULT: ${{ needs.macos-core-tests.result }} + run: | + { + echo 'This legacy architecture label is a compatibility completion gate over the selected matrix.' + echo "Native architectures tested when this component is selected: $SELECTED_ARCHITECTURES" + echo "Architectures deferred: $DEFERRED_ARCHITECTURES" + echo "Swift tests selected: $CHANGED; core tests selected: $CORE_CHANGED; packaging selected: $PACKAGE_CHANGED; unsigned package checks skipped: $SKIP_PACKAGES" + } >> "$GITHUB_STEP_SUMMARY" + test "$CHANGES_RESULT" = success + case "$CHANGED" in true|false) ;; *) exit 1 ;; esac + case "$SKIP_PACKAGES" in true|false) ;; *) exit 1 ;; esac + case "$PACKAGE_CHANGED" in true|false) ;; *) exit 1 ;; esac + if [ "$PACKAGE_CHANGED" = true ]; then test "$CHANGED" = true; fi + if [ "$CHANGED" = true ]; then + test "$MACOS_SWIFT_TESTS_RESULT" = success + else + test "$MACOS_SWIFT_TESTS_RESULT" = skipped + fi + if [ "$PACKAGE_CHANGED" = true ] && [ "$SKIP_PACKAGES" = false ]; then + test "$MACOS_PACKAGE_RESULT" = success + else + test "$MACOS_PACKAGE_RESULT" = skipped + fi + case "$CORE_CHANGED" in true|false) ;; *) exit 1 ;; esac + if [ "$CORE_CHANGED" = true ]; then + test "$MACOS_CORE_TESTS_RESULT" = success + else + test "$MACOS_CORE_TESTS_RESULT" = skipped + fi + + windows: + # Legacy architecture labels are compatibility completion gates over the + # SELECTED matrix; the summary states actual and deferred native coverage. + name: Windows ${{ matrix.arch }} + needs: [changes, windows-package, windows-tests] + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + arch: [arm64, x64] + steps: + - name: Check all selected Windows lanes + working-directory: ${{ github.workspace }} + env: + CHANGES_RESULT: ${{ needs.changes.result }} + CHANGED: ${{ needs.changes.outputs.windows }} + PACKAGE_CHANGED: ${{ needs.changes.outputs.windows_package }} + SKIP_PACKAGES: ${{ inputs.skip-packages && 'true' || 'false' }} + SELECTED_ARCHITECTURES: ${{ inputs.full-matrix && 'arm64, x64' || 'x64' }} + DEFERRED_ARCHITECTURES: ${{ inputs.full-matrix && 'none' || 'arm64 (pre-release)' }} + WINDOWS_PACKAGE_RESULT: ${{ needs.windows-package.result }} + WINDOWS_TESTS_RESULT: ${{ needs.windows-tests.result }} + run: | + { + echo 'This legacy architecture label is a compatibility completion gate over the selected matrix.' + echo "Native architectures tested when this component is selected: $SELECTED_ARCHITECTURES" + echo "Architectures deferred: $DEFERRED_ARCHITECTURES" + echo "Tests selected: $CHANGED; packaging selected: $PACKAGE_CHANGED; unsigned package checks skipped: $SKIP_PACKAGES" + } >> "$GITHUB_STEP_SUMMARY" + test "$CHANGES_RESULT" = success + case "$CHANGED" in true|false) ;; *) exit 1 ;; esac + case "$SKIP_PACKAGES" in true|false) ;; *) exit 1 ;; esac + case "$PACKAGE_CHANGED" in true|false) ;; *) exit 1 ;; esac + if [ "$PACKAGE_CHANGED" = true ]; then test "$CHANGED" = true; fi + if [ "$CHANGED" = true ]; then + test "$WINDOWS_TESTS_RESULT" = success + else + test "$WINDOWS_TESTS_RESULT" = skipped + fi + if [ "$PACKAGE_CHANGED" = true ] && [ "$SKIP_PACKAGES" = false ]; then + test "$WINDOWS_PACKAGE_RESULT" = success + else + test "$WINDOWS_PACKAGE_RESULT" = skipped + fi + + linux: + # Legacy architecture labels are compatibility completion gates over the + # SELECTED matrix; the summary states actual and deferred native coverage. + name: Linux ${{ matrix.arch }} + needs: [changes, linux-core, linux-package, linux-tests] + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + arch: [arm64, x64] + steps: + - name: Check all selected Linux lanes + working-directory: ${{ github.workspace }} + env: + CHANGES_RESULT: ${{ needs.changes.result }} + CHANGED: ${{ needs.changes.outputs.linux }} + CORE_CHANGED: ${{ needs.changes.outputs.linux_core }} + INTEGRATION_CHANGED: ${{ needs.changes.outputs.linux_integration }} + PACKAGE_CHANGED: ${{ needs.changes.outputs.linux_package }} + SKIP_PACKAGES: ${{ inputs.skip-packages && 'true' || 'false' }} + SELECTED_ARCHITECTURES: ${{ inputs.full-matrix && 'arm64, x64' || 'x64' }} + DEFERRED_ARCHITECTURES: ${{ inputs.full-matrix && 'none' || 'arm64 (pre-release)' }} + LINUX_CORE_RESULT: ${{ needs.linux-core.result }} + LINUX_PACKAGE_RESULT: ${{ needs.linux-package.result }} + LINUX_TESTS_RESULT: ${{ needs.linux-tests.result }} + run: | + { + echo 'This legacy architecture label is a compatibility completion gate over the selected matrix.' + echo "Native architectures tested when this component is selected: $SELECTED_ARCHITECTURES" + echo "Architectures deferred: $DEFERRED_ARCHITECTURES" + echo "GTK tests selected: $CHANGED; core build selected: $CORE_CHANGED; integration selected: $INTEGRATION_CHANGED; packaging selected: $PACKAGE_CHANGED; unsigned package checks skipped: $SKIP_PACKAGES" + } >> "$GITHUB_STEP_SUMMARY" + test "$CHANGES_RESULT" = success + case "$CHANGED" in true|false) ;; *) exit 1 ;; esac + case "$CORE_CHANGED" in true|false) ;; *) exit 1 ;; esac + case "$INTEGRATION_CHANGED" in true|false) ;; *) exit 1 ;; esac + case "$PACKAGE_CHANGED" in true|false) ;; *) exit 1 ;; esac + case "$SKIP_PACKAGES" in true|false) ;; *) exit 1 ;; esac + if [ "$INTEGRATION_CHANGED" = true ] || [ "$PACKAGE_CHANGED" = true ]; then + test "$CORE_CHANGED" = true + fi + if [ "$CHANGED" = true ] || [ "$INTEGRATION_CHANGED" = true ]; then + test "$LINUX_TESTS_RESULT" = success + else + test "$LINUX_TESTS_RESULT" = skipped + fi + if [ "$CORE_CHANGED" = true ]; then + test "$LINUX_CORE_RESULT" = success + else + test "$LINUX_CORE_RESULT" = skipped + fi + if [ "$PACKAGE_CHANGED" = true ] && [ "$SKIP_PACKAGES" = false ]; then + test "$LINUX_PACKAGE_RESULT" = success + else + test "$LINUX_PACKAGE_RESULT" = skipped + fi + + linux-probe-glibc: + # Preserve both former required-check names over the selected native matrix. + name: Linux probe glibc 2.34 (${{ matrix.arch }}) + needs: [changes, linux-core] + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + arch: [arm64, x64] + steps: + - name: Check the selected Linux core builds + working-directory: ${{ github.workspace }} + env: + CHANGES_RESULT: ${{ needs.changes.result }} + CHANGED: ${{ needs.changes.outputs.linux_core }} + CORE_RESULT: ${{ needs.linux-core.result }} + SELECTED_ARCHITECTURES: ${{ inputs.full-matrix && 'arm64, x64' || 'x64' }} + DEFERRED_ARCHITECTURES: ${{ inputs.full-matrix && 'none' || 'arm64 (pre-release)' }} + run: | + { + echo 'This legacy architecture label is a compatibility completion gate over the selected matrix.' + echo "Linux cores checked against glibc 2.34 when selected: $SELECTED_ARCHITECTURES" + echo "Architectures deferred: $DEFERRED_ARCHITECTURES" + echo "Component selected: $CHANGED" + } >> "$GITHUB_STEP_SUMMARY" + test "$CHANGES_RESULT" = success + case "$CHANGED" in true|false) ;; *) exit 1 ;; esac + if [ "$CHANGED" = true ]; then + test "$CORE_RESULT" = success + else + test "$CORE_RESULT" = skipped + fi diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000000..85c46a5b9e --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,754 @@ +name: Desktop Release + +# Tag desktop-vYYYY.MM.N (or dispatch, optionally with a version override) to build, +# sign and notarize the macOS disk images, build Linux packages and Windows +# zips, and publish all architectures together. A dispatch without a version defaults to YYYY.MM.N +# for the current month. No job writes to the source repository; a promoted +# changelog is uploaded after publishing and lands through a normal PR. + +on: + push: + tags: + - 'desktop-v*' + workflow_dispatch: + inputs: + version: + description: 'Version to release, without the leading v (for example 2026.09.1). Empty uses the CalVer default YYYY.MM.N.' + required: false + default: '' + type: string + +# One group for every release run: versions allocate from the shared releases +# list, so overlapping runs serialize instead of picking the same default N. +# GitHub keeps at most one pending run per group: a third overlapping trigger +# cancels the pending run (never the running one), so re-dispatch if your run +# shows Canceled and it will allocate the next N. +concurrency: + group: desktop-release + cancel-in-progress: false + +permissions: + contents: read + +defaults: + run: + working-directory: apps/desktop + +jobs: + version: + name: Resolve version + runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + permissions: + contents: read + outputs: + version: ${{ steps.resolve.outputs.version }} + tag: ${{ steps.resolve.outputs.tag }} + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Resolve + id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + TAG_NAME: ${{ github.ref_name }} + INPUT_VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + RELEASES_REPOSITORY: AgentWorkforce/relay-desktop-releases + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + version="${INPUT_VERSION#v}" + if [ -z "$version" ]; then + prefix="$(date -u +%Y.%m)" + if ! tags="$(gh api "repos/$RELEASES_REPOSITORY/releases?per_page=100" --paginate --jq '.[].tag_name')"; then + echo "::error::Could not list releases in $RELEASES_REPOSITORY to compute the default version; pass an explicit version input instead." >&2 + exit 1 + fi + max_n=0 + while IFS= read -r tag; do + [ -n "$tag" ] || continue + case "$tag" in + "v$prefix."*) ;; + *) continue ;; + esac + n="${tag#v"$prefix".}" + n="${n%%[-+]*}" + if printf '%s' "$n" | grep -Eq '^[0-9]+$'; then + if [ "$n" -gt "$max_n" ]; then + max_n="$n" + fi + fi + done <<< "$tags" + version="$prefix.$((10#$max_n + 1))" + echo "No version input; defaulting to $version" + fi + else + version="${TAG_NAME#desktop-v}" + fi + if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then + echo "::error::'$version' is not a X.Y.Z version; tag releases as desktop-vYYYY.MM.N (for example desktop-v2026.09.1)" >&2 + exit 1 + fi + { + echo "version=$version" + echo "tag=v$version" + } >> "$GITHUB_OUTPUT" + + # Fail before the expensive builds if neither the versioned section + # nor a promotable Unreleased section has release notes. + python3 scripts/release-notes.py "$version" --check + + echo "Releasing v$version" + + - name: Require analytics release configuration + env: + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} + run: | + set -euo pipefail + for name in POSTHOG_API_KEY POSTHOG_HOST; do + if [ -z "${!name}" ]; then + echo "::error::Missing required release secret: $name" + exit 1 + fi + done + + pre-release: + name: Full architecture checks + needs: version + uses: ./.github/workflows/desktop-ci.yml + permissions: + contents: read + secrets: + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} + with: + full-matrix: true + force-all: true + skip-packages: true + probe-version: ${{ needs.version.outputs.version }} + + build: + name: Build ${{ matrix.arch }} + needs: [version, pre-release] + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + arch: arm64 + rust_target: aarch64-apple-darwin + - runner: macos-15-intel + arch: x64 + rust_target: x86_64-apple-darwin + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Require signing and notarization credentials + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + missing=0 + for name in CSC_LINK CSC_KEY_PASSWORD APPLE_API_KEY_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER; do + if [ -z "${!name}" ]; then + echo "::error::Missing required release secret: $name" + missing=1 + fi + done + exit "$missing" + + - name: Cache the Swift release build + uses: ./.github/actions/swift-cache + with: + arch: ${{ matrix.arch }} + lane: package + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + targets: ${{ matrix.rust_target }} + + - name: Cache the probe build + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + with: + key: probe-${{ matrix.arch }} + workspaces: apps/desktop/probe + + - name: Build the probe + env: + VERSION: ${{ needs.version.outputs.version }} + AGENT_RELAY_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + AGENT_RELAY_POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} + run: scripts/build-probe.sh --target '${{ matrix.rust_target }}' --version "$VERSION" --out probe-build + + # Public releases require Developer ID signing and notarization above. + # Local builds and PR CI may still use the ad-hoc build-app.sh fallback. + - name: Assemble and sign the app bundle + env: + VERSION: ${{ needs.version.outputs.version }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + scripts/build-app.sh \ + --arch '${{ matrix.arch }}' \ + --version "$VERSION" \ + --build "$GITHUB_RUN_NUMBER" \ + --probe probe-build \ + --out dist + + - name: Set up Python for disk image tooling + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + + - name: Install disk image tooling + run: | + python3 -m venv "$RUNNER_TEMP/dmg-tools" + "$RUNNER_TEMP/dmg-tools/bin/python" -m pip install -r scripts/dmg-requirements.txt + echo "$RUNNER_TEMP/dmg-tools/bin" >> "$GITHUB_PATH" + + - name: Package the disk image + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + mkdir -p out + scripts/package-dmg.sh \ + --app 'dist/Agent Relay.app' \ + --out "out/AgentRelay-$VERSION-macOS-${{ matrix.arch }}.dmg" + # Recorded so the release body can name the exact probe that shipped. + cp probe-build/probe-version.txt "out/probe-version-${{ matrix.arch }}.txt" + # The .sha256 that package-dmg.sh wrote next to the image rides along + # but is regenerated in the publish job, where the stable-named copies + # are made, so every published checksum names its own file. + + - name: Package the standalone signed and notarized probe + env: + VERSION: ${{ needs.version.outputs.version }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + scripts/package-macos-probe.sh \ + --app 'dist/Agent Relay.app' \ + --out "out/AgentRelay-$VERSION-macOS-${{ matrix.arch }}-probe.tar.gz" + + - name: Upload the disk image + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dmg-${{ matrix.arch }} + path: apps/desktop/out/ + if-no-files-found: error + retention-days: 7 + + build-linux: + name: Build Linux ${{ matrix.arch }} + needs: [version, pre-release] + # Ubuntu 22.04 ships GTK 4.6, the oldest the app runs on; the app links + # against it, natively for each architecture. + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: ${{ vars.CI_LINUX_2204_RUNNER || 'ubuntu-22.04' }} + arch: x64 + rust_target: x86_64-unknown-linux-gnu + - runner: ${{ vars.CI_LINUX_2204_ARM64_RUNNER || 'ubuntu-22.04-arm' }} + arch: arm64 + rust_target: aarch64-unknown-linux-gnu + env: + # The oldest glibc the Linux probe and app run on (Amazon Linux 2023, + # RHEL 9 and Ubuntu 22.04 and newer). A probe linked on a newer glibc + # needs that glibc (std's weak pidfd_spawnp reference becomes a hard + # GLIBC_2.39 need), so the probe links against 2.34 with zig; the app + # links against the runner's GTK and glibc, and is checked. + GLIBC_FLOOR: '2.34' + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install GTK 4 + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libgtk-4-dev librsvg2-common pkg-config + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + targets: ${{ matrix.rust_target }} + + - name: Cache the probe build + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + with: + key: release-linux-gtk4-v1-${{ matrix.arch }} + workspaces: apps/desktop/probe + + - name: Download the tested release core + uses: ./.github/actions/linux-core-artifact + with: + arch: ${{ matrix.arch }} + rust-target: ${{ matrix.rust_target }} + probe-version: ${{ needs.version.outputs.version }} + + - name: Build the app + env: + AGENT_RELAY_PROBE_VERSION: ${{ needs.version.outputs.version }} + AGENT_RELAY_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + AGENT_RELAY_POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} + run: cargo build --manifest-path probe/Cargo.toml --locked --release --features gtk --bin agent-relay-desktop + + - name: Build packages + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + scripts/build-linux.sh --version "$VERSION" --arch '${{ matrix.arch }}' \ + --app probe/target/release/agent-relay-desktop --probe probe-build/agent-relay-probe --out out + + # The bytes the packages ship, checked again after packaging. + - name: Check the packaged binaries' glibc floor + run: | + tree='out/AgentRelay-linux-${{ matrix.arch }}' + scripts/check-glibc-floor.sh "$tree/usr/lib/agent-relay/agent-relay-probe" --max "$GLIBC_FLOOR" + scripts/check-glibc-floor.sh "$tree/usr/bin/agent-relay-desktop" --max "$GLIBC_FLOOR" + + - name: Package the standalone probe + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + scripts/package-linux-probe.sh \ + --probe probe-build/agent-relay-probe \ + --glibc "$GLIBC_FLOOR" \ + --out "out/AgentRelay-$VERSION-Linux-${{ matrix.arch }}-probe.tar.gz" + + - name: Record probe version + run: cp probe-build/probe-version.txt 'out/probe-version-linux-${{ matrix.arch }}.txt' + + - name: Upload Linux packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: linux-${{ matrix.arch }} + path: apps/desktop/out/ + if-no-files-found: error + retention-days: 7 + + build-windows: + name: Build Windows ${{ matrix.arch }} + needs: [version, pre-release] + # The GTK app and the core, built natively with MSYS2's clang toolchain + # and GTK 4; the zip carries the GTK runtime beside them. + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + arch: x64 + msystem: CLANG64 + - runner: windows-11-arm + arch: arm64 + msystem: CLANGARM64 + defaults: + run: + shell: msys2 {0} + working-directory: apps/desktop + env: + # A short cargo home and git-CLI fetches, as in desktop-ci.yml: RelayHistory's + # fixtures hold paths close to Windows' 260-character limit. + CARGO_HOME: C:\ch + CARGO_NET_GIT_FETCH_WITH_CLI: 'true' + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + steps: + - name: Keep line endings and allow long paths + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + git config --global core.autocrlf false + git config --global core.longpaths true + + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the Windows toolchain + id: toolchain + uses: ./.github/actions/windows-toolchain + with: + msystem: ${{ matrix.msystem }} + + - name: Cache the Cargo release dependencies + id: build-cache + uses: ./.github/actions/windows-build-cache + with: + arch: ${{ matrix.arch }} + profile: release + toolchain: ${{ steps.toolchain.outputs.identity }} + + # The Setup .exe is compiled with Inno Setup 6, as in desktop-ci.yml. + - name: Install Inno Setup + shell: pwsh + run: | + $iscc = 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' + if (-not (Test-Path $iscc)) { + if (Get-Command choco -ErrorAction SilentlyContinue) { + choco install innosetup --no-progress -y + } else { + winget install --id JRSoftware.InnoSetup -e --scope machine --silent --accept-source-agreements --accept-package-agreements --disable-interactivity + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + Get-Item $iscc | Format-List FullName, VersionInfo + + # Builds the release core and app (icon and version embedded) and + # packages them with the GTK runtime, as zips and the Setup .exe. + - name: Build the Windows package + env: + VERSION: ${{ needs.version.outputs.version }} + AGENT_RELAY_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + AGENT_RELAY_POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} + run: | + scripts/build-windows.sh --version "$VERSION" --arch '${{ matrix.arch }}' --out out --installer + # Recorded so the release body can name the exact probe that shipped. + printf '%s\n' "$VERSION" > 'out/probe-version-windows-${{ matrix.arch }}.txt' + # The tree itself is in the zip. + rm -rf 'out/AgentRelay-Windows-${{ matrix.arch }}' + + - name: Check the core's bridge commands + run: bash scripts/check-probe-bridge.sh probe/target/release/agent-relay-probe.exe + + # The packaged core runs with only the DLLs the zip carries. + - name: Smoke test the packaged core + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + unzip -l "out/AgentRelay-$VERSION-Windows-${{ matrix.arch }}.zip" | tail -n 1 + unpacked="$(cygpath -u "$RUNNER_TEMP")/unpacked" + rm -rf "$unpacked" && mkdir -p "$unpacked" + unzip -q "out/AgentRelay-$VERSION-Windows-${{ matrix.arch }}.zip" -d "$unpacked" + PATH=/c/Windows/System32 "$unpacked/AgentRelay-Windows-${{ matrix.arch }}/bin/agent-relay-probe.exe" --version + + # Setup installs per user where the updater expects the tree, and its + # uninstaller removes the install, the shortcut and the entry. + - name: Install and uninstall with Setup + shell: pwsh + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + $version = $env:VERSION + $setup = "out\AgentRelay-$version-Windows-${{ matrix.arch }}-Setup.exe" + Start-Process $setup -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait + $root = "$env:LOCALAPPDATA\Programs\AgentRelay" + $shortcut = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Agent Relay.lnk" + $entry = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{C5377993-AE99-466F-A519-676A299A6C6D}_is1' + foreach ($path in "$root\$version\AgentRelay-Windows-${{ matrix.arch }}\bin\agent-relay-desktop.exe", "$root\unins000.exe", $shortcut, $entry) { + if (-not (Test-Path $path)) { throw "Setup did not create $path" } + } + Get-ItemProperty $entry | Format-List DisplayName, DisplayVersion, InstallLocation + Start-Process "$root\unins000.exe" -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait + # The uninstaller finishes removing its own folder just after it exits. + for ($i = 0; $i -lt 30 -and (Test-Path $root); $i++) { Start-Sleep -Seconds 1 } + foreach ($path in $root, $shortcut, $entry, 'HKCU:\Software\Classes\agentrelay') { + if (Test-Path $path) { throw "The uninstaller left $path" } + } + + - name: Upload the Windows package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: windows-${{ matrix.arch }} + path: apps/desktop/out/ + if-no-files-found: error + retention-days: 7 + + - name: Keep only reusable release dependencies in the cache + if: steps.build-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/windows-build-cache-prune + with: + profile: release + + publish: + name: Publish to relay-desktop-releases + needs: + - version + - pre-release + - build + - build-linux + - build-windows + runs-on: ${{ vars.DEPLOY_LINUX_RUNNER || 'ubuntu-latest' }} + permissions: + contents: read + env: + RELEASES_REPOSITORY: AgentWorkforce/relay-desktop-releases + steps: + - name: Check out release notes + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Check publishing access + env: + GH_TOKEN: ${{ secrets.RELEASES_TOKEN }} + run: | + set -euo pipefail + if [ -z "$GH_TOKEN" ]; then + echo "::error::Set RELEASES_TOKEN with Contents read/write access to $RELEASES_REPOSITORY. The source repo's GITHUB_TOKEN cannot publish there." + exit 1 + fi + gh api "repos/$RELEASES_REPOSITORY" > repository.json + jq -e '.private == false and .permissions.push == true' repository.json > /dev/null || { + echo "::error::The releases repo must be public and RELEASES_TOKEN must have write access." + exit 1 + } + gh api "repos/$RELEASES_REPOSITORY/commits/main" --jq .sha > /dev/null || { + echo "::error::Initialize the releases repo with its README before publishing." + exit 1 + } + + - name: Download the disk images + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: dmg-* + path: apps/desktop/downloads + merge-multiple: true + + - name: Download the Linux packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: linux-* + path: apps/desktop/linux-downloads + merge-multiple: true + + - name: Download the Windows packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: windows-* + path: apps/desktop/windows-downloads + merge-multiple: true + + - name: Stage the release assets + id: stage + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + mkdir -p assets + for arch in arm64 x64; do + versioned="AgentRelay-$VERSION-macOS-$arch.dmg" + stable="AgentRelay-macOS-$arch.dmg" + if [ ! -f "downloads/$versioned" ]; then + echo "::error::missing build artifact downloads/$versioned" >&2 + exit 1 + fi + cp "downloads/$versioned" "assets/$versioned" + # Identical bytes under the name agentrelay.com links to. + cp "downloads/$versioned" "assets/$stable" + + probe_versioned="AgentRelay-$VERSION-macOS-$arch-probe.tar.gz" + probe_stable="AgentRelay-macOS-$arch-probe.tar.gz" + test -f "downloads/$probe_versioned" || { echo "::error::missing $probe_versioned"; exit 1; } + cp "downloads/$probe_versioned" "assets/$probe_versioned" + cp "downloads/$probe_versioned" "assets/$probe_stable" + + linux_tar="AgentRelay-$VERSION-Linux-$arch.tar.gz" + linux_deb="AgentRelay-$VERSION-Linux-$arch.deb" + test -f "linux-downloads/$linux_tar" || { echo "::error::missing $linux_tar"; exit 1; } + test -f "linux-downloads/$linux_deb" || { echo "::error::missing $linux_deb"; exit 1; } + cp "linux-downloads/$linux_tar" "assets/$linux_tar" + cp "linux-downloads/$linux_deb" "assets/$linux_deb" + cp "linux-downloads/$linux_tar" "assets/AgentRelay-Linux-$arch.tar.gz" + cp "linux-downloads/$linux_deb" "assets/AgentRelay-Linux-$arch.deb" + + linux_probe="AgentRelay-$VERSION-Linux-$arch-probe.tar.gz" + test -f "linux-downloads/$linux_probe" || { echo "::error::missing $linux_probe"; exit 1; } + cp "linux-downloads/$linux_probe" "assets/$linux_probe" + cp "linux-downloads/$linux_probe" "assets/AgentRelay-Linux-$arch-probe.tar.gz" + + windows_zip="AgentRelay-$VERSION-Windows-$arch.zip" + windows_probe="AgentRelay-$VERSION-Windows-$arch-probe.zip" + windows_setup="AgentRelay-$VERSION-Windows-$arch-Setup.exe" + test -f "windows-downloads/$windows_zip" || { echo "::error::missing $windows_zip"; exit 1; } + test -f "windows-downloads/$windows_probe" || { echo "::error::missing $windows_probe"; exit 1; } + test -f "windows-downloads/$windows_setup" || { echo "::error::missing $windows_setup"; exit 1; } + cp "windows-downloads/$windows_zip" "assets/$windows_zip" + cp "windows-downloads/$windows_zip" "assets/AgentRelay-Windows-$arch.zip" + cp "windows-downloads/$windows_probe" "assets/$windows_probe" + cp "windows-downloads/$windows_probe" "assets/AgentRelay-Windows-$arch-probe.zip" + cp "windows-downloads/$windows_setup" "assets/$windows_setup" + cp "windows-downloads/$windows_setup" "assets/AgentRelay-Windows-$arch-Setup.exe" + done + # The headless installer is served from the public releases repo so + # its one-line install keeps working wherever the source lives. + cp linux/install.sh assets/install.sh + ( cd assets && for package in *.dmg *.tar.gz *.deb *.zip *.exe install.sh; do sha256sum "$package" > "$package.sha256"; done ) + + probe_version="" + for record in \ + downloads/probe-version-arm64.txt \ + downloads/probe-version-x64.txt \ + linux-downloads/probe-version-linux-arm64.txt \ + linux-downloads/probe-version-linux-x64.txt \ + windows-downloads/probe-version-windows-arm64.txt \ + windows-downloads/probe-version-windows-x64.txt; do + test -f "$record" || { echo "::error::missing $record"; exit 1; } + recorded="$(tr -d '\n\r' < "$record")" + test -n "$recorded" || { echo "::error::empty probe version in $record"; exit 1; } + if [ -z "$probe_version" ]; then + probe_version="$recorded" + elif [ "$recorded" != "$probe_version" ]; then + echo "::error::probe version mismatch: $record contains $recorded, expected $probe_version" + exit 1 + fi + done + echo "probe_version=$probe_version" >> "$GITHUB_OUTPUT" + + ls -l assets + + - name: Prepare release notes + env: + VERSION: ${{ needs.version.outputs.version }} + PROBE_VERSION: ${{ steps.stage.outputs.probe_version }} + run: | + python3 scripts/release-notes.py "$VERSION" \ + --repository "$RELEASES_REPOSITORY" \ + --probe-version "$PROBE_VERSION" \ + --output release-notes.md + + - name: Create the release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 + with: + repository: ${{ env.RELEASES_REPOSITORY }} + token: ${{ secrets.RELEASES_TOKEN }} + # Tags belong to the distribution repo, never the source SHA. + target_commitish: main + tag_name: ${{ needs.version.outputs.tag }} + name: ${{ needs.version.outputs.tag }} + draft: false + prerelease: ${{ contains(needs.version.outputs.version, '-') }} + make_latest: ${{ !contains(needs.version.outputs.version, '-') }} + # Generated notes would describe the distribution repo, not the app. + generate_release_notes: false + fail_on_unmatched_files: true + files: | + apps/desktop/assets/install.sh + apps/desktop/assets/install.sh.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-macOS-arm64.dmg + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-macOS-arm64.dmg.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-macOS-x64.dmg + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-macOS-x64.dmg.sha256 + apps/desktop/assets/AgentRelay-macOS-arm64.dmg + apps/desktop/assets/AgentRelay-macOS-arm64.dmg.sha256 + apps/desktop/assets/AgentRelay-macOS-x64.dmg + apps/desktop/assets/AgentRelay-macOS-x64.dmg.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-macOS-arm64-probe.tar.gz + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-macOS-arm64-probe.tar.gz.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-macOS-x64-probe.tar.gz + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-macOS-x64-probe.tar.gz.sha256 + apps/desktop/assets/AgentRelay-macOS-arm64-probe.tar.gz + apps/desktop/assets/AgentRelay-macOS-arm64-probe.tar.gz.sha256 + apps/desktop/assets/AgentRelay-macOS-x64-probe.tar.gz + apps/desktop/assets/AgentRelay-macOS-x64-probe.tar.gz.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-arm64.tar.gz + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-arm64.tar.gz.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-arm64.deb + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-arm64.deb.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-x64.tar.gz + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-x64.tar.gz.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-x64.deb + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-x64.deb.sha256 + apps/desktop/assets/AgentRelay-Linux-arm64.tar.gz + apps/desktop/assets/AgentRelay-Linux-arm64.tar.gz.sha256 + apps/desktop/assets/AgentRelay-Linux-arm64.deb + apps/desktop/assets/AgentRelay-Linux-arm64.deb.sha256 + apps/desktop/assets/AgentRelay-Linux-x64.tar.gz + apps/desktop/assets/AgentRelay-Linux-x64.tar.gz.sha256 + apps/desktop/assets/AgentRelay-Linux-x64.deb + apps/desktop/assets/AgentRelay-Linux-x64.deb.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-arm64-probe.tar.gz + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-arm64-probe.tar.gz.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-x64-probe.tar.gz + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Linux-x64-probe.tar.gz.sha256 + apps/desktop/assets/AgentRelay-Linux-arm64-probe.tar.gz + apps/desktop/assets/AgentRelay-Linux-arm64-probe.tar.gz.sha256 + apps/desktop/assets/AgentRelay-Linux-x64-probe.tar.gz + apps/desktop/assets/AgentRelay-Linux-x64-probe.tar.gz.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-arm64.zip + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-arm64.zip.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-x64.zip + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-x64.zip.sha256 + apps/desktop/assets/AgentRelay-Windows-arm64.zip + apps/desktop/assets/AgentRelay-Windows-arm64.zip.sha256 + apps/desktop/assets/AgentRelay-Windows-x64.zip + apps/desktop/assets/AgentRelay-Windows-x64.zip.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-arm64-probe.zip + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-arm64-probe.zip.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-x64-probe.zip + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-x64-probe.zip.sha256 + apps/desktop/assets/AgentRelay-Windows-arm64-probe.zip + apps/desktop/assets/AgentRelay-Windows-arm64-probe.zip.sha256 + apps/desktop/assets/AgentRelay-Windows-x64-probe.zip + apps/desktop/assets/AgentRelay-Windows-x64-probe.zip.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-arm64-Setup.exe + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-arm64-Setup.exe.sha256 + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-x64-Setup.exe + apps/desktop/assets/AgentRelay-${{ needs.version.outputs.version }}-Windows-x64-Setup.exe.sha256 + apps/desktop/assets/AgentRelay-Windows-arm64-Setup.exe + apps/desktop/assets/AgentRelay-Windows-arm64-Setup.exe.sha256 + apps/desktop/assets/AgentRelay-Windows-x64-Setup.exe + apps/desktop/assets/AgentRelay-Windows-x64-Setup.exe.sha256 + body_path: apps/desktop/release-notes.md + + changelog: + name: Prepare changelog pull request + needs: + - version + - publish + runs-on: ${{ vars.DEPLOY_LINUX_RUNNER || 'ubuntu-latest' }} + permissions: + contents: read + steps: + - name: Check out the released snapshot + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Promote Unreleased in the artifact + env: + VERSION: ${{ needs.version.outputs.version }} + run: python3 scripts/release-notes.py "$VERSION" --promote + + - name: Upload the promoted changelog + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: promoted-changelog-v${{ needs.version.outputs.version }} + path: apps/desktop/CHANGELOG.md + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/detect-changes.yml b/.github/workflows/detect-changes.yml index d8d49f8aeb..073014f513 100644 --- a/.github/workflows/detect-changes.yml +++ b/.github/workflows/detect-changes.yml @@ -88,7 +88,17 @@ jobs: if (files.length === 0) unknownScope = true; - const isCi = (file) => file.startsWith('.github/'); + const desktopWorkflows = new Set([ + '.github/workflows/desktop-ci.yml', + '.github/workflows/desktop-release.yml', + '.github/workflows/ios-testflight.yml', + '.github/workflows/mobile-ci.yml', + ]); + const isDesktopAction = (file) => file.startsWith('.github/actions/'); + const isCi = (file) => + file.startsWith('.github/') && + !desktopWorkflows.has(file) && + !isDesktopAction(file); const isSwift = (file) => file.startsWith('packages/sdk-swift/') || file === 'Package.swift'; const isRust = (file) => @@ -102,6 +112,9 @@ jobs: file.startsWith('.agentworkforce/agents/'); // Files no build, test, or lint job consumes. const isInert = (file) => + file.startsWith('apps/desktop/') || + desktopWorkflows.has(file) || + isDesktopAction(file) || file.startsWith('web/') || file.startsWith('docs/') || file.startsWith('specs/') || @@ -139,6 +152,25 @@ jobs: throw new Error('Agent-only scope probe must select only Node jobs'); } + // Desktop has its own cross-platform workflow and must not fan out + // into the root Rust, Node, or Swift SDK jobs. + const desktopProbe = classifyFiles([ + 'apps/desktop/probe/src/main.rs', + '.github/workflows/desktop-ci.yml', + '.github/workflows/mobile-ci.yml', + '.github/actions/rust-lane-cache/action.yml', + ]); + core.info( + `desktop_probe rust_changed=${desktopProbe.rustChanged} node_changed=${desktopProbe.nodeChanged} sdk_swift_changed=${desktopProbe.sdkSwiftChanged}` + ); + if ( + desktopProbe.rustChanged || + desktopProbe.nodeChanged || + desktopProbe.sdkSwiftChanged + ) { + throw new Error('Desktop-only scope probe must not select root jobs'); + } + const detectedScope = classifyFiles(files); const rustChanged = unknownScope || detectedScope.rustChanged; const nodeChanged = unknownScope || detectedScope.nodeChanged; diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml new file mode 100644 index 0000000000..f73b1e3b36 --- /dev/null +++ b/.github/workflows/ios-testflight.yml @@ -0,0 +1,141 @@ +name: iOS TestFlight + +on: + workflow_dispatch: + inputs: + inventory_only: + description: Read Apple build processing and Internal Devices status without uploading + type: boolean + default: false + distribution: + description: Distribution scope (upload does not submit for review) + type: choice + options: + - internal + - app-store + default: internal + +permissions: + contents: read + +defaults: + run: + working-directory: apps/desktop + +concurrency: + group: ios-testflight + cancel-in-progress: false + +jobs: + inventory: + name: Read Apple processing and Internal Devices status + if: ${{ inputs.inventory_only }} + runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + - name: Test read-only transport and credential handling + run: node --test scripts/mobile-build-inventory.test.mjs + - name: Read inventory without changing distribution + env: + APPLE_MOBILE_API_KEY_BASE64: ${{ secrets.APPLE_MOBILE_API_KEY_BASE64 }} + APPLE_MOBILE_API_KEY_ID: ${{ secrets.APPLE_MOBILE_API_KEY_ID }} + APPLE_MOBILE_API_ISSUER: ${{ secrets.APPLE_MOBILE_API_ISSUER }} + run: node scripts/mobile-build-inventory.mjs + + upload: + if: ${{ !inputs.inventory_only }} + name: Archive and upload iOS + runs-on: macos-26 + steps: + - name: Check out + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Require current iOS SDK + run: | + sdk_version="$(xcrun --sdk iphoneos --show-sdk-version)" + if [ "${sdk_version%%.*}" -lt 26 ]; then + echo "::error::iOS 26 SDK or newer is required for App Store Connect uploads; found $sdk_version" + exit 1 + fi + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Prepare App Store Connect authentication + env: + APPLE_MOBILE_API_KEY_BASE64: ${{ secrets.APPLE_MOBILE_API_KEY_BASE64 }} + APPLE_MOBILE_API_KEY_ID: ${{ secrets.APPLE_MOBILE_API_KEY_ID }} + APPLE_MOBILE_API_ISSUER: ${{ secrets.APPLE_MOBILE_API_ISSUER }} + run: | + set -euo pipefail + for name in APPLE_MOBILE_API_KEY_BASE64 APPLE_MOBILE_API_KEY_ID APPLE_MOBILE_API_ISSUER; do + if [ -z "${!name}" ]; then + echo "::error::Missing required App Store Connect secret: $name" + exit 1 + fi + done + key_path="$RUNNER_TEMP/AuthKey.p8" + printf '%s' "$APPLE_MOBILE_API_KEY_BASE64" | base64 -D > "$key_path" + chmod 600 "$key_path" + + - name: Generate Xcode project + run: xcodegen generate --spec ipad/native/project.yml + + - name: Archive iPhone and iPad app + env: + APPLE_MOBILE_API_KEY_ID: ${{ secrets.APPLE_MOBILE_API_KEY_ID }} + APPLE_MOBILE_API_ISSUER: ${{ secrets.APPLE_MOBILE_API_ISSUER }} + run: | + set -euo pipefail + xcodebuild \ + -project ipad/native/AgentRelayiPad.xcodeproj \ + -scheme AgentRelayiPad \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$RUNNER_TEMP/AgentRelayMobile.xcarchive" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \ + -authenticationKeyID "$APPLE_MOBILE_API_KEY_ID" \ + -authenticationKeyIssuerID "$APPLE_MOBILE_API_ISSUER" \ + -skipPackagePluginValidation \ + CODE_SIGNING_ALLOWED=NO \ + archive + + - name: Preserve production push entitlement for cloud signing + run: | + set -euo pipefail + entitlements="$RUNNER_TEMP/production-push.entitlements" + cp ipad/native/Sources/AgentRelayiPad.entitlements "$entitlements" + plutil -replace aps-environment -string production "$entitlements" + codesign --force --sign - --entitlements "$entitlements" \ + "$RUNNER_TEMP/AgentRelayMobile.xcarchive/Products/Applications/AgentRelayiPad.app" + + - name: Upload to App Store Connect + env: + MOBILE_DISTRIBUTION: ${{ inputs.distribution }} + APPLE_MOBILE_API_KEY_ID: ${{ secrets.APPLE_MOBILE_API_KEY_ID }} + APPLE_MOBILE_API_ISSUER: ${{ secrets.APPLE_MOBILE_API_ISSUER }} + run: | + set -euo pipefail + case "$MOBILE_DISTRIBUTION" in + internal) export_options=ipad/native/ExportOptions.plist ;; + app-store) export_options=ipad/native/ExportOptions-AppStore.plist ;; + *) echo "::error::Unknown distribution scope"; exit 1 ;; + esac + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/AgentRelayMobile.xcarchive" \ + -exportOptionsPlist "$export_options" \ + -exportPath "$RUNNER_TEMP/AgentRelayMobileUpload" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \ + -authenticationKeyID "$APPLE_MOBILE_API_KEY_ID" \ + -authenticationKeyIssuerID "$APPLE_MOBILE_API_ISSUER" \ + -skipPackagePluginValidation diff --git a/.github/workflows/large-files.yml b/.github/workflows/large-files.yml index 816479c9bf..d87ecf1d90 100644 --- a/.github/workflows/large-files.yml +++ b/.github/workflows/large-files.yml @@ -14,6 +14,7 @@ jobs: -not -path './target/*' \ -not -path './dist/*' \ -not -path './bin/*' \ + -not -path './apps/desktop/assets/dmg-background.png' \ -not -name '*.lock' | \ while read file; do echo "::warning file=$file::Large file detected (>1MB)" diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 0000000000..e7975fc590 --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,117 @@ +name: Mobile CI + +on: + pull_request: + branches: [main] + paths: + - 'apps/desktop/ipad/native/**' + - 'apps/desktop/shared/RelaySessionOverview/**' + - '.github/workflows/mobile-ci.yml' + push: + branches: [main] + paths: + - 'apps/desktop/ipad/native/**' + - 'apps/desktop/shared/RelaySessionOverview/**' + - '.github/workflows/mobile-ci.yml' + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + working-directory: apps/desktop + +concurrency: + group: mobile-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + simulator: + name: iPhone handoff and unit tests + runs-on: macos-26 + timeout-minutes: 40 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate --spec ipad/native/project.yml + + - name: Select an available iPhone simulator + id: simulator + run: | + python3 - <<'PY' + import json, os, subprocess + inventory = json.loads(subprocess.check_output( + ["xcrun", "simctl", "list", "devices", "available", "--json"])) + phones = [device for runtime, devices in inventory["devices"].items() + if ".iOS-" in runtime for device in devices + if device.get("isAvailable") and device["name"].startswith("iPhone")] + if not phones: + raise SystemExit("No available iPhone simulator installed") + phone = phones[0] + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write("udid=" + phone["udid"] + "\n") + print("Testing on " + phone["name"]) + PY + + - name: Test mobile handoff without production credentials + env: + SIMULATOR_UDID: ${{ steps.simulator.outputs.udid }} + run: | + xcodebuild test \ + -project ipad/native/AgentRelayiPad.xcodeproj \ + -scheme AgentRelayiPad \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ + -resultBundlePath "$RUNNER_TEMP/MobileHandoff.xcresult" \ + -parallel-testing-enabled NO \ + -skipPackagePluginValidation \ + -only-testing:AgentRelayUnitTests \ + -only-testing:AgentRelayUITests/MobileHandoffUITests \ + CODE_SIGNING_ALLOWED=NO + + - name: Export viewable interaction screenshots + if: always() + run: | + if [ -d "$RUNNER_TEMP/MobileHandoff.xcresult" ]; then + mkdir -p "$RUNNER_TEMP/MobileHandoff-attachments" + xcrun xcresulttool export attachments --help + xcrun xcresulttool export attachments \ + --path "$RUNNER_TEMP/MobileHandoff.xcresult" \ + --output-path "$RUNNER_TEMP/MobileHandoff-attachments" + fi + + - name: Export simulator diagnostics and app console + if: always() + run: | + if [ -d "$RUNNER_TEMP/MobileHandoff.xcresult" ]; then + mkdir -p "$RUNNER_TEMP/MobileHandoff-diagnostics" + xcrun xcresulttool export diagnostics --help + xcrun xcresulttool export diagnostics \ + --path "$RUNNER_TEMP/MobileHandoff.xcresult" \ + --output-path "$RUNNER_TEMP/MobileHandoff-diagnostics" + xcrun xcresulttool get log --help + if ! xcrun xcresulttool get log \ + --path "$RUNNER_TEMP/MobileHandoff.xcresult" --type console \ + > "$RUNNER_TEMP/MobileHandoff-console.json"; then + echo "::warning::Bundle-wide console export unavailable; simulator diagnostics are preserved." + fi + fi + + - name: Preserve test results and interaction screenshots + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mobile-handoff-test-results + path: | + ${{ runner.temp }}/MobileHandoff.xcresult + ${{ runner.temp }}/MobileHandoff-attachments + ${{ runner.temp }}/MobileHandoff-diagnostics + ${{ runner.temp }}/MobileHandoff-console.json + if-no-files-found: ignore + retention-days: 7 diff --git a/.prettierignore b/.prettierignore index 2085ab091a..b6fdf4647f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ packages/config/src/cli-registry.generated.ts tests/benchmarks/head-to-head.ts .agentworkforce/trajectories/ .trajectories/ +apps/desktop/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f83a2c3fe9..c90ca4f4cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,24 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Major] + +### Added + +- Agent Relay Desktop is now open source in `apps/desktop`, including the macOS app, the GTK app for Linux and Windows, and their shared relay core. ### Removed - Python workflows, the `agent-relay.py` node provider, and the `agent-relay-sdk` package. Local workflows are YAML, TypeScript, JavaScript, or shell. Cloud and worker workflows are YAML or TypeScript. Node definitions are TypeScript or JavaScript. SDKs are TypeScript and Swift. +- Gemini, local Cursor CLI, Aider, Goose, and Droid are no longer spawn providers. CLI, MCP, SDK, Fleet, and broker compatibility paths return `provider_removed` instead of starting them, and Cloud connect, provider registries, and live-eval matrices no longer advertise them. + +### Breaking Changes + +- Node capacity no longer advertises `spawn:gemini`, `spawn:cursor`, `spawn:cursor-agent`, `spawn:aider`, `spawn:goose`, or `spawn:droid`, including when old teams, node definitions, or environment overrides name them. + +### Migration Guidance + +- Replace local Cursor CLI spawns with `cursor-cloud`. Move Gemini, Aider, Goose, and Droid workloads to a supported native provider such as Claude, Codex, or OpenCode. ## [14.0.0] - 2026-10-13 diff --git a/README.md b/README.md index 95e9b3c914..b04e668260 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,16 @@ Or install with npm: npm install -g agent-relay ``` +### Desktop + +[Agent Relay Desktop](apps/desktop/README.md) is the native companion app for +connecting local coding sessions, messaging agents, and sharing session history. +See the platform guides for [macOS](apps/desktop/macos/README.md), +[Linux](apps/desktop/linux/README.md), and +[Windows](apps/desktop/windows/README.md), or follow the +[agent-driven installation guide](apps/desktop/docs/agent-driven-setup.md) for +unattended macOS and headless Ubuntu setup. + ### Self Hosting Agent Relay has self hosting options for each primitive; start with the [self-hosting guide](docs/self-hosting/README.md). We're happy to help you set up the whole system on your environment, just reach out to our team hi(at)agentrelay.com and we'll walk you through it. diff --git a/apps/desktop/.gitattributes b/apps/desktop/.gitattributes new file mode 100644 index 0000000000..09ea033dd9 --- /dev/null +++ b/apps/desktop/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts run under bash on every platform, including MSYS2 on Windows, +# where a CRLF checkout would break them. +*.sh text eol=lf +linux/agent-relay.postinst text eol=lf diff --git a/apps/desktop/.github/README.md b/apps/desktop/.github/README.md new file mode 100644 index 0000000000..8e942a1271 --- /dev/null +++ b/apps/desktop/.github/README.md @@ -0,0 +1,246 @@ +# GitHub Actions runners + +Linux CI and release builds use Blacksmith through repository Actions variables. +Read-only jobs use +`CI_LINUX_*`; release publishing and changelog promotion use +`DEPLOY_LINUX_RUNNER`. macOS (including TestFlight) and Windows retain their +GitHub-hosted runners. + +| Repository variable | Blacksmith value | Fallback when unset | +| --- | --- | --- | +| `CI_LINUX_RUNNER` | `blacksmith-4vcpu-ubuntu-2404` | `ubuntu-latest` | +| `CI_LINUX_ARM64_RUNNER` | `blacksmith-4vcpu-ubuntu-2404-arm` | `ubuntu-24.04-arm` | +| `CI_LINUX_2204_RUNNER` | `blacksmith-4vcpu-ubuntu-2204` | `ubuntu-22.04` | +| `CI_LINUX_2204_ARM64_RUNNER` | `blacksmith-4vcpu-ubuntu-2204-arm` | `ubuntu-22.04-arm` | +| `DEPLOY_LINUX_RUNNER` | `blacksmith-4vcpu-ubuntu-2404` | `ubuntu-latest` | + +Keep GTK app builds on Ubuntu 22.04: its GTK 4.6 is the supported minimum. +Each architecture builds and tests natively. Standalone probe CI uses Ubuntu +24.04 with Zig targeting glibc 2.34. Do not point an ARM variable at an x64 +runner or use the general Ubuntu 24.04 variable for GTK builds. + +The [Blacksmith GitHub App](https://docs.blacksmith.sh/introduction/quickstart) +must have access to this repository. Configure the variables under repository +Settings → Secrets and variables → Actions → Variables, or with `gh variable +set NAME --body VALUE`. Values use the +[published Blacksmith runner labels](https://docs.blacksmith.sh/blacksmith-runners/overview). +Delete a variable to send its jobs back to the GitHub-hosted fallback on the +next run. These fallbacks apply when a variable is empty, not when a configured +runner is unavailable. + +Existing Rust cache actions work on Blacksmith without replacement. + +## Parallel CI lanes and required checks + +For both same-repository pull requests and pushes to `main`, CI first selects +changed components, then checks representative native architectures: macOS +arm64, Linux x64 and Windows x64. macOS packaging, Swift tests and core +integration tests run independently. Linux independently selects GTK/library +and real-core integration/startup steps within its native test job, alongside +the Zig core build and packaging. Windows also +runs native tests independently of packaging. Swift source/test changes select +Swift checks without Rust/npm core or release packaging work. Ordinary Rust +source/test changes retain native tests and core integration while deferring +release packaging on all platforms. Reviewed build inputs select the relevant +package lanes; unknown inputs conservatively select everything. Fork pull +requests retain the workflow's existing skip policy. + +The existing `macOS arm64`, `macOS x64`, `Linux arm64`, `Linux x64`, +`Windows arm64` and `Windows x64` check names are retained as compatibility +completion gates over the selected architecture matrix. Their names preserve +existing branch protection; they do not imply that a deferred architecture +was tested in that run. Job summaries list the actual architectures covered. +Each gate requires successful change detection and successful completion of +every applicable platform lane. A failed, cancelled or unexpectedly skipped +applicable lane fails the gate. An intentionally excluded component passes its +lightweight gate after successful detection and validation of the skipped +native dependencies. Gate runners remain active to preserve the expanded +architecture check names used by branch protection; missing/invalid flags, +failed detection and unexpected dependency results fail validation. +`Shell scripts` and `Probe` retain their names. +Both `Linux probe glibc 2.34 (arm64)` and `Linux probe glibc 2.34 (x64)` are +also preserved as inexpensive compatibility gates over the selected Linux +core builds in both representative and full-matrix modes. The actual producer +jobs are named `Linux core (arch)`. Their +gate summaries identify which architectures were checked and which are +deferred; producer failure or an unexpected skip fails both gates. + +Changelog/prose-only diffs skip all tests and builds; only change detection and +lightweight compatibility status jobs run. CI uses [job conditions](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-jobs-with-conditions) +to skip build/test jobs rather than the entire workflow, while retaining the +compatibility runners so expanded required-check names still report. + +Release automatically runs reusable CI tests for every platform and both arm64 +and x64 before release packaging begins. This prerequisite runs the complete +test matrix regardless of changed-path selection; the release's own build jobs +then package both architectures. It omits duplicate unsigned CI packages. There +is no nightly workflow. The tradeoff is deliberate: macOS x64, Linux arm64 and +Windows arm64 regressions can pass routine PR/main checks and be caught by the +required prerelease tests. The existing completion gate names remain usable by +branch protection under this representative-architecture policy. Release-only +linking, app bundle/runtime collection and installer issues introduced by +ordinary source edits may likewise be caught during release packaging. Every +release still packages both architectures after its full test prerequisite +succeeds. + +Each `Linux core (arch)` job uses Zig to build the core once, validates +the glibc floor and uploads that binary. The matching Linux package job and +selected integration steps download the same core artifact, so packaging and real-core +tests exercise the validated binary without compiling it again. A GTK-only +test change skips the core download, real app build, real-core tests and startup +smoke test. The artifact +carries a checksum and source, target and version metadata. Prerelease CI +builds it with the release's resolved version; the Linux release package job +reuses that validated artifact too. Core artifacts are retained for seven +days. `probe-version` also flows to the Linux unsigned CI packages so the +package version matches its downloaded core. + +Reusable CI accepts `full-matrix` (both architectures), `force-all` (every +component), `skip-packages` (omit unsigned CI packaging) and `probe-version` +(the version embedded in the core, default `0.0.0`). Release supplies all four +to request full tests without duplicate packages. A manual CI run can request +the full matrix with unsigned packaging enabled when diagnosing an +architecture-specific issue. + +Swift build caches separate architecture and package/test lane, and include +Swift, Xcode, SDK and manifest identities. Windows caches separate downloaded +Cargo sources from compiled dependencies; compiled cache keys include +architecture, toolchain, dependency manifests and debug/release profile, stay +stable across ordinary source commits, and exclude Cargo incremental state. +Dependency-focused caching avoids storing a new copy of large app/test binaries +for each source revision: workspace products are pruned before the first save, +and the key includes profile settings rather than the source SHA. Windows tests +disable dev/test debug symbols to limit linker work and cache size while +retaining debug assertions and overflow checks. Release builds use the same +Windows cache setup with their own release profile; test settings do not +change release builds. + +Windows platform, wording/state, GTK and hosting tests use targeted +`cargo test --no-default-features --features gtk` commands with `--lib` or +`--test`. The default `production-binaries` feature gates the app, core and +contract-codegen executables, so these tests avoid Cargo's automatic linking +of unrelated production binaries. The fake core remains available for the +integration harness. When packaging is skipped, a separate +`cargo check --features gtk --bins` keeps all Windows app/core/codegen entry +points checked with default features enabled without linking their executables. +Linux's GTK/library-only mode also opts out of default production binaries. +When Linux integration is selected, it shares one default-feature debug build +with GTK checks in the same native test job. Ordinary production builds, +real-core integration and release packaging retain default features. +`scripts/test_cargo_target_selection.py` guards that target selection. + +macOS and Linux Rust caches also separate package and test lanes. Linux's +GTK/library and real-core steps share the `tests` cache and setup, avoiding +duplicate GTK builds when both are selected. Parallel package/test jobs save +their own dependency builds. A new lane can seed itself from the +previous shared cache, using the complete historical key with read-only saves. +A marker restored with the lane cache prevents an older shared seed from +replacing a newer lane cache, including a partial lockfile match. Package +lanes prune restored debug profiles; macOS core tests and Linux native tests +prune release profiles. Cache markers live under the runner temporary directory, +outside the repository. +New Windows and Swift scopes start cold; restores are an optimization and +every selected lane still executes its builds and checks. + +## CI change selection + +`scripts/ci-changes.py` independently selects shell checks, Probe tests, Swift +tests (`macos`), macOS Rust/npm tests (`macos_core`), macOS packages +(`macos_package`), Linux GTK/library tests (`linux`), the Zig core build +(`linux_core`), real-core integration/startup (`linux_integration`), Linux +packages (`linux_package`), Windows native tests (`windows`) and Windows +packages (`windows_package`). It uses only Python's standard library and emits +boolean `key=value` lines suitable for `$GITHUB_OUTPUT`: + +```sh +python3 scripts/ci-changes.py --base "$BASE" --head "$HEAD" >> "$GITHUB_OUTPUT" +python3 scripts/test_ci_changes.py +``` + +The comparison uses `git diff --name-only --no-renames -z`: deleted paths and +both sides of a rename participate, and spaces or newlines in a filename stay +intact. Callers using stdin must supply that same NUL-delimited format. A +successful `--base`/`--head` Git comparison with no changed paths selects no +lanes, including distinct commits with identical trees. Empty/malformed stdin +or unavailable history selects every job, since an empty upstream transport +cannot prove that nothing changed. `--all` always selects every group. No +changed-path service or third-party filter action is required. + +Pull request selection compares the event base with the tested merge commit, +covering the whole PR diff rather than only the latest commit. A doc-only +follow-up on a PR that also changes code still selects the lanes required by +that code. + +| Changed input | Selected job groups | +| --- | --- | +| `CHANGELOG.md`, reviewed documentation allowlist, documentation PNG screenshots, or `ipad/` only | None | +| Swift source/tests in known macOS or shared package target directories | Swift checks only | +| Exact Swift package manifests, macOS plist/entitlements, or macOS app resources | Swift checks and macOS packaging | +| Generated `macos/Sources/RelayDesktopCore/CoreContract.swift` | Probe regeneration validation and Swift checks | +| Linux packaging under `linux/` | Shell, Linux GTK/library tests, core, integration and packaging | +| Windows packaging under `windows/` | Shell, Windows native tests and Windows packaging | +| `probe/tests/linux_app_gtk.rs` or Rust modules under `probe/tests/linux_app_gtk/` | Linux GTK/library and Windows native tests; no core build or integration | +| Rust modules under `probe/src/linux_app/ui/` | Linux GTK/library tests, core, integration and Windows native tests | +| `probe/tests/pi_live_session.rs` | Probe and macOS core tests | +| `scripts/test-pi-live.sh` | Shell, Probe and macOS core tests | +| Other Rust modules under `probe/src/` or Rust tests under `probe/tests/` | All native test/core groups, without packaging | +| Cargo/build inputs, resources, scripts, CI configuration, shared fixtures, assets, global configuration, or unknown paths | All | + +The reviewed prose allowlist lives in the script as explicit paths; `.md` +alone never exempts a file. It includes the reviewed top-level `docs/*.md` +files, probe and integration READMEs, Windows icon READMEs and the DMG artwork +prompt. New documents remain unknown until reviewed. `docs/relay-core.md` is +excluded from that allowlist because contract tests read it, and keeps every +component selected. Routine `CHANGELOG.md` edits add no tests or builds; in a +mixed change, the remaining paths determine all selected lanes. `probe/build.rs` +still reads the release heading and derives development version metadata from it, so +changelog-only edits defer validation of that input until the next selected +build or release. Release forces every component and packages the actual +resolved version, even when only the changelog changed. Shared GTK bridge, +host and model source retains all native test/core groups because those +modules also build without GTK on macOS. Changes to this selector +or its tests run the shell job and every build group. Extend the allowlist only +after checking consumers and adding a regression test. + +The audited GTK harness and its Rust modules select Linux GTK/library and +Windows native tests without the Zig core or real-core integration steps. Rust +UI modules under `probe/src/linux_app/ui/` are guarded by the GTK feature and +also select the Linux core and integration lanes, without scheduling Probe or +macOS jobs. Shared bridge/host/models remain broader native inputs. +The audited Pi live-session test and runner script select Probe and macOS core +tests, with shell checks added for the script. These exceptions apply before +the broader source/script defaults. Other test helpers retain their existing +coverage, and combining an exception with core, build or unknown inputs takes +the union rather than suppressing those inputs. + +`macos`, `macos_core` and `macos_package` control Swift tests, Rust/npm tests +and release packaging independently. Swift tests compile the macOS app entry +point through the package's test-target dependency. The Swift lane also runs +`swift test --package-path shared/RelaySessionOverview` so the shared package's +own test target executes, rather than only building its library as a dependency. +Known source/test targets select Swift checks alone; exact package manifests, +plist/entitlements and the app's existing resource scope also select packaging. +Unreviewed future package targets/build paths default to all groups. +The macOS completion gate accepts Pi-only changes with app lanes skipped and +core tests required. The generated +Swift contract is an output of the Rust generator rather than a Rust/npm input, +so changing only that file runs Probe's consistency check and Swift validation. A matching Rust +registry change selects native core tests, including macOS core tests. Unknown paths, +shared core/dependency/toolchain inputs and every fallback also select +`macos_core`, so a mixed Swift/core change cannot hide core regressions. + +`linux` selects GTK/library steps without requiring a core artifact; +`linux_core` controls the Zig build; `linux_integration` requires that build and +selects the real-core and app-startup steps in the same native test job. +`linux_package` also requires the core artifact. The completion gate validates +the selected work independently. `windows` and `windows_package` separately +control native tests and the release build, portable zip and installer smoke +checks. Ordinary Rust source changes skip all three package flags, while Cargo +build files, resource/build inputs and unknown paths retain packaging coverage. +Mixed changes take the union of their selections. The minimal prerequisites +are `macos_package` → `macos`, `linux_integration`/`linux_package` → `linux_core`, +and `windows_package` → `windows`. Reviewed docs and validated unchanged Git +trees select no lanes; unavailable history, empty/malformed stdin and explicit +`--all` select every group. +Release forces every component, skips duplicate unsigned CI packages during +prerelease tests, and then builds the actual release packages as before. diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000000..d4e4ce1002 --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,27 @@ +# SwiftPM build products +macos/.build/ +macos/.swiftpm/ +*.xcodeproj + +# Packaging output (scripts/*.sh and the CI workflows write these at the root) +/dist/ +/out/ +/probe-build/ +/probe/target/ +*.dmg +*.icns +*.iconset/ +*.zip + +# Tool caches an agent or editor may place in the checkout +.npm-cache/ + +# Python scripts +__pycache__/ +*.pyc + +# macOS +.DS_Store + +# Local git worktrees, never committed +.worktrees/ diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md new file mode 100644 index 0000000000..038c7262e6 --- /dev/null +++ b/apps/desktop/AGENTS.md @@ -0,0 +1,82 @@ +# Agent instructions + +## Keep the macOS app and the GTK app (Linux and Windows) in sync + +The macOS app (`macos/`, SwiftUI) is the reference design. The GTK app +(`agent-relay-desktop` with the probe's `gtk` feature: `probe/src/linux_app`, GTK 4) +serves both Linux and Windows: it is one program built for each, and it must +offer the same features, layout and wording as the Mac on both. +Both apps are thin clients of the same relay core, so nearly every user-facing +change is UI work on both sides over contract methods that already exist. + +When a change touches the user-facing app on one platform, make the matching +change on the other in the same PR: + +| macOS | Linux and Windows | +|---|---| +| `macos/Sources/RelayDesktop/WorkspaceShell.swift`, `Navigation.swift` | `probe/src/linux_app/ui/shell.rs` | +| `AgentsView.swift`, `AgentRows.swift`, `RelayModel.swift` | `probe/src/linux_app/ui/agents_page.rs`, `ui/relay.rs` | +| `TeamView.swift` | `probe/src/linux_app/ui/team_page.rs` | +| `MainWindowView.swift` (sessions), `SessionsTable.swift`, `UploadStagesView.swift` | `probe/src/linux_app/ui/sessions_page.rs` | +| `AgentsView.swift` (`MessageSheet`, `ActivitySheet`) | `probe/src/linux_app/ui/messages.rs` | +| `SettingsView.swift`, `AboutView.swift`, `IncomingModel.swift` | `probe/src/linux_app/ui/settings_page.rs` | +| `SignInView.swift`, `AppModel.swift` (sign-in, links) | `probe/src/linux_app/ui/app.rs` | +| `MenuPanelView.swift` | `probe/src/linux_app/ui/tray.rs` | +| `AppUpdater.swift`, `RelayDesktopCore/AppUpdate.swift` | `probe/src/linux_app/updates.rs` and About in `ui/settings_page.rs` | +| `RelayDesktopCore/*` presentation logic (presence, upload states and stages, wording, filters, diagnostics) and `shared/RelaySessionOverview` | `probe/src/linux_app/models/` | + +Rules: + +- Port behavior and wording, not just layout: user-visible sentences should + match the Mac's word for word unless the platform differs (menu bar vs + tray, LaunchAgent vs systemd user service vs logon task, DMG vs + tarball/`.deb` vs zip). +- A core method the Mac app starts calling must be called by the GTK app + too (and the reverse). Regenerate the Swift contract with + `scripts/generate-contract.sh` after changing `probe/src/relay/contract`. +- When a feature cannot or should not exist on one platform, say so and why + in the PR description. +- Platform-specific modules get a Windows counterpart. Code in + `probe/src/linux_app` or the core that touches the operating system + (sockets, signals, processes, the background service, the tray, opening + links and notifications, the install tree and updates, file modes) keeps + the same trait, contract and wording on Windows, behind `cfg(windows)`. A + feature that cannot exist on Windows (a harness that is a Unix-only tool) + is reported unavailable by the core, not hidden, and the PR says why. +- Add or update tests on both sides: + `swift test --package-path macos` and + `cargo test --manifest-path probe/Cargo.toml --features gtk --test linux_app_gtk` + (with `--lib linux_app` for the wording and state). On Windows, run the same + in an MSYS2 CLANG64 or CLANGARM64 shell, plus + `cargo test --manifest-path probe/Cargo.toml --features gtk --test linux_app_hosting`; + CI runs them on x64 and arm64 (see `windows/README.md`). +- Update `docs/workspace-layout.md`, `linux/README.md` and `windows/README.md` + when the layout or a feature changes. + +## Release notes + +Every PR that changes what someone using the app or core sees or relies on +adds a bullet under `## Unreleased` in `CHANGELOG.md`, in the same PR. Write it +for the person using Agent Relay, in the style of the bullets already there: a +bold one-line outcome, then what changed and what they do differently, naming +macOS and Linux where they differ. Build-only, CI-only, test-only and iOS-only +changes don't need one. The Release workflow refuses to run while +`## Unreleased` has no bullet, so a missing note blocks the next release. + +## Primary buttons + +Every blue action must use `AgentRelayPrimaryButtonStyle` on macOS and the +`primary` CSS class on GTK, including actions inside popovers and dialogs. +These are the canonical Agent Relay brand button: deep blue, white label, dark +border, and a tight bottom-left corner in both appearances, without shadow/glow. +Only sizing may vary at call sites. Do not use `.borderedProminent`, workspace +accent fills, local primary styles, or per-screen color/corner overrides. +Change the shared style on both platforms when the brand treatment changes. + +## Linux background service behavior + +The Linux app starts the `agent-relay.service` systemd user unit only when the +person enabled it: its `--headless` mode applies the agent-led defaults and +removes the app's login entry. Never stop, kill or disable that unit as part of +setup, repair or testing; restart it if it must pick up a change. The same +holds for the Windows **Agent Relay ()** logon task. diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md new file mode 100644 index 0000000000..ddecbe7fa8 --- /dev/null +++ b/apps/desktop/CHANGELOG.md @@ -0,0 +1,531 @@ +# Changelog + +Write the user-facing changes for each release under `## Unreleased` before +running the Desktop Release workflow. The workflow copies that section into +**What's new in this release** and uploads a promoted changelog artifact after +publishing; land that artifact through a normal pull request. A release whose +`## Unreleased` section has no bullet stops before building. + +## Unreleased + +- **Install the Pi extension without following replaced links.** The installer now validates and reads an existing Agent Relay extension through one no-follow file handle before its atomic update, so a swapped symbolic link is refused. +- **Preview session-history retention controls.** Settings › General › **Session history** shows the planned 7, 30 or 90 day and forever choices, but keeps the control disabled until relayhistory can enforce deletion. Agent Relay does not remove older sessions automatically yet. Same on macOS, Linux and Windows. + +- **A simpler sign-in screen.** The logged-out screen on macOS, Linux and Windows now offers **Sign in with Google** without the redundant **Use another account** button. Signing in always opens Google's account chooser instead of silently reusing a saved app or CLI session. + +- **History identity checks preserve active database locks.** On macOS and Linux, checking the local history file no longer releases SQLite locks held by another connection in the app or collector. New stores still use private file permissions. Windows keeps its existing file identity checks. + +- **Upload status stays responsive as local history grows.** On macOS, Linux and Windows, upload counters use transactional totals, delivery reuses its database connections, and unchanged status and session lists reuse shared snapshots. + +- **The desktop app no longer takes the `agent-relay` command name.** On Linux and Windows the GTK launcher is now `agent-relay-desktop` (`agent-relay-desktop.exe` on Windows), including app shortcuts, updates, launch at login and the Windows background task. `--headless` and `--install-launcher` keep working under the new name, while `agent-relay` remains available for the command-line tool. Existing Linux tarball and Windows installs need one manual upgrade to this release because their old in-app updater requires the former filename: install the new `.deb` or unpack the new Linux tarball, or run the new Windows Setup program. In-app updates resume afterward. macOS is unaffected. +- **Muse access stays inside sessions Agent Relay starts.** On macOS and Linux, Agent Relay no longer discovers or messages Muse sessions opened in a terminal or hosted by another app. Relay-owned Muse spawns keep their existing sandbox, follow-up messaging and lifecycle behavior; Muse remains unavailable on Windows. +- **Cursor Cloud agents join Agent Relay without the local `cursor-agent` CLI.** Agents can discover models, cached repositories and environments, list existing agents and runs, start one with an idempotency key, inject follow-ups while it works, read or cancel run results, and archive or restore it through the shared core on macOS, Linux and Windows. Agent Relay Cloud keeps the Cursor API key and registers each hosted agent as `cursor-cloud`, so messages become Cursor follow-ups and never expose credentials to the desktop or agent sandbox. + +- **Community builds no longer send analytics to Agent Relay's PostHog project.** Official macOS, Linux and Windows releases still support the same opt-out analytics, but their ingestion configuration now comes from protected release secrets instead of the source tree. Forks, local builds and tests contain no project configuration and send nothing. + +- **Public examples use generic people and computer names.** Documentation and the trusted-senders placeholder on macOS, Linux and Windows no longer expose names from Agent Relay's internal development environment. + +- **The Linux headless installer now downloads from the public releases page.** Install or update the core on a server with `curl -fsSL https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/install.sh | bash`; the old source-repository link no longer works once the source moves. + +- **Every download now includes the Apache 2.0 license.** The macOS app, the Linux tarball and `.deb`, the Windows zip and Setup program, and the standalone core archives ship `LICENSE` and the agent icons' license. + +## v2026.10.60 + +- **Continue an open Claude conversation from your phone, experimentally.** Enable **Continue Claude from mobile (Experimental)** once in Relay Settings on macOS or Linux. Windows shows the setting as unavailable until its native connection is verified. Your messages reach a supported running Claude session as native user turns, preserving its conversation and permissions; reading it sends nothing. Tool prompts go only to the signed-in machine owner for one-action approval. Claude stays in its original session, and mobile shows connecting, queued or failed status without treating a queued message as delivered. + +- **macOS keeps the warning when a chat action is interrupted by a service restart.** A disconnect arriving after the action fails no longer clears its warning; your restored draft stays available so you can check whether the action completed before retrying. + +- **Agent lists keep the native session title across devices.** macOS, Linux and Windows publish existing titles and later renames to the roster, so iPhone, iPad and other clients can show them before a generated summary exists. OpenClaw and Hermes gateway agents no longer link their agent IDs to unrelated session history. Title refreshes keep ended sessions idle. + +- **Peer updates no longer ask agents to reapprove work you already authorized.** On macOS, Linux and Windows, Relay's message instructions let agents continue within the receiving session's existing scope and tool permissions. Requests outside that scope still need your approval; another agent cannot grant it on your behalf, and Claude's own peer-message restrictions still apply. + +## v2026.10.59 + +- **Uploads resume within minutes after the history service recovers.** When session uploads kept failing because the service was down, Agent Relay waited longer and longer between tries, up to about twenty minutes after a long outage, so uploads stayed idle well after the service was back. It now waits at most about five minutes between tries after a server error or a dropped connection, and **Retry uploads** tries again at once instead of doing nothing while uploads wait out such a failure. A rate limit from the service is still honored: Retry leaves that wait alone. Uploads that keep failing this way now offer **Retry uploads** in the upload stage, and a wait an older version stored is shortened when Agent Relay updates. Same on macOS, Linux and Windows. + +- **"Reconnecting to its background service" no longer stays on screen.** If **Retry uploads** or **Compact now** could not reach Agent Relay's background service, the message under the button stayed until you clicked again, even after the service was back. It now clears as soon as the service answers, and the retry or compaction, which never reached the service, is sent once on its own. Errors from an action the service did receive stay until your next action, as before. Same on macOS, Linux and Windows. + +- **Diagnostics cannot indefinitely delay app updates.** On macOS, Linux and Windows, update diagnostics calls that must finish before proceeding have a three-second deadline. The GTK window stays responsive while that call finishes, and an unresponsive core cannot hold up the restart. + +- **Optional diagnostics cover more desktop activity.** When Share diagnostics is enabled, macOS, Linux and Windows report agent registration, messaging, navigation, searches and update handoffs with the same event schema. Search text, message contents, names and session IDs stay private. Following a transcript in the background does not count as opening it again. + +## v2026.10.58 + +- **Failed Claude Code delivery changes keep their error visible.** On macOS, Linux and Windows, refreshing the approval state after a refused change no longer hides why it failed. A later explicit refresh still clears a recovered error. +- **Agent Relay spends less CPU keeping sessions up to date.** On macOS, unchanged updates reuse the session list and its search data, and upload indicators animate without rebuilding the table. On macOS, Linux and Windows, a session-file change scans the affected provider instead of every provider; periodic full scans still discover new roots and recover missed changes. +- **Every switch and button answers at once.** Changing the upload policy, pausing or resuming, retrying or compacting uploads, a core setting's switch, renaming an agent or putting a session on the relay under a name, sending a message (from the composer, under a spawned agent or as an agent's test message), sharing a Codex thread or approving a held message, allowing or restricting Claude Code delivery, releasing a fleet worker, dismissing a seat or revoking a grant, and starting at login on Linux and Windows now show the change the moment you make it, the way the upload and messaging switches already did: the policy row moves and stays usable with a small spinner, the message appears in the conversation as *Sending…*, the released worker reads *Released*, and so on. If Agent Relay cannot make the change, the control goes back to what the core reports and the reason shows beside it; clicking again while a change is still saving makes the last click win. Logging out on macOS now shows *Disconnecting this computer…* until the account is gone, and a second sign-in click while the first is starting does nothing, as on Linux and Windows. +- **A column edge stops being highlighted once you finish resizing.** Dragging the edge of a sessions-table column used to leave that edge highlighted until something else took focus. It now clears as soon as you let go, cancel with Escape or double-click to reset the width. Same on macOS and Linux. +- **Windows: Upload session works while uploads are running.** Since the first Windows release, turning on **Upload session** in an open session, or **Upload** for selected rows, did nothing and showed no error, and the upload status could not be read, whenever the uploader was running: Windows reports a lock another process holds differently from Linux and macOS, and the app took the running uploader's own lock for a failure. The switch now uploads the session and the Upload column reports its progress, as on Linux and macOS. Nothing changes on macOS or Linux. + +- **Relay core failures leave a local diagnostic log.** On macOS, Connect failures and other core diagnostics are saved in `~/Library/Logs/Agent Relay/core.log`, even when the background service has no terminal. Agent Relay Dev uses its own log folder. Linux and Windows keep the same log in the app data directory's `logs` folder; Linux also keeps its systemd journal output. Logs redact credentials and rotate automatically at 1 MiB, keeping three backups. + +## v2026.10.57 + +- **Opening a newer Agent Relay replaces the one still running.** On Linux and Windows, starting a later version from the Start menu, the app menu or a link while an older one sat in the tray used to hand the launch to the old version, so you stayed on it without knowing, for instance after an update that installed but could not restart. The running app now quits and the newer one opens in its place with what you launched, also after a `.deb` upgrade over a running app. On Windows, an update whose background-service task is off no longer fails when that task cannot be moved: the new version sets it up on its own start. Nothing changes on macOS, which has its own single-window and update handling. + +## v2026.10.56 + +- **Windows: the background service's logon task is set up again.** Since the first Windows release, Settings › **Background service** said *Not installed* on every per-user install, because the app kept its task definition open while handing it to Task Scheduler, which refused to read it. The app now registers the **Agent Relay ()** task, turned off, on its next start, and the switch works as on Linux. +- **Rename a session right in its header.** On macOS, Linux and Windows, title editing now lives only in the open session. A clear Rename button turns the title into editable text without a boxed field; Enter or Done saves, Escape cancels, and clicking away saves after a short delay. The sparkle still fills in the saved pipeline title. The sessions table keeps its simple click-to-open behavior. Reconnecting during the save delay keeps your unsaved draft available. +- **OpenCode, Muse and Devin sessions on the relay show as on the relay.** Opening one of these sessions' transcript showed it as Offline with "Start session to receive messages." even while it was receiving messages, and the session could appear twice in the list. The transcript now shows the session's real messaging state and switch, and a session listed both as a transcript and as a relay session is one row. One of these sessions you put on the relay is now uploaded, like Codex, Claude and Grok sessions. Same on macOS and Linux. + +## v2026.10.55 + +- **A Grok session on the relay shows as on the relay, under Grok's own title.** Opening a running Grok session's transcript used to show it as Offline with "Start session to receive messages." even while it was receiving messages, and named it after the `` block Grok adds before your first prompt. The transcript now shows the session's real messaging state and switch, and the session is named by the title Grok generated for it. A Grok session you put on the relay is now uploaded, like Codex and Claude sessions. Same on macOS and Linux. +- **Agent Relay checks your agents with Cloud less often, and backs off when Cloud is busy.** Your computer sent its full agent list to Cloud every minute, and Cloud checked each agent with Agent Relay every time, even when nothing had changed. It now sends the list when an agent is added or removed, after you sign in again, and otherwise every 30 minutes. When Cloud can't check agents right now, the computer waits before trying again instead of retrying every minute. On macOS and Linux. +- **Codex terminal sessions receive messages without Node on the service PATH.** On macOS and Linux, Agent Relay finds the native CLI bundled with npm installations, including Homebrew and version-managed installs. macOS also recognizes the newer Codex app CLI location, avoiding false “command line is not installed” failures for sessions in terminals such as cmux. +- **Install Agent Relay on Windows with a double-click.** Download **AgentRelay-Windows-x64-Setup.exe** or **AgentRelay-Windows-arm64-Setup.exe** and open it: it installs for your account without asking for administrator rights or a folder, adds Agent Relay to the Start menu (and the desktop, if you tick it), registers `agentrelay://` links and starts the app. Running a newer installer upgrades in place, and in-app updates keep working as before. Uninstall from **Settings › Apps › Installed apps**: it removes every installed version, the shortcuts, the link handler, launch at login and the background-service task, and keeps your sign-in and data. The installer is not code-signed yet, so SmartScreen shows *Windows protected your PC* the first time: choose **More info** › **Run anyway**. The zip stays available as the portable alternative. + +## v2026.10.54 + +- **Starting Agent Relay again brings back the window you have open.** Launching it a second time, from the Start menu, the app menu, a terminal or an `agentrelay://` link, used to leave a process running with no window. Now that launch hands its link or `--background` to the running app and exits: the window comes forward with the link opened, and you still have one tray icon. On Linux and Windows. +- **The Linux and Windows window looks like the Mac's.** The logo and the search field's magnifier no longer light up under the pointer, the header runs edge to edge in one color with borderless saved-views and Table display icons, and the status bar, settings buttons and page titles use the Mac's heights and spacing. Selecting sessions now tints the rows lightly instead of filling them solid, the selection bar is a quiet card with a plain **Hide** button, checkboxes use the Agent Relay blue, and opening the avatar menu no longer selects your email address. Nothing changes on macOS. +- **Agent Relay runs on Windows.** Download **AgentRelay-Windows-x64.zip** or **AgentRelay-Windows-arm64.zip** from the release, unpack it under `%LOCALAPPDATA%\Programs\AgentRelay\` and start `agent-relay.exe`: you get the same window as on Linux and the Mac (sessions, Agents, Team, Messages, Settings, sign-in) and a tray icon with the same menu, and it adds itself to the Start menu and handles `agentrelay://` links. Settings › **Background service** runs Agent Relay as a logon task, so your agents stay connected while the app is closed, without administrator rights; updates download the verified zip and switch over in place. Claude Code and Codex sessions list, upload, receive messages and spawn as on the other platforms; Muse, Devin, Grok CLI, Hermes, OpenClaw, OpenCode existing-session injection and Pi need Unix-only tools and show as unavailable on Windows. The bare core ships as `AgentRelay-Windows--probe.zip`. Releases are not code-signed yet, so Smart App Control must be off and SmartScreen asks once. The Windows placeholder is gone. +- **Qwen Code, Kimi Code and Codebuddy Code sessions are tracked and uploaded.** Agent Relay now uses RelayHistory 0.50.1, which reads those three harnesses' native session files like Claude Code and Codex ones, subject to your session-sharing setting, and runs natively on Windows Arm PCs. Its relay tools also move on to the next Agent Relay socket when a Windows one is busy instead of reporting that the desktop is not running. On macOS, Linux and Windows. + +## v2026.10.53 + +- **Edit session titles without leaving Agent Relay.** On macOS and Linux, hover a title and click its pencil to edit it. The sparkle button fills in the pipeline’s saved title, preloaded when available; Enter saves and Escape cancels. Titles update immediately and roll back with an error if the original harness cannot save them. Unsupported sessions explain why editing is unavailable. +- **Session titles can be changed in their original harness through Agent Relay’s core.** Native title updates now support Codex, local Claude Code and Devin sessions, connected OpenCode and Grok sessions, Pi with the refreshed Relay extension, and local Hermes and OpenClaw conversations. Capability checks explain unavailable routes; a rename never sends an agent prompt or changes the Relay agent’s address. + +- **Spawns no longer fail when Agent Relay is slow to register the agent.** When Agent Relay Cloud was under load, it could take longer than 10 seconds to register a spawned agent, and the spawn failed with "Agent Relay did not register the agent in time" even though the agent had been registered. The computer now waits up to 30 seconds more for that answer and launches the agent when it arrives. On macOS and Linux. +- **Relay Connect retries overloaded sends without duplicating them.** Every Connect message now carries a fresh idempotency key, so Relay can retry a temporary server failure and still accept it only once. If the final outcome is unknown, the socket error returns the key to use for an exact retry. +- **Pi messages keep their order and channel answers return.** On macOS and Linux, messages queued while Pi is busy now run in arrival order, and an answer to subscribed channel posts returns to the last post automatically. Retries retain that original reply target even if a post leaves the queue. Extension installation failures stay visible in the app's status and Settings instead of leaving an empty session list without an explanation. Run `/reload` in Pi to load the updated extension, then turn its Relay registration off and on again. +- **Relay agent names describe their work.** New automatic names use the harness, repository, and up to two meaningful words from the session title, such as `claude-cloud-plan-support`, instead of a random-looking suffix. Duplicate names get a number; sessions without a task title use `session`. Existing and explicitly chosen names stay unchanged. Shared by macOS, Linux and headless mode. +- **Start Replicas agents through Agent Relay Cloud.** Once an owner connects Replicas in Cloud, agents can discover environments, start a replica, check its Relay connection, and release it through the shared core on macOS and Linux. Cloud owns delivery and credentials; it does not need a bridge running on your computer. +- **Antigravity and Oh My Pi sessions are tracked and uploaded.** RelayHistory 0.49.0 also reads Antigravity's App, CLI and IDE transcripts and Oh My Pi's sessions, like Claude Code, Codex and Pi ones, subject to your session-sharing setting. Oh My Pi sessions kept beside Pi's are not listed twice. On macOS and Linux. +- **OpenClaw no longer lists a session for every heartbeat.** Each OpenClaw heartbeat or cron run used to show up as its own session, so a single cron job could fill the list with dozens of near-empty rows. Agent Relay now uses RelayHistory 0.49.0, which shows each OpenClaw conversation once, as OpenClaw's own session list does: every run of a cron job, and every reset of a chat, is part of one session, and heartbeat check-ins are no longer counted as your prompts. OpenClaw sessions already uploaded are re-listed under their conversation the next time history syncs, and the old per-run copies are removed from the cloud. Your choices carry over to the conversation. If you deleted any of its runs from the cloud, it is not uploaded. If you excluded any of its runs, it is not uploaded either, unless you share “Only sessions I choose” and chose another of its runs. On macOS and Linux. + +## v2026.10.52 + +- **Pi messages keep their order and channel answers return.** On macOS and Linux, messages queued while Pi is busy now run in arrival order, and an answer to subscribed channel posts returns to the last post automatically. Retries retain that original reply target even if a post leaves the queue. Extension installation failures stay visible in the app's status and Settings instead of leaving an empty session list without an explanation. Run `/reload` in Pi to load the updated extension, then turn its Relay registration off and on again. +- **New Relay Connect rooms last at most 7 days.** `agent-relay-probe connect create --expires-in-minutes` refuses more than 10,080 minutes for a new room, before it asks you to sign in, matching Agent Relay Cloud. A host can still rejoin a longer room an earlier version created by repeating its original command. On macOS and Linux. +- **Spawned agents have no limit by default.** A computer that accepts spawns used to take at most 12 agents at a time unless you raised it. Now there's no limit unless you set one: Settings › **Max spawned agents** is empty by default, and a number there caps it as before. If you never changed the old default of 12, it becomes no limit; a cap you saved yourself stays as it is. Agent Relay Cloud places agents on computers without a limit by how busy they are. On macOS and Linux. +- **Each count in the menu bar opens exactly the sessions it counts.** Choosing **Receiving messages**, **Local only**, **Uploaded** or **Not uploaded** opens the session list searched for those sessions (`is:active is:on-relay`, `is:active is:off-relay`, `upload:uploaded`, `-upload:uploaded`), and the count is that same search, so the numbers match. **All sessions** replaces **Open Agent Relay**, and the sections are now **Messages** and **Uploads**, since every count is of sessions. On macOS and Linux. +- **Problems no longer push a banner into the menu.** When something stops messages or uploads, the menu-bar icon turns orange and so does the dot beside **Messages** or **Uploads**; hover the dot to read what's wrong, or click it to open Diagnostics with the full details. The menu no longer jumps away from the menu bar when a warning clears. On Linux the tray icon turns orange and **⚠ Needs attention** opens Diagnostics. +- **Message an already-running Pi session.** On macOS and Linux, Agent Relay installs a Pi extension; run `/reload` once in your existing Pi session, then enable **Receives messages**. Messages wait for Pi to be idle, and completed answers return through Relay. Pi needs no upgrade or restart. + +- **Claude Code cloud sessions are now a setting you turn on, so macOS no longer asks for your Keychain out of nowhere.** Uploading your claude.ai cloud sessions needs Claude Code's sign-in, which on a Mac is in your Keychain, and the sessions helper used to raise macOS's "wants to use your confidential information" prompt from the background every few minutes. Settings → Relay → *Claude Code folders* now has **Claude Code cloud sessions**, off by default. When you turn it on, macOS asks once, next to a line explaining why; choose **Always Allow** and uploads continue in the background without asking again. Background uploads never show the prompt. Cloud sessions you uploaded before stay uploaded; new ones wait until you turn this on. On macOS and Linux (Linux reads the sign-in from Claude Code's file and never prompts). +- **New installs upload only the sessions you select by default.** The probe setup now offers selected sessions first, matching the macOS and Linux apps. Initial history indexing processes the last 24 hours first, then the rest of the week, then older sessions, so recent work is available sooner. +- **The Table display button above the sessions table is a plain columns icon.** The gear badge is gone on macOS and Linux. + + +## v2026.10.51 + +- **The menu-bar panel and Linux tray are an at-a-glance summary.** Instead of a list of recent sessions, the menu shows how many of this computer's agents are **Receiving messages** and how many are **Local only**, and how many sessions are **Uploaded** and **Not uploaded**; each count opens its page in the app. On the Mac the counts are badges, blue while live and muted otherwise, and the upload status is a dot beside Sessions that says what it means on hover. **Open Agent Relay**, **Settings…** and **Quit Agent Relay** sit at the bottom. +- **Uploads and messages use your Agent Relay account's workspace, and only that.** Agent Relay no longer reads a workspace key from `RELAY_WORKSPACE_KEY`, `RELAY_API_KEY`, the `agent-relay` CLI's files or a key pasted into the app, so a stale or over-quota key elsewhere on the computer can't send your agents to another workspace. Settings › Messages shows **Workspace from your Agent Relay account**; switch workspaces from the account menu. The paste-a-key sheet and the workspace-mismatch notice are gone on macOS and Linux. A key you pasted before is dropped; agents registered in another workspace stay there until you turn them off and on again. +- **Agent Relay starts its background service again on Macs where macOS lost track of it.** On some Macs, macOS reported the service enabled while it could not find the helper inside the app and kept failing to start it, so the app waited on the core forever. Agent Relay now detects that and registers the service by its full path instead, asking for Login Items approval if macOS needs it. The app must run from **Applications** (or `~/Applications`); opened from the disk image or another copy, it says to move it there first. +- **Agent Relay warns when a Claude Code session stops reading its messages.** Claude Code reads messages delivered to a terminal session only when its current turn ends, so a session whose turn is stuck kept collecting messages while it still looked active. Agent Relay now watches the session's transcript, and when a delivered message has sat unread with no progress for 10 minutes it adds a warning to Activity and marks the agent stalled (with how many messages wait and for how long) in the core's `agents.list` and in `GET /agents` on the session socket. The warning clears once the session reads them. Set `RELAY_DESKTOP_CLAUDE_INBOX_STALL_SECS` to change the 10 minutes. Delivery itself is unchanged: messages are never resent. + +## v2026.10.50 + +- **Give your OpenClaw agents Agent Relay's tools in one click.** While OpenClaw is on, Settings → **Agent Relay tools** has **Connect**: Agent Relay adds its hosted MCP to `openclaw.json` (or reuses one already there) and opens its consent page in your browser, where you pick a workspace and agent name and click Approve, as for Grok Bot and ChatGPT. Your OpenClaw agents can then list agents on the relay, message them and spawn new ones, with no terminal or config editing. Messages from people who aren't trusted senders still never get these tools. On macOS and Linux. +- **Session uploads no longer stop while macOS waits to allow access to Claude Code's sign-in.** On a Mac where Claude Code keeps its claude.ai sign-in in the Keychain, the sessions helper waited for the answer to macOS's "allow access" prompt before doing anything else, so when nobody answered it (a headless Mac, or nobody at the screen) the collector stayed on **Scanning catalog…** and no session was uploaded, for hours. The helper now waits a few seconds at most: until the prompt is answered, Claude Code cloud sessions are skipped ("Waiting for Keychain access to Claude Code's sign-in") and every other session uploads as usual, without a second prompt. Once you allow access, cloud sessions are picked up on the next pass; if you deny it, the helper does not ask again for an hour. +- **OpenClaw no longer needs upgrading to join the relay.** Agent Relay now works with every OpenClaw from 2026.3.13 on, choosing what it sends from what the Gateway reports, so an older Gateway no longer refuses the connection or a message. Every sender still gets a session of its own, and an agent's answer still goes back to that sender only, with no duplicate turns. OpenClaw older than 2026.8.1 cannot limit relay sessions, so on those versions anyone on the relay who messages your agents gets a session with the agent's usual tools; Settings → **OpenClaw agents** says so on macOS and Linux, and updating OpenClaw to 2026.8.1 or later makes those sessions answer-only. +- **A relay message is no longer sent to OpenClaw twice.** On every released OpenClaw up to 2026.10.4, Agent Relay's check of a sender's OpenClaw session was refused, so a retried delivery could start a second turn and a new message could land while the agent was still answering the previous one. The check now asks only for what every OpenClaw accepts. +- **OpenClaw sessions are tracked and uploaded.** Agent Relay now uses RelayHistory 0.47.0, which reads every OpenClaw agent's sessions (main, Telegram, relay, cron and subagent chats) like Claude Code, Codex and Hermes ones, subject to your session-sharing setting. It works on every OpenClaw from 2026.3, including the JSONL session store that versions before 2026.8.1 use, and a session OpenClaw later imports into its database is not uploaded twice. +- **Agent Relay no longer offers a Claude Desktop handoff that Claude Code will refuse.** **Open in Claude** ran `claude --desktop --resume`, which Claude Code refuses for a session another process is already running ("Session … is open in another terminal. Run /desktop there instead.") — and the sessions at the top of the list are running ones, so it failed nearly every time it was clicked. Agent Relay now reads what holds each Claude Code session and offers only what works: a session you are typing in keeps **Open in Terminal**, with a note to run `/desktop` in that terminal to continue it in Claude; a session a headless process holds has no open action, with a note to run `claude agents` to find it. **Open in Claude** comes back once nothing holds the session. On Linux, where Claude Code has no desktop handoff, the headless session is the one that changes. + +## v2026.10.49 + +- **OpenClaw and Hermes agents join the relay.** Where OpenClaw runs on this computer, Agent Relay lists each agent of its Gateway (**OpenClaw · **), and where Hermes has a `relay` profile, it lists **Hermes (relay)**. Messages to them land in a session of their own for each sender, never in your main chat or channels, and the agent's answer goes back to the sender automatically. Relay sessions are answer-only: OpenClaw's read-only mode, with slash commands treated as text, and a Hermes profile with no tools. Settings → **OpenClaw agents** and **Hermes** turn each off, and **Trusted senders** lists relay names whose messages reach your own OpenClaw and Hermes instead, with their memory and tools. OpenClaw's own tools can now use the relay too (list agents, send, reply). + +## v2026.10.48 + +- **A renamed agent's previous name keeps working for a day.** When a session is renamed again, the name it replaces is hidden from rosters and never used to send, but still forwards messages to the session for 24 hours, so a late reply arrives. Renaming back to it within that time takes it up again, and every name is released when the agent leaves the relay. The name the session was registered with still forwards for as long as it is on the relay. The rename message on macOS and Linux says so. +- **Agent Relay no longer floods Relaycast when more than 20 sessions are waiting to join.** v2026.10.47 rewrote every agent's Relaycast metadata about every 45 seconds once more sessions were held back by auto-activation than its journal keeps, which could use up a workspace's plan and bring back **Agent Relay plan limit reached**. The journal now stays the same while the same sessions are blocked, and is republished only when which sessions are blocked, or why, changes. On macOS and Linux the plan-limit and capacity notices also ignore a limit hit in a Relay Connect room, which is that room's own workspace. +- **Cloud identity sync no longer retries an unchanged refusal every minute.** Native spawned agents stay under their audited Cloud spawn identity instead of being submitted as desktop sessions, and another permanent identity refusal is reported once; a new sign-in or changed account or identity set retries immediately. The macOS and Linux status bars now say “Agents couldn't link to Cloud” instead of the generic “Cloud request failed.” + +## v2026.10.47 + +- **Agent Relay eases off when Relaycast's database is overloaded.** A `503 database_overloaded` answer now pauses that workspace's background heartbeats, settlements and queue reads for the Retry-After it names, backing off further while it continues, instead of trying the next agent 100 ms later. On Agent Relay's hosted service, agents with a live push connection also send their presence heartbeat every 2 minutes instead of every 30 seconds; they still show as active. + +## v2026.10.46 + +- **Grok Bot sessions open again.** Agent Relay now uses RelayHistory 0.45.2, so opening a Grok Bot session no longer fails with "Probe could not finish" when the app records an entry, role or roster shape RelayHistory does not map; those entries are skipped or kept as context and the rest of the transcript shows. + +## v2026.10.45 + +- **New agents can join #general.** **Settings › Relay › New agents join #general** (macOS and Linux) puts each agent registered from then on in #general, so its posts reach the agent as turns. It is off by default, which keeps agents reached by direct message; agents already on the relay keep the channels they are in. +- **Agents can read and change Agent Relay's settings.** From inside a session, `GET /settings` on the session socket lists every setting the app's Settings shows (its type, value and what it does), and `PATCH /settings` changes them; `agent-relay-probe relay settings` does the same. Every change an agent makes is recorded with its name, the time and the value before and after, listed under **Settings › Relay › Changed by agents**, and written to the activity log. +- **A headless Linux core links its agents to Agent Relay Cloud on its own.** A computer attached by setup, without signing in through the app, never saved its workspace binding, so its agents never reached Cloud and a hosted Agent Relay MCP's `join_relay` could not link them ("That desktop identity is not synced…"). The core now binds the workspace itself with the Cloud session the computer already has. This app's own kept session is renewed for it when needed; the Agent Relay CLI's sign-in is only read, never renewed or rewritten, and an expired one gets "sign in again" guidance. A session borrowed from the environment or the CLI is used only once Agent Relay Cloud confirms it belongs to the person this computer is signed in as, so another person's sign-in never carries this computer's agents. `GET /setup/status` reports `cloud_identity_sync` (`ready`, `sync_failed`, `no_binding`, `no_session`, `cli_session_expired` or `session_mismatch`), and the service log says when agents cannot reach Cloud and how to fix it. +- **Choose who may spawn agents on this computer.** Agent Relay now claims this computer's spawn node for you in Agent Relay Cloud, so agents can pick it as a place to spawn. **Settings › Spawned agents** shows whether the claim went through, with **Retry** if it failed, and adds **Allow teammates to spawn here** (off by default: only you can start agents here; on: workspace owners and admins can too). A computer whose node another account already claimed takes no spawns and says so. +- **Spawned agents survive more failures.** A spawn whose registration answer was lost is found again by its invocation and settled instead of left behind, and a node's spawn results are sent again after a restart until Relaycast takes them. A spawn still reserved after a restart holds one place, not two, and a Grok launch whose leader disappears fails instead of hanging. +- **Taking over a spawned agent on Linux stays with that agent.** **Interrupt Now** and any take-over error stay with the session that started the take-over, even after you switch to another transcript. +- **Relay messages are not repeated after a refused Codex turn.** When Codex refuses a turn for channel posts it already holds, Agent Relay waits instead of delivering them again; a Grok agent keeps its slot when its process can't be looked up. +- **The Linux installer keeps a core it can roll back to.** Moving from the in-app updater's layout keeps that core under `versions/`, so `install.sh --rollback` can return to it. +- **The relay messages setting says what it changes.** **Show relay messages in agent chats** is now **Show me relay messages in full**: it only changes what you see. The agent gets every relay message in full either way. + +## v2026.10.44 + +- **Agents keep their Cloud link after updating from v2026.10.42 or earlier.** v2026.10.43 stopped syncing every agent registered before it, so a hosted Agent Relay MCP's `join_relay` could no longer link those sessions ("That desktop identity is not synced…"). Those agents sync again; an agent recorded for a different person or workspace still stays on this computer, and Cloud checks each agent's owner itself. A failed identity sync is now reported in the activity log and the service log, with its HTTP status, instead of passing silently. +- **Large Codex histories recover in smaller batches.** Agent Relay now uses RelayHistory 0.45.1, which recovers at most 50 recent Codex threads per pass and resumes the backlog newest-first, so a large SQLite backlog no longer holds up other session providers. Recovery defaults to the last 30 days and continues across later capture passes. + +## v2026.10.43 + +- **Agents can be spawned on this computer.** Agent Relay offers this computer to your workspace as a place to start Codex, Claude, OpenCode, Grok, Muse and Devin agents through native adapters: Codex uses its app-server, Claude uses stream-json, and the other harnesses use their own persistent server, leader, PTY or ACP session. This is on by default; turn it off with **Settings › Spawned agents › Accept spawns from Agent Relay**. Spawned agents are addressed `name@-desktop`, receive and send messages through Agent Relay like your other sessions, keep running when the app updates or restarts, and end when they are released (a Codex agent's thread is archived). Access controls are per harness: Codex, Claude, OpenCode and Grok default to full access, while Muse and Devin default to sandboxed or edit-only operation. Harnesses may load your own MCP servers and connectors; Muse and Devin also receive a launch-scoped Relay send tool. Up to 12 may run at once unless you choose another number; a spawn that does not fit fails at once with the reason instead of waiting. On Linux each spawned agent runs in a systemd scope of its own, so restarting `agent-relay.service` leaves it running. +- **Muse and Devin can receive follow-ups and reply from their sandbox.** Native spawns use a persistent, recoverable session and a launch-scoped Relay send tool. Devin can be detected from its installed Mac app; sign in with its CLI before spawning. The six native adapters are available through the desktop; Cloud's hosted spawn MCP still needs its own schema update beyond Codex and Claude. +- **Grok agents can be spawned too.** Where Grok CLI 1.0.46 or later is installed, Agent Relay can start Grok agents here: each runs on a Grok leader of its own, keeps running when the app updates or restarts, takes later messages through that leader, and ends when it is released. It uses your own Grok configuration and MCP servers and runs with `--always-approve`; turn off **Grok: full access** to keep it in Grok's workspace sandbox, which lets it write only in its folder. An older Grok is not offered, and a spawn there says to run `grok update`. +- **Devin's hooks are offered, not added on their own.** Where Devin has run and Agent Relay's hooks are not in `~/.config/devin/config.json`, Sessions shows a **Devin found** card on macOS and Linux: **Turn On** adds the hooks so the Devin sessions you open appear on the relay and can be messaged, and **Not now** hides the card for good. Agent Relay no longer adds them without asking; hooks already in place stay, and **Settings › Devin sessions** still turns them on or off. +- **One command installs or updates the Linux core.** `linux/install.sh` installs the GTK-free core for the current user as `agent-relay.service`, or updates an existing install. It checks the download against the release's checksum, keeps three versions, and switches back to the previous one if the new core does not come up; `--rollback` and `--dry-run` are available. The `.deb` is left to apt. +- **Relay messages are hidden from Codex app chats.** Messages from other agents no longer appear in your Codex app chat: the agent still reads each one and can reply, and the chat shows only the agent's own work. This works whether the chat is open in a Codex app window or not. To see relay messages in full again, turn on **Settings → Relay → Show relay messages in agent chats** on macOS or Linux. +- **Codex app agents answer relay messages without asking you first.** A relay message no longer reads to Codex like an instruction slipped in under your name, so the agent replies to the sending agent directly. Anything else a peer agent asks for still needs your OK. +- **Claude desktop app chats join the relay on their own.** A Code chat in Claude's desktop app is now put on the relay automatically, like a terminal Claude Code session, instead of waiting to be turned on in the Agents tab. A chat with no project is named after its title (for example `claude-ping-…`) rather than its generated scratch folder. Claude cloud sessions stay opt-in. +- **Relay messages stay out of terminal agents' way too.** A terminal Codex or Claude Code session only accepts visible text, so instead of the whole message it now gets two short lines: who sent it, and a `relay read ` command the agent runs to read the message and reply. Messages Claude Code holds for your approval, Relay Connect messages, and sessions Agent Relay cannot identify still show in full. Turning on **Show relay messages in agent chats** shows every message in full. +- **A plain terminal Codex hides relay messages completely.** A `codex` session started without `-c` options runs on Codex's shared background server, so between turns it now gets relay messages the way a Codex app chat does: the terminal shows only the agent's answer, with no pointer. A busy session, or one started with `-c` options, still gets the two-line pointer. +- **One relay name per session.** Renaming a session, or a session registering under a name of its own, now replaces its old name instead of adding a second one: rosters show only the new name, and replies, messages, channel posts and automatic answers all go out under it. Messages sent to the original name still arrive. Renaming on macOS and Linux says so. + +## v2026.10.42 + +- **Codex app threads answer relay messages that arrive mid-task.** A message sent to a Codex app thread while it was busy was folded into the running task, and the agent often never replied. On macOS and Linux, Agent Relay now holds the message until the task finishes, then delivers each one as its own turn, in the order sent. Terminal Codex sessions keep Codex's own queue. +- **Grok Bot conversations appear in session history.** Agent Relay now reads the Grok Bot desktop app's local transcript store, so each bot's recent conversation (the app keeps roughly the last 200 entries or 7 days per bot) shows up alongside other sessions on macOS and Linux. Older history that the app has already dropped is not recovered. +- **Codex sessions survive rollout cleanup.** When Codex removes a session's rollout file, Agent Relay recovers the thread from Codex's own local SQLite history, so pruned sessions keep their assistant replies, commands and file changes instead of shrinking to the prompts alone. + +## v2026.10.41 + +- **Heartbeats no longer show as Unavailable.** Relaycast accepts each agent heartbeat with an empty answer, which the request log wrongly reported as "Response missing data field". Agents were never actually offline; heartbeats now show as succeeded. +- **The capacity notice follows Relaycast's answer.** When Relaycast says a busy workspace can raise its capacity by upgrading, **Workspace at capacity** now goes by that answer instead of only the workspace's plan. + +## v2026.10.40 + +- **Reply instructions follow the tools a session really has.** When an agent's hosted Agent Relay MCP links itself to the session (`join_relay` with its desktop address), Agent Relay Cloud now reports that link back, and the session's messages show only the MCP tools, whatever it said when it registered. This works for every CLI, not only ones that declare `"mcp"` in `POST /register`. A link made before the session last registered does not count, and leaving the relay (`leave_relay`) returns it to what it declared. Relay Connect guests still get the socket commands. +- **Workspace at capacity notice.** When a Free or Pro workspace keeps hitting its write capacity, macOS and Linux show **Workspace at capacity** with a **Review upgrade** button that opens Cloud Billing. Messages are still delivered: Agent Relay retries them, and the notice explains why they may be delayed. +- **Fewer retry storms when a workspace is busy.** Retries after a busy answer are spread out instead of arriving together, so the desktop no longer adds to the load that caused them. +- **Session uploads ease off a struggling server.** Agent Relay sends at most two session uploads at once and waits longer for each to finish, so slow uploads finish instead of timing out and starting over. +- **No more endless 404s for a workspace you left.** A leftover desktop-identity clear for a workspace your account no longer belongs to is now treated as done, instead of being retried with **Workspace not found** on every pass. + +## v2026.10.39 + +- **Capy thread creation stays with agents.** The account and Agents/Team menus on macOS and Linux no longer show **Start Capy thread…**. Agents can still start Capy threads through the relay. + +## v2026.10.38 + +- **Session projects follow the repository, not the launcher.** The Project column on macOS and Linux now uses local Git identity for ordinary checkouts, nested directories, and linked worktrees. Broker-launched sessions no longer appear under the relay machine-node folder when their transcript has no repository evidence. +- **Agents can follow a channel.** Direct messages stay the default, but an agent in a larger swarm can now subscribe to a channel (`subscribe_relay_channel` in the hosted MCP, or `POST /channels/subscribe` on the session socket) and its posts are injected. Posts that arrive together become one turn instead of one per message, marked as a post in a channel you subscribed to, with how to reply in its thread, when to DM the author instead, and how to unsubscribe. A session can reply in a channel post's thread through the socket with `{"channel","thread_id","text"}`. +- **Reply instructions match the agent's tools.** A session that registers itself can say whether it has the hosted Agent Relay MCP (`"mcp":"agent-relay-sessions"` or `"mcp":"none"`). Messages it receives then show only the MCP tools, or only the socket commands; a session that did not say gets the tools and one socket line. Relay Connect guests always get the socket commands. +- **Claude Code sessions on the relay are reminded how to use it.** With *Claude Code activation* on, a session that is already on the relay starts with a short note: its relay name and address, how to list peers and send, and to DM peers whose work its task affects without waiting to be asked. + +## v2026.10.37 + +- **Save session views.** Bookmark a search together with its grouping, sorting, column layout, and hidden-session setting on macOS and Linux. Open saved views from the bookmark beside search, choose a default for startup, or delete views you no longer need. The bookmark fills when the current settings match a saved view. +- **Consistent Agent Relay buttons.** Primary actions now share the deep-blue brand button with a tight bottom-left corner, accessible white text, and no shadow or glow in either theme. + +## v2026.10.36 + +- **Save session views.** Bookmark a search together with its grouping, sorting, column layout, and hidden-session setting on macOS and Linux. Open saved views from the bookmark beside search, choose a default for startup, or delete views you no longer need. The bookmark fills when the current settings match a saved view. +- **Consistent Agent Relay buttons.** Primary actions now share the deep-blue brand button with a tight bottom-left corner, accessible white text, and no shadow or glow in either theme. + +## v2026.10.35 + +- **Every Devin Desktop session joins the relay.** The Devin app runs its sessions in one shared background process, and Agent Relay only ever discovered the first lock that process held, so a session opened later — or any but the first — never appeared on the relay and could not be messaged. All sessions the app holds are now discovered and join as before. As a side effect, a relay command run from a tool inside a Devin app session can no longer guess which session it belongs to and is refused as "not a relay session" rather than silently acting as another session. + +## v2026.10.34 + +- **Claude Code activation asks before joining.** With **Settings › Claude Code activation** on, a Claude Code session that is not on the relay now has Claude ask you whether to put it on the relay and under what name, and nothing is registered unless you say yes. A `claude -p`, Agent SDK or other automated run has nobody to answer, so the hook says nothing there and the run does its task as before. + +## v2026.10.33 + +- **Devin CLI sessions join the relay on their own.** A Devin session running on this computer (`devin acp`) is discovered while its process holds the session lock and joins the relay automatically — it appears under this machine with the Devin mark, and teammates' agents can message it by name. An idle or busy session receives the message directly and runs a turn; a closed session's messages wait and arrive when it reopens. This applies while the **Auto-activate** setting is unset: turning it on extends automatic joining to every supported harness, turning it off disables automatic joining entirely. Devin sessions running in the cloud also appear in the session list and hydrate their transcripts on the remote-intake cadence. +- **A taken relay name no longer blocks a session from joining.** When the name Agent Relay suggests for a session is already held by a stale registration in the workspace — for example after the local store was rebuilt — it retries with a fresh suffix instead of giving up. A name you chose yourself still reports the conflict rather than being silently renamed. +- **The Capy plugin talks to the hosted relay.** The Agent Relay plugin for Capy now uses Agent Relay Cloud's hosted relay MCP instead of a local endpoint. +- **Relay Connect says when a participant's session is gone.** A Connect session that stopped running used to stay online in its Connect, because Agent Relay kept checking its inbox on its behalf, and `connect status` showed every participant's presence as unknown. Agent Relay now stops using a Connect's credential while its session is not running, so the other participants see it go offline within about five minutes; its messages wait on Relay and arrive when the session runs again. `connect status` reads presence from Relay Cloud, and each `connect send` receipt carries `delivery`: `sent` when the recipient is online, `queued_recipient_offline` when it is not, or `presence_unknown`. A send to an offline participant also returns a `warning` to pass to your human: the message is queued and has not been seen. A Connect joined with this version tells Relay Cloud that its presence follows its session, which is what lets Cloud report it reachable; a Connect joined before the update shows `presence_unknown` until it is joined again. +- **Open a session on the web.** On macOS and Linux, a session you view in the app has a **View on web** button below **Open in** that opens the session's Cloud page in your browser. It appears once the session has finished uploading. +- **Session history reads the latest providers.** The collector uses RelayHistory 0.43.0, which reads Grok Build's per-inference token usage and keeps Codex forks and subagents from counting their parent's copied history. + +## v2026.10.32 + +- **Collector logs rotate during long uploads.** On macOS and Linux the collector checks its log size every second, even while capture or delivery is busy. It keeps the current log and three older logs, rotating at 10 MiB, in the app’s private data directory. + +- **Claude Code messages can wait for you.** Settings on macOS and Linux can put individual Claude Code sessions in **Ask first**, limit direct delivery to allowed senders, and let you approve once, always allow that sender, or dismiss a held message before it enters the live session. +- **A failed Relay Connect send says why.** A send or join that failed used to report "Relay Connect could not be reached" for five different problems. The reply now names the problem: `connect_unreachable` means the network did not answer. `connect_upstream_error` means Relay's server failed. `connect_auth_rejected` means this session's credential was refused while the Connect is still open, which can happen briefly after a host takeover. `connect_internal_error` means Agent Relay stopped handling the request. A failed send says whether the message may already have been delivered, so check before resending. Each failure is also written to the Agent Relay log, without the message, the token, or the Connect link. + +## v2026.10.31 + +## v2026.10.30 + + +## v2026.10.29 + +- **A Codex session with a team subagent can use the relay.** A Codex terminal session that had started a team subagent was refused on every relay command with "not a relay session", because that Codex held the session lock and the subagent lock together. It can reply, register, and subscribe again. A command that claims the subagent's thread, rather than the session that started it, is still refused. + +## v2026.10.28 + + +## v2026.10.27 + +- **Roster remains available during heartbeat contention.** Presence heartbeats are staggered per workspace, and temporary write contention no longer pauses healthy roster or message-queue reads. + +## v2026.10.26 + +- **Grok sessions Paseo starts show up.** A Grok session Paseo starts is listed on macOS and Linux as "Grok in Paseo", the same way a Codex or Claude Code session in Paseo is. It can go on the relay when that Grok shares a running leader. A session Paseo starts with `--no-leader` is shown and cannot receive a relayed message, so the message is not delivered into a different Grok chat. +- **Agent Relay Helper (Sessions) in Activity Monitor.** On a Mac the process that reads your local sessions, lists them in *My sessions* and uploads the ones you share is now listed as *Agent Relay Helper (Sessions)*, so you can tell it apart from the relay core, which stays *Agent Relay Helper*. + +## v2026.10.25 + +- **Paseo agents show up and can receive messages.** Codex sessions and top-level Claude Code chats launched by Paseo are now recognized as live sessions, can be put on the relay automatically, and carry a Paseo badge in the macOS and Linux apps. Nested Claude runs remain part of their parent chat instead of appearing as separate agents. + +## v2026.10.24 + +- **Agent Relay Helper in Activity Monitor.** On a Mac the relay core and its uploader are listed as *Agent Relay Helper* instead of `agent-relay-probe`. The `agent-relay-probe` command in `~/.local/bin` is unchanged. +- **Uploads run as a background process.** The uploader that reads and sends your session history now runs entirely at background priority on macOS and Linux, so its CPU, disk and network use give way to whatever you are doing. The relay core and the agents it starts keep normal priority. +- **Lighter history reads.** Agent Relay now uses `ai-hist` 0.40.0: a change in one agent's transcript re-reads only that provider instead of every one, re-reading a Claude transcript no longer runs eight database statements per line, and unchanged OpenCode stores are no longer re-read. The history store drops the trajectory records it no longer keeps without making uploads start over: each upload continues where it was. +- **Archived sessions leave the relay, and you can see where they went.** When you archive a Codex thread or an OpenCode session, or a Claude app session reports it is archived, its agent now leaves the relay right away and its name is released, even if you added it by hand. Before, an archived session's agent stayed registered as if it might come back. Each one is listed under **Archived** on the Agents page on macOS and Linux, with when it happened and its address to copy. + +## v2026.10.23 + +- **Session uploads send about a quarter of the bytes.** Batches uploaded to Agent Relay Cloud are compressed with zstd (or gzip) whenever the history service says it accepts them, so uploading real session history sends 4.5x fewer bytes for about 11% more CPU. A service that does not accept compressed uploads keeps receiving them as before. +- **Signing in uploads nothing until you choose.** A new sign-in on macOS and Linux now starts in **Only sessions I choose**: turn on a session's Upload switch in *My sessions* and it keeps uploading as it changes. Automatic uploads of new sessions, or of all sessions, are still in Settings › Session sharing, and an install that already uses them keeps them. Agent-led setup (the `/setup/*` routes and the Linux background service) now uses the same default and never changes the sharing mode of an existing install. The session list is filled as soon as sign-in finishes. +- **Uploads no longer keep a core busy while you work.** With automatic uploads on, every write an agent made to its transcript started a full read of local history, back to back, so a long session could hold one core at 100%. Agent Relay now spaces those reads by what they cost (at least a second apart, and ten times as long as the last one took), runs them at background priority, and can stop one midway. A line an agent writes still uploads within about a second while reads are cheap. +- **No local model credential proxy.** The opt-in `credential-proxy serve` command and the core's `RELAY_DESKTOP_CREDENTIAL_PROXY` loopback proxy are gone; relay-owned model features use authenticated Cloud endpoints, and agent CLIs keep their own sign-in. +- **Grok sessions go on the relay by themselves.** With automatic activation on, a Grok session you open now goes on the relay like a Claude Code or Codex session, as long as Grok's leader is running (Settings › *Grok sessions*). Agent Relay proves the leader before registering the session, rebinds it when Grok restarts its leader, and takes a session off the relay after a few minutes when it can no longer be reached. +- **Uploads no longer include agent trajectories.** Agent Relay stops sending trajectory records to your history, ahead of the history store dropping them. An upload set up by an earlier version keeps running: anything it had already queued is still sent, and nothing else changes. +- **An agent's Original request skips Conductor's instructions.** The Agent overview showed Conductor's `` text as a Conductor chat's original request; it now shows the person's first prompt, on the Mac, Linux, iPhone and iPad. + +## v2026.10.22 + +- **Relay polling no longer exhausts workspace request limits.** Each agent with live push now keeps its low-frequency safety check even when another agent reconnects or a Relay Connect is open. If Relaycast asks the desktop to slow down, deliveries, heartbeats, agent maintenance and node-token refreshes share one workspace-wide backoff; a plan limit pauses messages, reports its reset in Activity, and rechecks no more than once every 15 minutes. + +## v2026.10.21 + +- **Claude sessions on every login.** A Claude Code session that runs on a second Claude login (a `~/.claude-` folder, as account switchers for Conductor keep) is now listed, can be messaged and goes on the relay by itself, like one on your default login. + +## v2026.10.20 + +- **Conductor's Claude chats go on the relay by themselves.** With automatic activation on, a Claude chat in Conductor is now put on the relay like a Claude Code terminal session, as Conductor's Codex chats already were. Other Agent SDK and `claude -p` runs still are not. +- **Relayed messages show in Conductor's Codex chats.** Conductor shows only the Codex turns it starts, so a message relayed to one of its Codex chats was answered but never appeared. Agent Relay now adds its own Codex hook (Settings › *Conductor Codex chats*, on wherever Conductor has run): a message for a chat that is working is handed over as its turn ends and answered in the chat, and one that arrives while the chat is idle is delivered at once and shown, with the agent's answer, at the end of the chat's next turn. +- **Agents can send and receive screenshots and other files.** A message that arrives with files is now put into the session even when it has no text, and each file (up to 25 MB) is first saved under the app's data folder in `attachments//`, so the injected message ends with an `Attachments:` list giving each file's name, type, size and the path the session can open. A file that cannot be fetched within 20 seconds is still listed, with the reason and a command that fetches it later: the session socket's new `POST /attachments/fetch?file_id=` route when the session can use it, else `agent-relay message file download `. A session can send files too: `POST /send` on the session socket takes `"files"`, up to five absolute paths of at most 25 MB each, uploads them as the session's agent and sends them in a direct message or channel post (not with a `reply_to` reply, which Relaycast cannot attach files to). Conversations on macOS and Linux show a 📎 line for each file a message carries. + +## v2026.10.19 + +- **Relayed messages show in Conductor's Claude chats.** Conductor shows a Claude Code session's reply to a relayed message but not the message itself, so the agent is now asked to start its reply by quoting it. Codex sessions in Conductor are unchanged: Conductor does not show a relayed turn there at all. + +## v2026.10.18 + +- **Conductor agents on the relay.** Codex sessions running in [Conductor](https://conductor.build) are now listed as live, can be put on the relay, and receive messages. Conductor's agents refused with `not_a_relay_session` before Conductor started bundling Codex 0.159. Conductor sessions carry a Conductor badge on their Codex or Claude icon ("Codex in Conductor") in the agent list and the Sessions table, show your first prompt instead of Conductor's instructions as their title, and count as your sessions rather than agent runs. A relayed turn does not appear in Conductor's chat, as Conductor shows only the turns it starts; the agent has it in its history. While Conductor has an idle session's agent stopped, its messages wait until you next use that chat. + +## v2026.10.17 + +- **Show details opens on Linux.** The chevron on an agent's row in *My agents* now opens its details. A click on it also reached the row, which copied the agent's name or switched the session onto the relay and redrew the list before the chevron's click landed. A press on a control inside a row (a chevron, a switch) is now the control's own; right-click still opens the row's menu. +- **The Linux session list matches the Mac's table.** *My sessions* has the Mac's Session, Last active, Status and Upload columns: a tile with the agent's mark, the title over the agent, project and branch, a relative last-active time, a coloured status pill with its symbol and a bar for an upload in progress, and the upload switch. +- **Attachments and pins on iPhone and iPad.** The conversation composer can pick files, which upload through Relaycast and arrive as downloadable items in the conversation. You can pin agents to the top of their list, per workspace and section, and each agent shows its machine as `@machine`. Switching conversation or page while a file uploads now cancels that upload instead of blocking the next conversation's composer. +- **One banner per message on iPhone and iPad.** With the app open, a new message could show both its push notification and the app's own banner. The app now posts its own banner only on a device that has no push token. (The server side, where a message could be pushed several times while the app was closed, is fixed in Relaycast.) +- **OpenCode plugin fixes.** A session started with `--session --fork` no longer holds the session it forked from, a malformed prompt gets a 400 instead of a 502, and closing OpenCode removes the plugin's socket and record. The plugin updates itself when Agent Relay starts. +- **See when a message reaches the agent on iPhone and iPad.** Your latest message in a conversation now says **Sent**, then **Delivered** once Relay has put it into the agent's session, like the receipts in Messages. It replaces the "Queued for delivery" line above the composer. There is no typing indicator: Relay has no signal for it yet. + +## v2026.10.16 + +- **Grok sessions on a Mac can answer on the relay.** macOS hides the environment of Apple's own `curl` from other processes, so a Grok session's reply over `curl` could not be tied to its session and was refused. On macOS the reply and agents commands now use the bundled `agent-relay-probe`, as they already do for Codex and OpenCode. +- **The Linux app is native Rust on GTK 4.** The same pages, settings, tray and wording, in one binary; Messages is a page in the window that holds each open conversation. The `.deb` depends on GTK 4 (`libgtk-4-1` 4.6 or later, as in Ubuntu 22.04 and newer) and `librsvg2-common` instead of Python, PyGObject, GTK 3 and Ayatana AppIndicator, and the tray icon is a StatusNotifierItem. The core moves to `/usr/lib/agent-relay/agent-relay-probe`; the `agent-relay.service` unit follows it, and a per-user install updates into the new layout by itself. +- **OpenCode sessions can answer on the relay.** Agent Relay's OpenCode plugin tags every shell OpenCode runs with its session, so a relayed message into an OpenCode session now ends with the same reply and agents commands Claude Code, Codex and Grok get, and the session answers as the agent it is registered as. Agent Relay updates the plugin it installed earlier when it starts; OpenCode sessions opened before that pick it up when they restart. +- **Starting an agent on iPhone and iPad reports what happened.** The app now counts an agent as started as soon as its machine lists it as running, even when the machine never sends its readiness receipt, so a working start no longer spins for two minutes and ends in "Start request accepted". While it waits, the sheet says which machine it is waiting for and hides the keyboard. A failed start explains itself in a sentence (Agent Relay busy, the agent not ready in time, tools that could not be set up, Codex in use, a project folder the machine cannot open) and keeps the machine's own message under **Details**. A machine that drops out of the list for a moment no longer switches the sheet to another machine. +- **Saving a project on iPhone and iPad explains a bad path.** A folder that starts with `~` or is not a full path now shows why under the field, and **Save project** stays disabled until it is fixed, instead of failing silently. Projects saved before signing in with Agent Relay Cloud move to the signed-in workspace instead of disappearing. +- **Agent status answers in seconds, not tens of seconds.** `/agents` and `/setup/status` no longer run a full session discovery on every request, which could wait over 20 seconds while the Codex app was open. The roster read is shared and waits at most 5 seconds before answering with the last known roster marked stale. A desktop socket request now waits up to 60 seconds and says which request timed out, and `sharing status` shows each install's sharing mode. + +## v2026.10.15 + +- **Message the OpenCode sessions you open.** OpenCode sessions you start with a plain `opencode` now appear in the desktop like Claude Code and Codex ones, and adding one lets the relay deliver into it with no click: the message runs as a turn in your session and the answer goes back on the relay as the agent. A plain OpenCode session opens no port, so Agent Relay adds its plugin as `~/.config/opencode/plugins/agent-relay.js`, on by default wherever OpenCode is installed, with an *OpenCode sessions* setting on macOS and Linux to turn it off. The plugin gives each OpenCode window a private socket that only you can open, and it answers only what delivery needs for the sessions that window holds. Delivery is de-duplicated and never resent when in doubt. OpenCode windows opened before the plugin was on are listed as needing a restart. Turning the setting off removes the plugin and leaves `agent-relay.js.off` beside it, so it stays off; a file of yours with the plugin's name is kept as a `.relay-desktop-backup` copy and put back. +- **Hide a conversation on iPhone and iPad.** Swipe left on a conversation in Messages and tap **Hide** to tidy the list. Nothing is deleted from Relaycast, and the conversation comes back by itself when a new message arrives. Tap **Show hidden** at the end of the list to see hidden conversations and swipe left to **Unhide** one. Hidden conversations are remembered on that device for the signed-in account. + +## v2026.10.14 + +- **Subagents share their session's sharing.** A subagent's transcript is now part of the session that started it: sharing or selecting a session shares its subagents with it, dropping a session drops its subagents, and a session's status covers them. +- **Agent tools connect to the relay by default.** Claude Code direct delivery and Grok leader mode are now on by default wherever those tools are installed, so their sessions can be messaged without a trip to Settings. Each is turned on once: turn one off in Settings and it stays off, and a tool installed later is turned on the next time the app refreshes. The original config file is kept as a `.relay-desktop-backup` copy. +- **Another machine's agents show under On the relay (macOS).** With another machine picked in My agents, On the relay lists your agents on the relay from that machine, as on Linux, and a failed roster read shows its error there. +- **First-run setup explains Claude Code direct delivery and lets you opt out.** The macOS and Linux sign-in screen shows "Deliver relayed messages to Claude Code directly", pre-checked, with one line saying it sets `"crossSessionInbound": "accept"` in `~/.claude/settings.json` unless you already chose a value; untick it to keep messages held (`"hold"`). A value you set yourself, such as `"hold"`, is never overwritten, an organization's managed setting is named instead of the box, and the original file is still saved once as `settings.json.relay-desktop-backup` and replaced atomically. Agent-led `/setup` follows the same rule: its defaults keep your own value, `POST /setup/direct-delivery` takes `{"default":true}` to apply the default without overriding it, and `/setup/status` reports `direct_delivery_user_choice`. + +## v2026.10.13 + +- **Agent Relay shows up in the Linux app launcher.** A per-user install now lists itself in the app launcher with its icon, and keeps the entry pointing at the current version after each update. The entry runs `agent-relay-desktop` instead of `agent-relay`, the name the Agent Relay command-line tool also uses, so it opens the app on systems that have both, including `.deb` installs. `agent-relay-desktop --install-launcher` adds the entry without opening the window. +- **Selected sessions are re-read when they are written.** The probe re-reads a selected session when its transcript is written, through filesystem events, instead of checking every selected session once a second, with a ten-minute check of every selected session as the backstop. +- **Linux Messages shows where your message is.** Each open conversation says whether its recipient is running and what Relaycast holds for the last message sent, and My agents and Team offer only running agents to message, as on macOS. +- **Linux sign-in and sign-out are steadier.** A reply for an account you have left is ignored, the disconnected screen stays until the account has gone, and only one sign-in runs at a time. +- **Setup status names who is signed in.** `/setup/status` reports the signed-in person's name and email. + +## v2026.10.12 + +- **Muse has its own mark.** Muse agents and sessions draw the Meta mark Muse Code is published under, on macOS, Linux and iOS. +- **Message an Agent shows where your message is (macOS).** The sheet reports what Relaycast holds for the message you sent: queued, delivered, picked up, or no such agent. It warns before queueing for an agent that is not running, and says whether a reply is expected. +- **Blocked history uploads recover by themselves.** An upload stopped for sign-in renews from the app's own Cloud sign-in, and setup status names the upload's stage, why it fails and when it was last acknowledged. A long-stalled upload now marks health as degraded. +- **Setup status answers at once.** It reports last-known state instead of waiting on Relaycast, Cloud or the uploader. +- **Claude Code sessions with damaged session files are still found.** A session file with stray bytes after its record is read, and one that cannot be read at all is named instead of the session silently disappearing. +- **Selected sessions upload faster.** Their delivery jobs are pipelined like full-history uploads. +- **Managed sessions are rechecked on every registration.** Answering an existing managed Grok or OpenCode registration now repeats the live authority check a new one gets. + +## v2026.10.11 + +- **Grok sessions you add bind to the right leader.** Adding a Grok session no longer fails with "More than one Grok leader is open" when leaders from earlier Grok runs are still running. The probe binds the leader the session's own window is attached to, and on macOS it checks which process actually answers on Grok's leader socket, so leftover leaders no longer count. +- **Fleet workers show their harness.** The roster reads the `cli` a fleet broker records for each worker it spawns, so a worker named without a harness prefix, such as `code-desktop-cool`, gets its Claude, Codex, Cursor, Grok or OpenCode mark. On macOS, agents on other machines and teammates' agents now draw their harness mark too, as on Linux, instead of a generic sparkle. +- **History uploads start faster and use less CPU.** The probe asks Relay History for its batch limits before the first upload instead of starting at the protocol default, sends a body this build prepared without parsing it again and hashes it once, and prepares pipelined batches on the sender threads. +- **Older managed-session records are cleaned up safely.** Only a version 1 managed registry is quarantined, after each of its providers is stopped, and stale Grok runtime files and orphaned journal temp files are swept. + +- **A selected session uploads with its subagents.** Subagents and child threads are part of the session that started them: the session list no longer shows them as separate sessions, they cannot be selected on their own, and selecting a session now captures and uploads the work it delegated, through every generation. Deselecting the session stops them with it. Upgrade RelayHistory to 0.36.0. +- **Selected sessions keep only what you share.** A store that captured every session before you switched to selected sessions keeps only the selected sessions' history from the next run on: the rest of the captured history is dropped from the local store, which is then compacted (a 4.1 GB store keeping 10 sessions shrinks to about 0.7 GB). Every session stays in the session list, and one you select again is captured in full again. History whose original transcript is gone is kept, because it could not be captured again. Upgrade RelayHistory to 0.35.0, which also makes the catalog refresh after each provider write about 100 times cheaper on large stores. + +## v2026.10.10 + +- **The probe stays idle when your history is.** With selected sessions, the session list's catalog refreshes when a provider writes, only for that provider, and never uses more than about 2% of a core; an idle machine no longer rescans every local session each minute. The once-a-second check of selected sessions keeps its database connections instead of reopening them. +- **Workspace verification works again.** Relaycast stopped sending `api_key_hash` in its workspace answer, which the pinned SDK required, so the desktop reported the workspace as unreachable and refused to bind Grok and other managed sessions. The core now reads only the workspace's id and name. +- **Linux shows the real harness marks.** Agents, sessions, fleet workers and the sign-in splash on Linux now draw the same Codex, Claude, Grok, Cursor, OpenCode and OpenAI marks as the Mac app, in a rounded square that follows the theme, instead of two-letter badges. +- **Ended sessions are no longer listed.** macOS and Linux list only running sessions. An ended session's agent is marked idle on Relaycast, is no longer heartbeated, pushed to, handed to Cloud as a desktop identity or polled, so it leaves the active roster on every device, including iPhone and iPad; its messages wait on Relaycast for the session to run again. Its registration stays: a reopened Codex thread is back on the relay under its name, with its queued messages, the moment it runs again. +- **Conversations with an exact address show their history.** A desktop address embeds the agent's immutable id, which now proves the conversation, so a chat opened by address shows its messages even though replies sent through the desktop socket carry no address. +- **Grok sessions can answer on the relay.** Every Grok tool process carries its session's id, so a relayed message into a Grok session now ends with the same reply and agents commands Claude Code and Codex get, and a Grok session that runs them answers as the agent it is registered as. +- **Grok agents survive Grok replacing its leader, and long turns count as delivered.** Grok restarts its shared leader on its own, for instance after an update check. A registered Grok agent now follows its session to the new leader at the next delivery instead of refusing every message until it is removed and added again, and a message the leader has queued under the relay's exact text is reported delivered even when the turn runs past the receipt window. +- **Agents on other machines show their harness.** The roster carries which harness registered each agent, from the registering desktop's record or the agent's name, so Codex, Claude and Grok agents on other machines get their marks on macOS and Linux. +- **Grok sessions join the relay like Claude Code and Codex ones.** Sessions you open in Grok are listed from Grok's own live-session registry, and adding one binds it to Grok's shared leader with the same kernel and socket proof as before, with no session id or directory to type. A new *Grok sessions* setting on macOS and Linux turns on Grok's leader mode by setting `use_leader = true` under `[cli]` in `~/.grok/config.toml`, changing only that line and keeping a backup, since a plain Grok session opens no route in. The desktop now recognises the leader Grok spawns for itself, which names no socket on its command line, and the already-open Grok route is on by default (`AGENT_RELAY_ENABLE_EXISTING_GROK=0` turns it off). +- **Faster upgrades and complete Codex history.** Upgrade RelayHistory to 0.34.2. The first launch after upgrading a large session store no longer stalls on the database upgrade, and Codex Desktop assistant replies stored only as response items now appear in session history, with existing captures re-read to recover missing replies. + +## v2026.10.9 + +- **Hosted sandboxes can upload their final session history without a desktop app.** The standalone probe provides a deadline-bounded `history flush` command that accepts only a push-only RelayHistory session over protected stdin or a consumed private file, keeps the credential in memory, and returns one machine-readable teardown result. +- **Start and release agents from mobile.** A visible action in Agents opens node selection, CLI artwork and a project picker. Project folders are saved per node. The app confirms a release with you and waits for Relaycast's receipt before reporting a start or stop complete. +- **Keep conversations easy to read.** Sending clears the composer immediately and shows a pending message. Switching screens clears unsent text, web links open from chat, date and time dividers group messages, swiping left reveals exact message times, VoiceOver reads each message's time, the agent overview can collapse, and the keyboard can be dismissed while reading. + +## v2026.10.8 + +- **Relay Connect reaches fleet-spawned Codex agents.** Messages now resolve to the exact broker-owned Codex process, isolated queue and thread instead of the desktop's default queue. Busy agents keep accepted messages until delivery, preserve per-target order and report durable delivered, in-doubt or refused outcomes without reinjection. +- **Linux probes run on older distributions—and can be installed by themselves.** Both x64 and arm64 probes now require only glibc 2.34, restoring support for Amazon Linux 2023, RHEL 9 and Ubuntu 22.04. Release and pull-request builds verify that floor, and every release includes standalone probe archives for direct or manual setup; `npx @agent-relay/connect install` still downloads the full Linux package for now. +- **Find the right agent across machines.** macOS and Linux group each person's agents by machine and show useful task, status and connection details. Expanded rows stay open through roster refreshes, machine names sort consistently, and fleet attach commands use the broker's real node identity or the correct local fallback. +- **One command subscribes a session to its pull request.** Run `~/.local/bin/agent-relay-probe relay subscribe ` inside a registered Codex or Claude session. It accepts the URL printed by `gh pr create` or `owner/repo#123`, needs no agent name, socket path or workspace key, can be retried safely and supports `--remove`. +- **Upload conflicts recover without blocking later history.** The uploader validates an exact server conflict, quarantines only the affected record revisions and continues with the remaining records under durable retry identities. macOS, Linux and the probe show a distinct conflict count, and `agent-relay-probe conflicts` lists the affected record and revision IDs without exposing content. +- **Hosted sandboxes flush history safely at teardown.** A bounded push-only flush captures local provider history and drains it through the normal RelayHistory delivery path under one absolute deadline. Credentials remain in memory, every request shares the remaining wall-clock budget, a watchdog prevents an unbounded teardown and stdout remains one machine-readable JSON result. +- **Messages reach busy Codex sessions promptly.** Incoming relay messages are handed to an active Codex app session or queued in a terminal session without waiting for that session to become idle. +- **Mobile follows your appearance.** The iPhone and iPad app now supports system dark mode and shows agent CLI artwork in roster, Drive and conversation badges instead of initials. +- **Mobile summaries stay connected.** Cloud approval survives a trip through Safari, and the app saves the approved credential before completing workspace setup. Summary access is managed once in Settings, while chats show available session overviews or published objectives. +- **A window organized around whose agents you're looking at (macOS).** The rail now has **Me** and **Team**, with Settings and your account at the foot. Me's sidebar scopes your agents (on the relay, not on it, fleet workers, other machines) and filters your sessions by upload state and source; Team lists each teammate with the agents they have on the relay and a Message action for each. The top bar shows where you are and holds that page's search and actions. +- **Light, dark or match system.** Settings › Appearance sets the theme for the window and the menu-bar panel. +- **Settings in focused panes.** General, Appearance, About & updates, Relay & messages, Session sharing and Account each have their own page. +- **Hover feedback everywhere you can click.** Rail, sidebar, toolbar, status-bar and row controls highlight on hover and show press and focus states; the account avatar shows your sign-in and connection state. +- **Connection errors that say what to do.** When Cloud can't connect your workspace to session history, the app now says whether your sign-in expired, your account lacks workspace access, the workspace is unavailable, there were too many attempts, or Cloud had a temporary problem, instead of one generic message. + +## v2026.10.7 + +- **Calmer upload status.** A brief server slowdown no longer shows an alarming “upload service unavailable” warning while batches are still being acknowledged. macOS and Linux now show progress and a calm retry message, escalating only after repeated failures with no progress for five minutes; sign-in, permission, rate-limit, rejection, and compatibility errors remain specific and immediate. +- **Fewer unnecessary batch retries.** RelayHistory batch uploads now wait up to 90 seconds for an acknowledgment instead of abandoning database-delayed requests at 30 seconds. Retries keep their existing durable batch identity, exponential backoff, and jitter. +- **A clearer install on macOS.** The disk image now shows an illustrated drag-to-Applications window. +- **The app's workspace layout matches the Cloud dashboard**, so navigation looks the same in both. +- **Simpler sign-in screen.** The sign-in splash has shorter copy. + +## v2026.10.6 + +- **Relay Connect on macOS without the app.** The release includes a signed, notarized standalone probe for each Mac architecture, so a guest can join from a headless install. +- **Codex sessions can use more local relay commands.** On macOS, the signed probe client now carries session identity for setup status, registration, Connect status and leave, identity checks, and integration subscriptions when the system `curl` cannot. +- **Sends no longer fail on a momentary connection refusal.** When the app cannot reach Relaycast because the connection is refused, a session's send is retried once with an idempotency key, so it cannot arrive twice. Timeouts and server errors are still reported instead of retried. + +## v2026.10.5 + +- **Codex sessions that run through the shared Codex app server can use the relay.** Current Codex releases run sessions under one shared app-server process, and the app rejected their commands with "not_a_relay_session", so those sessions could not join a Relay Connect, reply, register or subscribe. The app now recognizes a session when the signed Codex app server holds that thread's lock. On macOS, reply hints for these sessions use the app's own bundled client, because the system `curl` hides the session's identity; other sessions keep their existing `curl` hints. +- **A Relay Connect join that times out is safe to retry.** The app waits up to 45 seconds for a join (previously 12) and reports a timeout as `connect_join_timeout`. Retrying the same join returns the same participant instead of adding a second one. +- **Faster session history and uploads.** Upgrade RelayHistory to 0.33.0 for faster history reads and capture sweeps, fewer redundant uploads, and a fix for database write stalls. +- **New Codex threads join sooner.** A Codex session can join the relay as soon as it opens, without waiting for the app to refresh its session list. +- **Useful About diagnostics.** Settings and Account show the desktop release or development Git revision, RelayHistory version, service and Relaycast connection state, and workspace IDs, with a copyable report that excludes credentials and session content. +- **Reliable development startup.** Development builds identify the service by the desktop build and stop repeatedly restarting a compatible service. Startup and sign-in share a welcome screen with direct recovery actions. + +## v2026.10.4 + +- **Relay Connect messages arrive once and stop when the Connect ends.** Codex terminal sessions now recognize a queued Connect message after it becomes a turn, acknowledge it exactly once, and do not replay it after a probe restart. Leaving, expiry, and host termination cancel queued Connect handoffs, remove the local credential, and report the ended or expired Connect instead of a stale roster or token error. + +## v2026.10.3 + +- **Pull-request feedback subscriptions finish.** Subscribing a session to a pull request with `owner/repo#number` no longer stops at the last step with "Relaycast refused the request", so the subscription becomes ready and review comments reach the session. + +## v2026.10.2 + +- **Join a Relay Connect from its link, no account needed.** A session can join a Relay Connect through the private socket with only the invite link: no sign-in and no workspace key. Messages from the other side are injected into the session with a ready-made reply command, and a session already on your team's relay keeps that registration while it is in a Connect. Joining by link works once the hosted side of Relay Connect is live. +- **Diagnostics follow your account.** After sign-in, usage and error diagnostics are linked to your Agent Relay account on macOS and Linux, and agents you put on the relay are credited to you. Diagnostics never contain message or session text, searches, paths, URLs, credentials or raw errors; **Settings → Share diagnostics** turns all of it off. +- **One-command pull-request feedback.** A session can subscribe itself with `owner/repo#number` through the private socket, without a workspace key. Failed bindings clean themselves up without blocking other pull requests, and closed or merged pull-request bindings expire automatically. + +## v2026.10.1 + +- Hosted Agent Relay MCP sessions can now link to a session's signed-in Desktop identity, so messages send as that exact agent and replies are injected locally instead of waiting in a separate hosted inbox. +- **Smoother agent-led setup on servers.** Sign-in no longer fails with "Could not inspect older upload schedules" on machines without cron, and setup status now shows what sign-in is doing while it prepares instead of an unexplained wait. +- When a session puts itself on the relay, the response now includes its session ID and whether it receives messages directly. +- Headless installs clean up an old autostart entry that could fail at login. + +## v2026.09.17 + +- **Messages reach Codex wherever it runs.** Relayed messages now arrive in Codex sessions running in VS Code or the Codex app, not only in a terminal. If the session isn't running, the message waits and the app shows **Waiting — session not running** instead of "Sent."; it's delivered when the session is back. +- **Readable session names.** Sessions that join the relay get readable names based on their project instead of a long ID. +- **Headless Linux keeps its uploader current.** On servers, the app now replaces an outdated uploader the same way the desktop app does, so upload health reports correctly. +- The Session Uploads window gives the session list more room: the upload steps fold behind **Show details** once sessions are listed, and the window opens larger. The toggle respects Reduce Motion. + +## v2026.09.16 + +- **Agents, grouped by person.** The Agents page is organized by person: your own sessions, fleet workers and agents on your other machines under your name and avatar, then each teammate's live agents in a collapsible section. Ended sessions are hidden. **Message** on any agent opens a chat at its exact address. +- **Readable addresses.** Sessions on the relay now have an address that names the computer they run on (for example `reviewer@ada-s-laptop-…`) instead of `@direct`, so teammates can tell whose machine an agent is on. Addresses stay the same if the computer is renamed, and existing `@direct` addresses keep working. +- **Replies show up where you sent from.** When a session answers a message, the reply comes back to the desktop app and the web conversation, threaded under the message it answers. A session that has already replied no longer also sends its final narration as a second message. +- **Sessions can join the relay themselves.** A Codex or Claude Code session can put itself on the relay, see who else is on it, and leave again without anyone opening the app. A setting controls whether sessions may do this. +- **Agent-led setup, including headless Linux.** A coding agent can set up Agent Relay on a Mac or a Linux server end to end, with sign-in by a one-time device approval from any browser or phone. On servers set up this way, every session goes on the relay, Claude Code receives relayed messages directly, and new sessions are uploaded automatically. +- **Faster and more reliable sessions.** Looking up the calling session is near-instant (it could take about 45 seconds), older Codex sessions are recognized when they send, and sessions are ordered by their real last activity. +- Signing in again while your workspace's uploader is running now just works: the app takes over the running uploader instead of showing an error. +- An older copy of the app no longer shows you as signed out of a workspace a newer version is uploading, and never replaces a newer command-line uploader with its older one. + +## v2026.09.15 + +- The session list no longer shows uploaded sessions as still waiting on a large history: sessions it has confirmed stay **Uploaded** until they change, and any it hasn't checked yet show **Checking…** instead of counting as in progress. +- The menu bar panel lists your Codex and Claude sessions, each with a switch that puts it on the relay. A dot on the session's icon shows whether it can receive messages right now, and a session on the relay is uploaded automatically. +- Linux: a message injected into a session carries the one-line reply command only when that session can actually send through the app, matching macOS; a session the app could never identify as the sender no longer gets a command that always fails. +- The uploader's log no longer grows without limit: `collector.log` rotates at 10 MB and keeps three older logs. Disconnecting a workspace now also releases its upload queue and removes its logs, while keeping your local session history. +- The app now releases upload data left behind by disconnected workspaces in the background after it starts, without delaying launch, and skips workspaces it has already cleaned up with a quick file check instead of reopening their history. +- Uploads now clean database-incompatible JSON text, skip only individually rejected records while continuing later uploads, distinguish those skips from privacy exclusions in status, report server rejections honestly, and reliably replace an uploader left running by an older app build. + +## v2026.09.14 + +- Uploads no longer stop for good on one very large session record, such as a tool result that printed a whole PDF. The record is shortened to fit and marked as truncated, and the rest of your history keeps uploading. + +## v2026.09.13 + +- A large upload backlog now drains in minutes instead of hours: the uploader keeps sending while it has a backlog, instead of pausing to re-read all of your local history every minute. +- Sessions show **Uploaded** as soon as everything in them has been uploaded, instead of all showing **Preparing** until your whole history finishes uploading. +- macOS: sessions made available on the relay can now reply and send as their own `@name` through a private Unix socket. The app derives the sender from the connecting process, checks pid and process start time to prevent pid-reuse impersonation, rate-limits sends, and never exposes the session's Relaycast token. + +## v2026.09.12 + +- Updating no longer stalls uploads on a large session history: the one-time upgrade of local history now finishes in the background (the app shows **Upgrading local history** meanwhile), and the app restarts the uploader so an update takes effect without quitting it. After the uploader restarts, the app no longer shows the previous run's "Sync paused or offline" message while the new run catches up. +- Linux: after an update, the app shows **Upgrading local history** while the uploader upgrades your session history, instead of an error, and restarts an uploader still running the previous version so the update takes effect. + +## v2026.09.11 + +- Session uploads now read straight from your local session history, so there is no separate upload queue to fill up. Capture and uploads that stopped because that queue was full start again on their own after updating, resending everything not yet uploaded. +- Messages can target one exact Relaycast endpoint with its canonical `agent@machine` address. The desktop shows and copies published addresses, accepts `agentrelay://message?to=agent%40machine`, keeps plain `@name` sends working, and reports malformed, stale, wrong-machine, missing, or ambiguous endpoints without falling back to another machine. +- Injectable agents preserve a DM sender's canonical address in the prompt and use it for automatic final answers, so a reply cannot follow a same-named agent onto another machine. Replies already queued by older builds remain compatible. + +## v2026.09.10 + +- Linux: the Agents page now lists the Claude Code sessions running on your computer. **Make available** gives a session an @name on the relay, and teammates' and agents' messages to it are typed into the session for you. Messages arrive the moment they're sent. +- Linux: a new **Workspace** page lists everyone on the relay — agents, people and services — with who is active, what each one does, and whether it runs on this computer, in the cloud or on another desktop. +- Linux: sessions made available on the relay can now reply and send messages themselves. Each message a session receives shows the sender's address and a one-line reply command; the app sends the reply as the session's own @name, and no key or token is ever shown to the session. +- Linux: message anyone in the workspace from the **Workspace** page. Messages go out as your one Agent Relay identity — the same you as on the web dashboard — so every conversation is in one history wherever you open it. Replies appear the moment they're sent. +- Session uploads no longer get stuck on a session whose output contains binary data (for example, a command that printed an executable). Uploads that were stuck this way resume on their own after updating. + +## v2026.09.9 + +- Integration events are quieter: index updates, generic record syncs, edits and pushes no longer interrupt the session; pull requests, reviews and new comments still arrive. +- Automatic replies now go only to people and agents who message you. An agent's answer to a GitHub event is never posted back to GitHub unless it replies on purpose. + +## v2026.09.8 + +- Integrations no longer need relayfile on your Mac: no install, login or background service. Subscribing uses your Agent Relay sign-in, and if the provider isn't connected yet, Agent Relay opens to connect it. + +## v2026.09.7 + +- Injectable sessions now answer back: when a message's turn finishes, the agent's final answer is sent to the sender as the agent's `@name` (Codex and Claude Code sessions). Turn it off per agent with **Reply with final answer**. +- New **Integrations** in each agent's tools panel: subscribe it to a GitHub, Linear, Slack or Jira resource and provider events arrive in the session, while its replies go back to the provider. It uses your Agent Relay workspace automatically; there is nothing to set up per workspace. + +## v2026.09.6 + +- Fixed signing in with Google and **Connect workspace** returning "This sign-in request was not recognized": the sign-in's check value no longer gets lost on the way back from Agent Relay. +- The Agents tab now joins the Relaycast workspace this Mac's `agent-relay` already uses, so injectable sessions sit beside your relay agents. Without one, it uses your Agent Relay account's workspace, fetched automatically when you sign in. If the two differ, the tab says so. +- Messages reach injectable sessions faster: pushed direct messages and thread replies go straight into the session, and the background check runs less often. +- Each injectable session in the Agents tab has a tools panel: rename its agent (the old name is released), send it a test message through Relaycast, and create a **webhook** — a URL and bearer token that any service (CI, alerts, scripts) can POST to, landing straight in that session. +- The Agents tab (macOS) and a new Agents page (Linux) now show the fleet workers this computer's Agent Relay broker is running — `agent-relay node|fleet agent spawn` agents across Codex, Claude, Devin, and other providers — with their live state, channels, and pending message counts. Copy a worker's @name to message it from the workspace, or release it back to the fleet. + +## v2026.09.5 + +- New **Agents** tab (now the first tab): every Codex thread and Claude Code session on your Mac is listed. Turn one on to give it a name on Relaycast; anyone in your workspace can then DM or @mention that name, and the message is put straight into the running session — no `agent-relay` spawn needed. +- One click lets Claude Code show relayed messages without holding them for approval. + +## v2026.09.4 + +- Agent Relay is now available for Linux on x64 and arm64, with Debian packages and tarballs. +- On macOS, check for app updates from Settings and download a verified disk image. +- On macOS, choose which sessions to share from the menu bar and find local sessions by title. diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/apps/desktop/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/desktop/LICENSE b/apps/desktop/LICENSE new file mode 100644 index 0000000000..4b1147b334 --- /dev/null +++ b/apps/desktop/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Agent Workforce Incorporated + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000000..8152c06cf2 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,515 @@ +# Agent Relay Desktop + +Native, temporary agent collaboration: see [Relay Connect](docs/native-connect.md). + + +The native companion app that connects a computer to +[Agent Relay Teams](https://agentrelay.com) in two clicks: install the app, sign +in with Google, done. + +It bundles `agent-relay-probe`, the relay core: one Rust program that puts your +local coding sessions (Claude Code, Codex and friends) on the relay, delivers +messages to them, and captures and uploads them to your workspace. The app shows +that in the menu bar and lets you pause uploads, choose which past sessions to +share, and message your agents. You never have to touch a terminal. + +macOS, Linux and Windows ship as full companion apps; the +[Windows app](windows/README.md) is the Linux GTK app built for Windows. The +native [iPhone and iPad app](ipad/native/README.md) connects directly to Cloud +and Relaycast. + +## Start over with local sessions during development + +Build the probe, list its configured installs, then reset the intended one: + +```bash +cargo build --manifest-path probe/Cargo.toml --bin agent-relay-probe +probe/target/debug/agent-relay-probe sharing status --json +probe/target/debug/agent-relay-probe sessions reset --local \ + --site-url '' --account '' --workspace '' \ + --confirmation 'RESET LOCAL SESSIONS' +``` + +Use the install’s site origin and IDs from `sharing status`. For local development, +use the launcher’s exact origin, including its port. This command keeps sign-in +and original Codex/Claude conversations, clears the target install’s upload journal +and selections, and restarts its collector with manual selection and no uploads +selected. Cloud sessions are unchanged. Existing conversations can be discovered, +read, and selected again; refresh the desktop session list after it finishes. + +Reader/preview caches and the old reset exclusion list are shared across local +installs, so those are cleared machine-wide. Other installs’ upload journals and +selections are retained. Interrupted local resets replay locally before capture +resumes. A pending cloud reset must be completed before switching reset modes. + +Without `--local`, `sessions reset` remains the destructive cloud-and-local reset +and requires `DELETE ALL SESSIONS`. Use `--local` for a development start-over; +the destructive reset deliberately excludes pre-existing sessions. Neither reset +is exposed in the desktop Settings UI. + +## Download + +| Mac | Download | +| --- | --- | +| Apple silicon (M1 and later) | [**AgentRelay-macOS-arm64.dmg**](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-macOS-arm64.dmg) | +| Intel | [**AgentRelay-macOS-x64.dmg**](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-macOS-x64.dmg) | + +Linux releases include `.deb` packages and relocatable `.tar.gz` trees for +x64 and arm64. See [linux/README.md](linux/README.md) for dependencies, +source runs, and packaging. + +| Linux | Debian package | Tarball | +| --- | --- | --- | +| x64 | [**AgentRelay-Linux-x64.deb**](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Linux-x64.deb) | [`.tar.gz`](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Linux-x64.tar.gz) | +| arm64 | [**AgentRelay-Linux-arm64.deb**](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Linux-arm64.deb) | [`.tar.gz`](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Linux-arm64.tar.gz) | + +Windows releases are per-user installs for x64 and arm64 that carry the GTK +runtime: a Setup program, or a portable `.zip`. See +[windows/README.md](windows/README.md) for installing, the logon task, +building with MSYS2, and the harnesses Windows lacks. + +| Windows | Installer | Portable | +| --- | --- | --- | +| x64 | [**AgentRelay-Windows-x64-Setup.exe**](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Windows-x64-Setup.exe) | [`.zip`](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Windows-x64.zip) | +| arm64 | [**AgentRelay-Windows-arm64-Setup.exe**](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Windows-arm64-Setup.exe) | [`.zip`](https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Windows-arm64.zip) | + +For unattended macOS installation, headless Ubuntu/systemd installation, and +the private session-socket setup API, see [Agent-driven setup](docs/agent-driven-setup.md). + +Not sure which you have? Apple menu → **About This Mac**. "Chip" means Apple +silicon; "Processor" means Intel. + +Those two URLs always resolve to the newest release. Every release also carries +version-pinned copies (`AgentRelay--macOS-arm64.dmg` and +`…-x64.dmg`, byte-identical to the stable names) and a `.sha256` for each image: + +```sh +shasum -a 256 -c AgentRelay-macOS-arm64.dmg.sha256 +``` + +Open the image, drag **Agent Relay** to Applications, and launch it. + +### Welcome and uploads + +On macOS the window is organized by whose relay you are looking at. The rail's +**Me** holds *My agents* (every live session on this Mac, its switch onto the +relay, fleet workers and your agents on other machines) and *My sessions* +(every session with its upload state and the controls to choose which upload). +**Team** shows each teammate's agents on the relay. **Settings** and your +account sit at the foot of the rail; Settings › Appearance chooses Match +system, Light or Dark. The layout is documented in +[`docs/workspace-layout.md`](docs/workspace-layout.md). + +Agent Relay opens a full window as well as a menu-bar control. The welcome +screen uses a split layout, with sign-in on the right. Sign in with Google in +your browser; signing in does not select any sessions for upload. The relay core +keeps your Agent Relay session, connects the workspace, captures the local +session catalog and starts the background collector. The window then opens +My agents automatically. + +**My agents** lists every session on this Mac; the menu-bar panel counts how +many sessions are receiving messages, local only, uploaded and not. Each row's switch puts the session on the relay, which gives it a name to +message and uploads it. Search filters the list; a row's chevron shows its +address, ids, folder and upload, plus a Cloud session overview and tools for rename, replies, webhooks and +integrations. **Message** in the top bar writes to any address; the **⋯** menu +holds refresh, recent messages and relay settings. Workspace and Claude Code +delivery settings live in **Settings › Relay & messages**. + +My agents and Team can filter by machine. Agents on other machines and fleet +workers show their saved Cloud overview when available and can request a new +summary. A published agent description and the original user request from +uploaded History events remain visible while Cloud is generating a summary or +unavailable. + +The default is **Only sessions I choose**. In **My sessions** (also the upload +status in the window's status bar or the menu-bar panel), check the rows you want +and click **Upload selected**. Checking a row alone does not start an upload. +Selected sessions continue uploading changes; other existing and newly discovered +sessions stay local. **Settings › Session sharing** is where automatic uploads +for new sessions or all past and future sessions require explicit confirmation. +Existing saved upload policies are preserved. Already uploaded remote data is not +deleted by stopping uploads. + +My sessions displays actual per-session queued/uploading/uploaded states and +record-level progress. The open window refreshes every five seconds. Search +filters loaded sessions; **Load more** extends the initial 500-session page. + +The session uploads table opens with the latest local titles from the uploader's +shallow catalog while full history capture continues. Preview rows remain +unselectable until the uploader confirms they are ready. The desktop shows +per-source files processed and sessions captured locally. The preview catalog, +local file counts and capture counts stay on the Mac; Cloud receives only +permitted-session delivery progress. Sessions on the relay are uploaded. +Settings controls opening at login, the background service and upload policy. +Settings and Account include About diagnostics: the desktop release (or Git +revision for development), running and bundled service versions, the compiled +RelayHistory (`ai-hist`) version, bridge protocol, service process, Relaycast +verification and live-channel state, and Cloud/Relaycast workspace IDs. Copy +diagnostics copies those displayed fields without credentials or session content. +Disconnected services mark their retained snapshot as stale. Account also lets +you log out. +Quitting the app leaves the relay core running: agents keep receiving messages +and uploads continue. Reopen the app from the Dock or Applications. Turn off +**Settings › General › Keep agents connected after quitting** to have quitting +stop the core until the app opens again. + +### First run + +When the release was built with Developer ID credentials it is signed and +notarized, and opens normally. + +Local builds and PR CI builds produced without those credentials are **ad-hoc signed**. macOS then +refuses the first launch with *"Agent Relay" cannot be opened because it is from +an unidentified developer*. To open it once: + +1. In Applications, **right-click** (or Control-click) **Agent Relay**. +2. Choose **Open**. +3. Choose **Open** again in the dialog. + +macOS remembers the decision; later launches are normal. On macOS 15 you may +instead have to go to **System Settings → Privacy & Security** and click **Open +Anyway** under the message about Agent Relay. + +## How the relay core is bundled and run + +The relay core is [`probe/`](probe/), a Rust crate built from this repository by +CI and releases for every target and stamped with the desktop release version it +ships in. It reads local history through the published `ai-hist` crate. +`build-app.sh` lays it inside the bundle with the LaunchAgent that runs it: + +``` +Agent Relay.app/Contents/ + Info.plist + PkgInfo + MacOS/RelayDesktop # the SwiftUI app + Helpers/Agent Relay Helper # agent-relay-probe: the relay core, 0755 + Helpers/Agent Relay Helper (Sessions) # a hard link to it: the session collector + Library/LaunchAgents/com.agentrelay.desktop.core.plist + Resources/AppIcon.icns + Resources/agent-relay-probe.sha256 # checksum of the bundled core + Resources/probe-version.txt # the core's version +``` + +The app registers the LaunchAgent with `SMAppService.agent` on launch. launchd +runs `agent-relay-probe relay serve` at login with no window, keeps it alive, +and restarts it from the bundle after an update; the app is a client of its +socket, `~/.agentworkforce/desktop/core.sock`. Activity Monitor lists the core +as **Agent Relay Helper** and the session collector it starts as **Agent Relay +Helper (Sessions)**. The collector reads local sessions, lists them for *My +sessions* and uploads the ones being shared; it runs as a background process, +so it yields to everything else. When macOS asks for approval, +Settings and the sign-in screen open **System Settings › General › Login +Items**. With **Keep agents connected after quitting** off, quitting +unregisters the LaunchAgent, which stops the core until Agent Relay opens again. Agent Relay Dev has its own label +(`com.agentrelay.desktop.dev.core`), socket (`core.dev.sock`) and data directory +(`com.agentrelay.desktop.dev`). The bridge contract and the lifecycle are in +[`docs/relay-core.md`](docs/relay-core.md). + +The core keeps `~/.local/bin/agent-relay-probe` no older than itself (never over +a copy another developer signed) so the command line runs the same uploader. +Packaging checks that the core serves `relay serve` and that the uploader +commands it runs support `--json`, `--selected-sessions-only` and +`sessions preview`; the check runs on hosts that match the build target. + +Sign-in opens Google in your default browser and returns to a localhost +receiver the core runs; the app never handles Google credentials or tokens. + +## Development + +Requirements: macOS 13 or later, the Xcode command line tools, and a Rust +toolchain for the probe. + +```sh +# The app on its own (SwiftPM, no Xcode project) +swift build --package-path macos +swift test --package-path macos + +# The probe and its tests +cargo test --manifest-path probe/Cargo.toml --locked +scripts/build-probe.sh --target aarch64-apple-darwin --version 0.0.0 --out probe-build + +# A complete, ad-hoc signed bundle +scripts/build-app.sh --arch arm64 --version 0.0.0 --probe probe-build --out dist +# One-time setup for the styled, headless DMG packager (Python 3.10+) +python3 -m venv /tmp/relay-dmg-tools +/tmp/relay-dmg-tools/bin/python -m pip install -r scripts/dmg-requirements.txt +export PATH="/tmp/relay-dmg-tools/bin:$PATH" +scripts/package-dmg.sh --app 'dist/Agent Relay.app' --out dist/AgentRelay.dmg +``` + +`swift run` has no LaunchAgent to register, so it connects to a core you start +yourself on the same socket: + +```sh +cargo run --manifest-path probe/Cargo.toml -- relay serve +``` + +Development reloads rebuild both the Swift executable and Rust helper, validate +the helper’s reset command, and re-sign the bundle with its existing identity. + +Each new build (`build-app.sh`, or `scripts/dev-reload.sh` replacing the app +executables) registers its core again on launch and retires the one registered +before. launchd admits the core against a code requirement macOS records for +the agent: a Developer ID or Apple Development signature names a team, so an +update starts at once. An ad-hoc signature pins the old binary's hash, which +macOS keeps for a short while after the bundle changes; the app registers the +core again while it does not answer, and the new core starts within about a +minute. + +Every script takes `--help`. In short: + +| Script | What it does | +| --- | --- | +| `scripts/build-probe.sh` | Builds `probe/` for one Rust target, stamped with a version. Writes `agent-relay-probe`, `agent-relay-probe.sha256` and `probe-version.txt`. | +| `scripts/build-app.sh` | `swift build -c release --arch …`, assembles `Agent Relay.app`, renders the icon, fills in `Info.plist`, then signs (Developer ID → notarized, else ad-hoc). | +| `scripts/package-dmg.sh` | Creates a branded Retina `.dmg` with fixed app and Applications icon positions, using `dmgbuild` without Finder automation, and writes `FILE.dmg.sha256`. | +| `scripts/render-dmg-background.swift` | Composes the subdued relay-runner artwork with the source logo/wordmark, dashed drag arrow and install instruction at 1x and 2x. Run with an output directory to preview; `scripts/dmg-settings.py` places the native Finder icons in the matching 768×576 layout. This artwork is specific to the macOS DMG. | +| `scripts/render-icon.swift` | Standalone Swift script that draws the Agent Relay mark on a dark squircle into an `AppIcon.iconset`. Run by `build-app.sh`; `swift scripts/render-icon.swift OUT_DIR` to preview it. | + +The Linux app (`agent-relay-desktop`, GTK 4) runs and tests with: + +```sh +cargo build --manifest-path probe/Cargo.toml --features gtk --bin agent-relay-desktop --bin agent-relay-probe +probe/target/debug/agent-relay-desktop +xvfb-run -a cargo test --manifest-path probe/Cargo.toml --locked --features gtk --test linux_app_gtk +``` + +Build a release tree, tarball, and Debian package with +`scripts/build-linux.sh`, passing the app (`cargo build --manifest-path probe/Cargo.toml --release +--features gtk --bin agent-relay-desktop`) and a probe from `scripts/build-probe.sh` +(`x86_64-unknown-linux-gnu` or `aarch64-unknown-linux-gnu`). See +[`linux/README.md`](linux/README.md). + +The Windows app is the same `agent-relay-desktop` program, built natively in an +[MSYS2](https://www.msys2.org) CLANG64 (x64) or CLANGARM64 (arm64) shell with +its Rust and GTK 4 packages: + +```sh +cargo build --manifest-path probe/Cargo.toml --features gtk --bin agent-relay-desktop --bin agent-relay-probe +cargo test --manifest-path probe/Cargo.toml --locked --features gtk --test linux_app_gtk +scripts/build-windows.sh --version 0.0.0 --arch x64 --out dist/windows +``` + +`scripts/build-windows.sh` builds both programs and writes the portable tree, +the zip with the GTK runtime, and the bare-core zip. See +[`windows/README.md`](windows/README.md). + +The relay core, its bridge contract and the apps' lifecycles live in +[`docs/relay-core.md`](docs/relay-core.md). + +## Release process + +1. Add user-facing bullets under `## Unreleased` in [`CHANGELOG.md`](CHANGELOG.md), + user-facing changes for the release, then make sure `main` is green. The + workflow requires at least one Unreleased bullet and copies those bullets + into **What's new in this release**. +2. Dispatch the `Desktop Release` workflow with no inputs, or tag and push: + + ```sh + gh workflow run 'Desktop Release' --repo AgentWorkforce/relay + # or, to override the default version: + # gh workflow run 'Desktop Release' --repo AgentWorkforce/relay -f version=2026.09.2 + # or: + git tag desktop-v2026.09.1 + git push origin desktop-v2026.09.1 + ``` + +`.github/workflows/desktop-release.yml` then builds macOS, Linux and Windows for arm64 +and x64 on native runners (`macos-15`, `macos-15-intel`, Ubuntu 22.04 on +Blacksmith for both Linux architectures, and `windows-latest` and +`windows-11-arm`; see [CI runner configuration](.github/README.md)), +requires macOS signing and notarization, and publishes to +[`AgentWorkforce/relay-desktop-releases`](https://github.com/AgentWorkforce/relay-desktop-releases). +Versions are CalVer (`YYYY.MM.N`, where `N` counts that month's releases +starting at 1). A `workflow_dispatch` run without a `version` input computes +the next `N` from the current month's published releases; pass `version` +(without the leading `v`) to override it, or push a `desktop-v…` tag for the same effect. +Overlapping release runs serialize on one concurrency group; if your run shows +Canceled because a newer release superseded it while pending, re-dispatch and +it allocates the next `N`. +The source stays in this repository. Each release contains these assets: + +- `AgentRelay-2026.09.1-macOS-arm64.dmg` + `.sha256` +- `AgentRelay-2026.09.1-macOS-x64.dmg` + `.sha256` +- `AgentRelay-macOS-arm64.dmg` + `.sha256` (stable name, same bytes) +- `AgentRelay-macOS-x64.dmg` + `.sha256` (stable name, same bytes) +- `AgentRelay-2026.09.1-Linux-x64.deb` + `.sha256` +- `AgentRelay-2026.09.1-Linux-arm64.deb` + `.sha256` +- `AgentRelay-2026.09.1-Linux-x64.tar.gz` + `.sha256` +- `AgentRelay-2026.09.1-Linux-arm64.tar.gz` + `.sha256` +- stable `AgentRelay-Linux-.deb` / `.tar.gz` copies with `.sha256` +- `AgentRelay-2026.09.1-Windows-x64.zip` + `.sha256` +- `AgentRelay-2026.09.1-Windows-arm64.zip` + `.sha256` +- stable `AgentRelay-Windows-.zip` copies with `.sha256` +- `AgentRelay-2026.09.1-Windows--Setup.exe` + `.sha256`: the per-user + installer, with stable `AgentRelay-Windows--Setup.exe` copies +- `AgentRelay-2026.09.1-Windows--probe.zip` + `.sha256`: the bare + Windows core (`agent_relay/helpers/agent-relay-probe.exe` and the DLLs it + needs), with stable `AgentRelay-Windows--probe.zip` copies +- `AgentRelay-2026.09.1-macOS--probe.tar.gz` and + `AgentRelay-2026.09.1-Linux--probe.tar.gz` + `.sha256`: the standalone + headless probe (`agent_relay/helpers/agent-relay-probe`), with stable + `AgentRelay---probe.tar.gz` copies. The Linux probe runs on glibc + 2.34 and newer; the release fails if either architecture's probe needs a + newer glibc (`scripts/check-glibc-floor.sh`). + +Installed builds check the public repository's latest stable release once per +day by default. When an update is available, the app shows a blue update button +and asks before quitting to install it. **Settings → Software updates** also +provides an immediate check. The app verifies the architecture-specific disk +image's published SHA-256 and the replacement app's signature before installing +and reopening Agent Relay. + +## Repository secrets and variables + +Configure these Actions secrets in the repository where builds run. Public +releases require five signing/notarization secrets plus `RELEASES_TOKEN`; +local builds and PR CI can still use ad-hoc signing. No secrets belong in the public +distribution repository. + +| Name | Kind | Purpose | +| --- | --- | --- | +| `RELEASES_TOKEN` | secret | Fine-grained token restricted to `AgentWorkforce/relay-desktop-releases`, with Contents read/write. | +| `CSC_LINK` | secret | Developer ID Application certificate and private key, as a base64-encoded `.p12` (the same format as Pear). The signing identity is selected from the certificate. | +| `CSC_KEY_PASSWORD` | secret | Password for that `.p12`. | +| `APPLE_API_KEY_BASE64` | secret | App Store Connect API key as a base64-encoded `.p8`. | +| `APPLE_API_KEY_ID` | secret | App Store Connect API key ID. | +| `APPLE_API_ISSUER` | secret | App Store Connect API issuer ID. | +| `POSTHOG_API_KEY` | secret | PostHog project ingestion key embedded only in official release builds. | +| `POSTHOG_HOST` | secret | HTTPS PostHog ingestion origin embedded only in official release builds. | + +The release workflow fails when a signing/notarization secret is missing. GitHub +does not allow one repository to read another repository's secrets, so the five +Apple values must be configured in the source repository. +The publishing token is passed only to the publish job; the default `GITHUB_TOKEN` +stays read-only. Initialize the distribution repo with its README on `main` and +make it public before the first release. A prerelease tag such as `v1.2.3-rc.1` +does not replace the stable `/releases/latest` download. + +Create the fine-grained token in GitHub, then store it without putting its value in +a command line or transcript: + +```sh +gh secret set RELEASES_TOKEN +``` + +The tag is created against the distribution repo’s `main`, not the source +commit. Release notes must describe the shipped app; source PR notes are not +automatically copied into the distribution repository. Nothing in these scripts or workflows prints a +secret value; the decoded `.p12` is written to a `0600` temporary file that is +deleted before the script exits, alongside the temporary signing keychain. + +## Website + +The download buttons on point at the stable +asset names above, so a new release changes what they serve without any website +deploy. + +### Desktop analytics and error tracking + +The relay core sends explicit `desktop_operation` events and handled +`$exception` events to the same PostHog project as Cloud, for the production +site in a release build only. The app records the few operations only it +performs (launch, settings, logs, login item, connection links) through the +core. Official release builds receive the project ingestion key and HTTPS host +from protected GitHub Actions secrets. Forks, source builds, tests and +`--dev-site-url` bundles contain no PostHog project configuration and send +nothing. A PostHog ingestion key is not an administrative API credential, but +keeping it out of source prevents community builds from writing to the official +project. + +The operation/outcome funnel distinguishes sign-in, browser approval, history +preparation and connection restoration. Further events cover helper startup and +polling failures, reported upload health failures/recovery, pause/resume, including/excluding sessions, sharing-mode changes, +logout, login-item settings and opening settings/logs. Successful polling does not +emit events. Session inclusion means the helper accepted an upload selection; it +does **not** assert that the upload reached Cloud. Native crashes and probe-internal errors that are not exposed in its status +contract are not captured by this client. + +After sign-in, events are linked to your Agent Relay account. They never contain +message or session text, searches, paths, URLs, credentials or raw errors. +Opting out turns all of it off. + +| State | `distinct_id` | Person and groups | +|---|---|---| +| No install attached | `desktop:` | `$process_person_profile: false` | +| Install attaches | one `$identify` from `desktop:` (`$anon_distinct_id`) to Cloud `users.id` | merges the installation's earlier events, sign-in setup included, into the user; a cancelled or unattached sign-in stays anonymous | +| Attached | Cloud `users.id` | `$groups: { organization: , workspace: }` | + +The installation UUID is random. Once merged it belongs to that person: a +relaunch keeps it until discovery decides, and sign-out, opt-out, discovery +finding no install and another person attaching replace it. The merge is recorded only once PostHog accepts `$identify`; a +refused one is sent again with the next event. Context is limited to fixed +operation/outcome/error codes, app/probe/OS versions, bounded session counts +(`session_count`) and sharing mode (`sharing_mode`), with `$geoip_disable`. +Error grouping uses operation + error code, so an unknown helper failure remains +`helper_failed`; Cloud supplies server-side diagnostics for the corresponding +failure category. No session replay or UI autocapture is enabled. + +Session agents register on Relaycast with `cloud_user_id`, `cloud_workspace_id` +and `owner_hash` in their metadata, so the engine credits their messages to the +signed-in person. When an install attaches, agents registered before it, or +under another person, are updated through `PATCH /v1/agents/{name}`. + +**Settings → Share diagnostics** disables both event types and drops queued +events. The core keeps the preference in the app data directory. Identical exception operation/error pairs +are limited to once per minute (repeated background polling failures are also +deduplicated in product events; explicit user attempts still count), with a +global limit of 100 operations per minute on a monotonic clock. One request is +in flight at a time; requests are best effort with finite timeouts, without disk +persistence, and never delay sign-in or upload actions. + +Desktop usage stays in `desktop_operation`, with these additional `operation` +values shared by macOS, Linux and Windows: + +| Operation | Trigger and interpretation | +|---|---| +| `agent_register` | Explicit `agents.register` request starts and succeeds/fails; `agent_kind` is the fixed provider enum and `already_registered` distinguishes an existing registration from a new one. Automatic discovery/registration is not a UI creation attempt. | +| `agent_unregister` | Explicit removal starts and succeeds/fails. | +| `message_send` | Person-to-agent send starts and succeeds/fails; `is_reply` records whether a reply parent was supplied. Success means the send API accepted it, not that the agent read it. | +| `message_test` | Explicit test-message command starts and succeeds/fails. | +| `message_composer` | Opening the message composer (`started`), without its recipient. | +| `page_view` | Entering a page or settings pane (`succeeded`), including the initial connected page; unchanged snapshots do not repeat it. | +| `search` | A nonempty session/agent/team query or filter settles for 500 ms (`succeeded`); clearing cancels a pending event. No query, query length, result text or recipient is sent. | +| `transcript_open` | Initial transcript read or explicit refresh starts and succeeds/fails; pagination and background following are excluded, including polls without a cursor. | +| `delete_sessions` | Explicit deletion succeeds/fails, separate from `exclude_sessions`. | +| `upload_completed` | A previously observed queued/uploading session becomes `uploaded`; `session_count` aggregates transitions in that listing. Historical uploads, new pagination baselines and repeated polls do not count. This is observed progress, not a complete Cloud delivery ledger: transitions between reads or while the app is closed may be missed. | +| `update_install` | An update starts and succeeds/fails. Success means replacement was installed or handed to the native replacement helper, not a confirmed relaunch. A downloaded Linux `.deb` emits `requires_action` because installation is manual. | + +The optional `surface` is a closed vocabulary: `sessions`, `agents`, `team`, +`messages`, `projects`, and `settings_general`, `settings_opening`, +`settings_relay`, `settings_sharing`, `settings_account`, `settings_about`, +`settings_diagnostics`. Projects is reserved for the macOS-only hidden page. +The settings event now follows actual settings-pane navigation on both platforms. +Browser opening emits `browser_approval` started and succeeded/failed on each desktop. +Success means the native opener accepted the handoff (on Windows, the validated +URI was queued to the shell worker), not that browser approval finished. +Sign-in cancellation is emitted once in the core, not again by either UI. +Include/exclude/delete report the number of requested session keys as +`session_count`; sharing-mode changes report `sharing_mode`. +Agent/message/transcript command failures use `request_failed`, never error text; +update failures use `update_failed`. All new events use the existing opt-out, +identity, rate limits, exception deduplication and best-effort queue. +Awaited update telemetry calls have a three-second client deadline, including connection +and response time. GTK waits asynchronously so the window stays responsive; an +unresponsive diagnostics call cannot indefinitely delay downloading or restarting. + +The request format follows PostHog's [capture API](https://posthog.com/docs/api/capture) +and [exception schema](https://github.com/PostHog/posthog-js/blob/main/packages/core/src/error-tracking/types.ts). +The core's telemetry and account tests inspect requests with a recording +sender; they never send test events to PostHog. + +### Desktop build identity + +The probe is an internal component of the desktop release, not a separately +versioned product. Release builds stamp the same `YYYY.MM.N` version into the app +and probe. Plain Cargo builds report `-dev+`; the manifest's +`0.0.0` is an unpublished package placeholder. Development builds take their numeric base +from the latest release heading in `CHANGELOG.md`; Git hashes identify builds +without being treated as chronological versions. `build.rs` reads the resolved +`ai-hist` version from `Cargo.lock` for runtime diagnostics. The macOS packaging +and reload scripts record the desktop Git revision in `build-revision.txt`. + +## License + +Agent Relay Desktop is licensed under the [Apache License 2.0](LICENSE). diff --git a/apps/desktop/assets/agent-icons-LICENSE.txt b/apps/desktop/assets/agent-icons-LICENSE.txt new file mode 100644 index 0000000000..e956ce2594 --- /dev/null +++ b/apps/desktop/assets/agent-icons-LICENSE.txt @@ -0,0 +1,25 @@ +Codex and Claude: Cloud public/onboarding-icons (LobeHub icons). +Grok, Cursor, OpenCode, OpenAI, and Claude Code: +https://github.com/lobehub/lobe-icons/tree/master/packages/static-svg/icons + +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/desktop/assets/agent-relay-favicon.svg b/apps/desktop/assets/agent-relay-favicon.svg new file mode 100644 index 0000000000..7ebee81365 --- /dev/null +++ b/apps/desktop/assets/agent-relay-favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/assets/agent-relay-mark.svg b/apps/desktop/assets/agent-relay-mark.svg new file mode 100644 index 0000000000..4c3b04672f --- /dev/null +++ b/apps/desktop/assets/agent-relay-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/assets/agent-relay-wordmark.svg b/apps/desktop/assets/agent-relay-wordmark.svg new file mode 100644 index 0000000000..55408800b2 --- /dev/null +++ b/apps/desktop/assets/agent-relay-wordmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/assets/dmg-background.png b/apps/desktop/assets/dmg-background.png new file mode 100644 index 0000000000..ddadcea47a Binary files /dev/null and b/apps/desktop/assets/dmg-background.png differ diff --git a/apps/desktop/assets/dmg-background.prompt.txt b/apps/desktop/assets/dmg-background.prompt.txt new file mode 100644 index 0000000000..63a95f13b4 --- /dev/null +++ b/apps/desktop/assets/dmg-background.prompt.txt @@ -0,0 +1,28 @@ +Generator: built-in image_gen +Selected direction: #1, subdued archival relay runners on white (approved by user). + +Edit reference: the approved Agent Relay installer concept with four faint +relay-runner poses, pale cool-gray background, and the Agent Relay branding. + +Final background-only prompt: +Precise-object-edit. Convert the supplied approved installer mockup into a +production BACKGROUND-ONLY asset. Preserve the exact 4:3 composition, pale cool +gray nearly white background and the four faint archival relay runner silhouettes +in the lower band at exactly their original size, positions, contrast and texture. +REMOVE the top-left Agent Relay logo and wordmark; REMOVE the large app icon at +left and its shadow and label; REMOVE the Applications folder at right with its +arrow badge, shadow and label; REMOVE the center dashed arrow; REMOVE the bottom +instruction text. Fill those removed areas seamlessly with the same pale +background. Final image contains ONLY the pale background and the unchanged four +runner illustrations in the lower band. No text, no logo, no app icons, no folder, +no arrow, no UI. Do not crop, reframe, move or redesign the runners. This is +production artwork, not a mockup. + +Composition: +- assets/dmg-background.png contains only the pale background and runner strip. +- scripts/render-dmg-background.swift renders the original agent-relay-mark.svg + and agent-relay-wordmark.svg geometry, with dark lettering on the pale surface, + plus the dashed arrow and the instruction at both standard and Retina sizes. +- scripts/dmg-settings.py places native Finder icons and their filename labels. +- The final content area is 768 x 576 points; runners occupy the lower band. +- This is a macOS DMG-specific presentation; Linux packages have no Finder view. diff --git a/apps/desktop/docs/agent-details.md b/apps/desktop/docs/agent-details.md new file mode 100644 index 0000000000..a6fca673a3 --- /dev/null +++ b/apps/desktop/docs/agent-details.md @@ -0,0 +1,11 @@ +# Agent details data contract + +The Agents page reads `roster.list` through the signed-in desktop core. A roster row has `name`, `address`, `kind`, `where`, `status`, `last_seen_ms`, `description`, `host`, `owner_hash`, and `is_self`. The core intentionally reduces the Relaycast record; arbitrary registration metadata is not passed to the UI. The local `fleet.list` path adds worker state, last activity, provider, model, session ID, channels, and node. Both lists are already refreshed by the page. The expanded view uses these fields without adding one network request per open row. + +The one-line summary uses a short agent summary if a future authorized core contract supplies one, then recent outgoing text if supplied, then the existing description. Spawn-instruction boilerplate is suppressed. When no task text is available, the page says so instead of presenting a brief path as current activity. Idle workers show idle time when their last activity is known. + +Fleet workers keep their broker state as the subtitle. The core intentionally excludes fleet-owned sessions from `sessions.list`, while agent names can repeat across machines and a broker's display name need not equal the machine part of a Relaycast address. The desktop cannot safely join a fleet worker to a roster task until a per-worker detail contract supplies its canonical address or objective. + +Per-agent outgoing messages, PR mentions, transcript links, and terminal event streams are not present in `roster.list` or `fleet.list`. The desktop's `messages.history` is the signed-in person's one-to-one conversation with an endpoint, and `activity.list` describes deliveries to local agents; neither is a general history of what another agent sent. Agent Relay exposes workspace message search, while hosted shared sessions, RelayHistory, and `agent-relay node agent attach --json` hold other possible activity evidence. A follow-up core API should return a bounded, authorized per-agent view and capability links. It must enforce the signed-in person's access to private DMs and transcripts before the UI renders them. A separate short status with an update time or TTL should be set by the agent; the spawn task must not stand in for live status. + +Local session registrations already publish `cloud_user_id`, `cloud_workspace_id`, and `owner_hash` from the signed-in account. Fleet spawn publishes declared organization, project, workstream, role, and objective, but no owner identity. The desktop therefore leaves many fleet workers in **Other live agents**. Cloud node enrollment has a `createdBy` user ID, but the roster has no node ID and the desktop's fleet listing is local to the machine. [AgentWorkforce/relay#1895](https://github.com/AgentWorkforce/relay/issues/1895) tracks publishing the workspace-scoped owner identity from trusted enrollment and spawn paths. Ownership should be fixed at that source and published as the same owner metadata as local sessions. A future app fallback would require an authenticated node-to-person mapping supplied by the core; a matching host or workspace alone is insufficient proof of ownership. diff --git a/apps/desktop/docs/agent-driven-setup.md b/apps/desktop/docs/agent-driven-setup.md new file mode 100644 index 0000000000..e382e55d2a --- /dev/null +++ b/apps/desktop/docs/agent-driven-setup.md @@ -0,0 +1,424 @@ +# Agent-driven setup + +Agent Relay exposes setup through the same private per-user Unix socket that +registered sessions use for replies. A running Codex, Claude or Devin session may use +these routes before it has a Relaycast identity. The app authenticates the +caller from kernel peer credentials and process ancestry; it never trusts a +session id supplied in a request. + +## Headless Ubuntu installation + +The supported server path is a current Ubuntu LTS `.deb`, a systemd user +service, and lingering so delivery survives the SSH login that installed it. +Replace `x64` with `arm64` when appropriate: + +This path requires passwordless sudo or one human-approved sudo prompt for the +package install and `loginctl enable-linger`. Never capture or relay a sudo +password. + +```sh +release=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download +curl -fLO "$release/AgentRelay-Linux-x64.deb" +curl -fLO "$release/AgentRelay-Linux-x64.deb.sha256" +sha256sum --check AgentRelay-Linux-x64.deb.sha256 +sudo apt install ./AgentRelay-Linux-x64.deb +systemctl --user daemon-reload +systemctl --user enable agent-relay.service +systemctl --user restart agent-relay.service +sudo loginctl enable-linger "$USER" +systemctl --user is-active agent-relay.service +``` + +The service runs `/usr/lib/agent-relay/agent-relay-probe relay serve --headless`. +It imports no GTK module, +serves the session socket, supervises uploads, discovers Codex and Claude +processes inside tmux/SSH process trees, and delivers messages without a +display. Headless startup also removes the app-owned +`~/.config/autostart/agent-relay.desktop` entry left by an older GUI setup; +the systemd user unit is the sole startup owner and does not use the GUI-only +`--background` flag. Unrelated autostart entries are preserved. The tarball contains the same unit at +`usr/lib/systemd/user/agent-relay.service` for administrators who install a +relocatable tree themselves. + +Headless startup applies the agent-led defaults: Claude Code direct delivery, +automatic activation of every eligible interactive session, and upload mode +`selected`: nothing uploads until a session is chosen, as in the apps. A sign-in +through `/setup/sign-in` installs the uploader in that mode. The bootstrap never +changes an install's sharing mode, so it never widens what an install shares and +never narrows a mode its owner chose (`new` or `all` from Settings). A durable +one-time bootstrap also runs before the first `/setup/*` request on Linux and +macOS. Explicit changes made through those routes afterward, `POST +/setup/sharing` with `new` or `all` included, survive status reads and app +restarts. + +Provider subscriptions from a registered local session use the private socket +described below. They do not expose or require a workspace key. The separate +npm `agent-relay` CLI remains available to workspace operators and fleet agents +that already have a workspace key. + +The `.sha256` file is part of every release. Do not install an artifact whose +checksum fails. Alpine, RHEL, and old-glibc hosts are not the default package +path. + +## Scriptable macOS installation + +Download the release DMG and its `.sha256`, verify it, mount it, and copy the +signed app. Starting the app once creates the private socket; the setup API +does not require its window. +Copy the app before opening it. Registering the helper from a mounted DMG +records that volume as its parent; after ejection, launchd cannot start it. +The app refuses background registration outside `/Applications` or +`~/Applications` so this install order is enforced. + +```sh +case "$(uname -m)" in + arm64) relay_arch=arm64 ;; + x86_64) relay_arch=x64 ;; + *) printf 'Unsupported Mac architecture: %s\n' "$(uname -m)" >&2; exit 2 ;; +esac +release=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download +curl -fL "$release/AgentRelay-macOS-$relay_arch.dmg" -o AgentRelay.dmg +curl -fL "$release/AgentRelay-macOS-$relay_arch.dmg.sha256" -o AgentRelay.dmg.sha256 +sed "s#AgentRelay-macOS-$relay_arch.dmg#AgentRelay.dmg#" AgentRelay.dmg.sha256 | shasum -a 256 --check - +volume=$(hdiutil attach -nobrowse -readonly AgentRelay.dmg | awk '/\/Volumes\// {sub(/^.*\/Volumes\//,"/Volumes/"); print; exit}') +ditto "$volume/Agent Relay.app" "/Applications/Agent Relay.app" +hdiutil detach "$volume" +open -a "Agent Relay" +``` + +Release asset names may include a version; use the names and checksums listed +on the release when the stable aliases are unavailable. + +If the socket is still absent after 30 seconds, inspect the core job with +`launchctl print "gui/$(id -u)/com.agentrelay.desktop.core"`. A job showing +`job state = spawn failed`, `last exit code = 78: EX_CONFIG`, and +`program identifier = Contents/Helpers/Agent Relay Helper` has lost its app +bundle path in macOS Background Task Management. Repeated SMAppService +registration and toggling Login Items did not repair the affected record. Builds with +the fallback repair register a user LaunchAgent with an absolute path to the +bundled helper. Check `launchctl print "gui/$(id -u)/com.agentrelay.desktop.core.local"` +and wait for the socket again. Do not use `sfltool resetbtm`: it resets other +apps' background items too. + +## Finding the socket + +The app writes the absolute socket path, followed by a newline, to: + +```sh +socket=$(cat "$HOME/.agentworkforce/desktop/relay-socket") +``` + +The pointer and socket are mode `0600`; their parent directories are mode +`0700`. On Linux the normal socket is +`$XDG_RUNTIME_DIR/agent-relay/relay.sock`. Setup requests must run from inside +the Codex, Claude or Devin process tree they are configuring. A same-user process can +already read the app's private credential store, so pre-registration setup +does not widen the existing same-user trust boundary. Other users and an +unrelated same-user process are rejected. + +Claude Code records each running session in +`~/.claude/sessions/.json` (under `CLAUDE_CONFIG_DIR` when set), and the +Desktop identifies a Claude caller from that file. The file is read up to the +end of its first JSON value, so a complete record followed by stray bytes still +names its session. When the caller's nearest session is a running Claude Code +process whose file does not parse at all, every route answers HTTP 409 +`session_file_unreadable` with the file's path and the parse error. A file +counts as the running session's when one of its `..key` files +records that process's start time. Claude Code writes the file when a session +starts, so exiting the session and resuming it with `claude --resume` makes it +readable again; the Desktop never writes Claude Code's files. + +## Setup contract + +All responses use `{"ok":true,"data":…}` or +`{"ok":false,"error":{"code":…,"message":…}}`. + +| Request | Purpose | +| --- | --- | +| `GET /setup/status` | Sign-in, uploader, defaults, registration, webhook and integration state | +| `POST /setup/sign-in` | Reuse an eligible login or start device approval; optional `{"workspace":"UUID","acknowledge_uninspected_schedules":false}`. Browser-capable local clients can opt in with `flow:"browser"`. | +| `POST /setup/sharing` | `{"mode":"selected"}`, `new`, or `all` | +| `POST /setup/auto-activate` | `{"enabled":true}`; eligible interactive sessions are activated automatically, subject to the safety limits below | +| `POST /setup/direct-delivery` | `{"enabled":true}` sets Claude Code `crossSessionInbound` to `accept` (`false` removes it); `{"default":true}` applies the first-run default, which keeps a value the user already set and answers `{"direct_delivery","user_choice"}` | +| `POST /register` | Register only the calling session; optional name, description and `mcp` (`"agent-relay-sessions"` if the session has those tools, `"none"` if not) | +| `DELETE /register` | Remove only the calling session | +| `POST /webhooks` | Create the calling session's webhook | +| `DELETE /webhooks` | Revoke the calling session's webhook and private channel | +| `POST /integrations/subscribe` | Subscribe with provider and resource; a subscription the caller already holds answers `already_subscribed:true` | +| `DELETE /integrations/subscribe` | Remove that provider/resource subscription | + +## Subscribe this agent to one GitHub pull request + +Run this single command inside the registered Codex, Claude or Devin session that +should receive the events, giving it the address `gh pr create` printed or the +`owner/repo#number` shorthand: + +```sh +~/.local/bin/agent-relay-probe relay subscribe https://github.com/AgentWorkforce/skills/pull/120 +``` + +The command finds the socket through the pointer file, sends one request, and +prints the JSON answer; it exits non-zero when the Desktop refuses. It takes no +agent name: the Desktop identifies the calling session from the process, as it +does for `/register`. It uses the signed-in account and the calling session's +saved relay identity; no workspace key or provider token is put in the agent's +environment, argv, or request. If GitHub is not connected, finish the one-time +connection page the Desktop opens; the command waits up to seven minutes for +that, longer than the five the Desktop allows. A production Desktop creates +`~/.local/bin/agent-relay-probe` when it starts and keeps it at its own version +on Linux and macOS; a development build leaves that copy alone, so run its own +bundled probe with `--dev`, which reads Agent Relay Dev's pointer +(`relay-socket.dev`). The command never falls back from one build's pointer to +the other's. + +`agent-relay-probe relay` commands wait up to 60 seconds for the Desktop to +answer. When it does not answer in time the command fails with "Agent Relay +did not answer METHOD ROUTE within 60 seconds. This is not a setup problem; +the core is still running. Try again shortly." The route is named without its +query. Retry the same command; do not re-run setup. + +Repeating the command is safe. When the session already holds that +subscription the answer is a success carrying `"already_subscribed":true`, and +no cloud request is made, so an agent can run it after every pull request it +opens without first checking state. A subscription still being made or cleaned +up is not reported as held; the request goes through and can return `refused` +or `busy` until it settles. + +`agent-relay-probe relay subscribe --remove ` ends it. Either +spelling of the pull request ends a subscription made with the other. + +Only a registered session can subscribe; an unregistered one receives +`not_a_relay_session` with "Register this session before subscribing it", and +`POST /register` first fixes that. The command accepts exactly one numbered +pull request: a whole repository, a raw path glob, another provider, or an +address that is not `https://github.com///pull/` is +rejected before the socket is opened. + +The equivalent raw request, for clients that cannot run the probe, is one +`POST` with the same resource. On macOS, a Codex session under the shared app +server must use the probe command: the system `curl` hides the session's +identity and is answered `not_a_relay_session`. + +```sh +curl -fsS --unix-socket "$(cat "$HOME/.agentworkforce/desktop/relay-socket")" \ + -H 'Content-Type: application/json' \ + --data '{"provider":"github","resource":"AgentWorkforce/skills#120"}' \ + http://relay/integrations/subscribe +``` + +A session started by the Agent Relay fleet broker already answers to its fleet +name and cannot register here: `/register` answers `not_a_relay_session` with +"This session is already managed by this computer's relay", and the subscribe +command asks it to register. It subscribes with the workspace CLI instead, which needs +`agent-relay` 13.0.0 or newer: `agent-relay integration subscribe github +--resource '/github/repos///pulls//**' --to @ +--no-input`. Older CLIs let the worker's `RELAY_AGENT_TOKEN` displace the +workspace key and fail with `Workspace key required (rk_live_...)`; prefix the +command with `env -u RELAY_AGENT_TOKEN` there. + +One numeric PR subscription receives new review comments, submitted reviews, +issue comments on the PR, and check-run events. Relayfile correlates those +events by the PR's stable resource reference even though their stored paths can +be under `reviews/`, `issues/`, `comments/`, or `checks/` rather than the pull +directory. The injected session text has this shape: + +```text +[via Agent Relay — from @__relay_webhook__ to @connect-skill · message m_… · ref …] + +Github pull_request_review_comment.created + +Relayfile path: /github/repos/AgentWorkforce/skills/comments/… +``` + +`pull_request.closed` is injected before the binding is removed automatically; +GitHub uses that action for both closed and merged pull requests. Failed or +interrupted cleanup is retried by the Desktop, and an unfinished binding for a +different resource does not block this request. + +`GET /setup/status` answers at once from the last known state; it never +waits on Relaycast, Cloud, or the uploader, and it never runs session +discovery: whether interactive sessions still await activation is checked in +the background and the last answer is reported. Work still in progress reads +as pending: + +| Field | Values | +| --- | --- | +| `relaycast` | Relaycast's verification of the workspace key: `pending` while it is being asked, then `verified`, `rejected`, or `unreachable` (asked again on the next refresh); `null` while no workspace key is found | +| `defaults` | `applied` once the agent-led defaults hold with a workspace signed in or an explicit setup choice was made, else `pending` | +| `uploader` | The last uploader check (below); `{"running":false,"healthy":false,"pending":true}` before the first check answers | +| `unreadable_session_files` | Running Claude Code sessions whose `.json` does not parse, each `{"path","pid","error"}`; such a session is invisible to discovery and its commands receive `session_file_unreadable`. Empty when every file reads | + +`uploader` holds: + +| Field | Values | +| --- | --- | +| `running`, `paused` | Whether the collector runs, and whether the person paused uploads | +| `stage` | `not_running`, `paused`, `needs_attention`, `conflict`, `offline`, `preparing`, `uploading`, or `up_to_date` | +| `healthy` | `running`, no delivery failure, and a stage other than `needs_attention`, `conflict`, `offline`, or `not_running` | +| `stalled` | Queued records have had no acknowledgement for five minutes, whatever the failure, while the collector runs unpaused and is not being replaced or upgrading; the stage is then `needs_attention`, except that a receiver conflict keeps `conflict` and a failed cycle with no delivery failure keeps `offline` | +| `failure_code` | Why delivery fails: `transient`, `rate_limited`, `authentication_required`, `permission_denied`, `invalid_payload`, `receiver_rejected`, `unsupported_evidence`, `mapping_version_mismatch`, or `delivery_conflict`; `null` when it does not | +| `renewal_failure` | With `authentication_required`, why the upload sign-in could not be renewed from Agent Relay's own: `no_cloud_session`, `cloud_refused`, `cloud_unreachable`, or `wrong_destination`; else `null` | +| `pending_records` | Records queued and not yet acknowledged | +| `last_acknowledged_ms` | When an upload was last acknowledged (Unix ms); `null` before the first | + +An upload blocked on `authentication_required` renews by itself: the core +refreshes the app's Cloud sign-in while the block lasts (backing off from one +supervision period to 32 while refresh fails), and the collector renews the +upload sign-in from it on its next pass. + +Each status read starts a fresh uploader check in the background, so polling +status keeps `uploader` current. `POST /setup/auto-activate` with +`enabled:true` reports the activation it starts, so it waits on Relaycast +registration; status and the other setup routes do not wait behind it. +If the request times out, `auto_activate:true` in status confirms only that +the preference was saved. Check the session's `registered` state and +`defaults_error` before treating activation as complete. + +After the first agent-led setup request, `/setup/status` reports +`sharing_mode:"selected"` (or the mode the install already had), +`auto_activate:true`, and `direct_delivery:true`. +The direct-delivery default, like the apps' first-run setup, sets `accept` +only when the user's own `~/.claude/settings.json` does not name +`crossSessionInbound`. A key that is present, with any value (`hold`, +`refuse`), is the user's explicit choice and is kept: status then reports +`direct_delivery:false` with `direct_delivery_user_choice:"hold"` and no +`defaults_error`. An absent key reads as unset. **Ask again** and +`{"enabled":false}` write `"hold"`, so they are kept as choices. Only an explicit +`{"enabled":true}` overrides a kept choice; ask the human first. +The calling session's effective delivery state remains available at +`session.direct_delivery`. If organization-managed Claude settings forbid +`crossSessionInbound:"accept"`, status reports `direct_delivery:false` and a +`defaults_error` with code `managed_policy`; an explicit direct-delivery call +returns HTTP 409 with the same code. The app never edits the managed file. + +Auto-activation is deliberately narrower than session discovery. It admits +interactive Codex/Claude sessions, live Devin CLI sessions, and open Grok sessions +whose leader is reachable, excluding Agent SDK, `claude -p`, structured +pipe-driven Claude runs, `codex exec`, and sessions owned by the fleet broker. +Muse sessions are added only when Agent Relay starts and owns them. Sessions +beneath the system temporary roots are excluded by default. Set the colon-separated +`AGENT_RELAY_AUTO_ACTIVATE_EXCLUDED_DIRS` environment variable on the app or +service to replace those roots; an empty value disables only the directory +filter. At most five sessions are registered per minute and twenty +auto-activated identities may be active. An auto-activated identity whose +eligible process ends is removed after a five-minute grace period. A manually +enabled or self-registered identity whose session ends keeps its name and its +queued messages, but is neither listed nor heartbeated as present until the +session runs again. + +`POST /setup/sign-in` returns `pending_approval` plus +`verification_url`, `verification_url_complete`, `user_code`, `expires_at`, +and `interval`. Existing callers retain device approval, which works from +another computer or phone. The secret device code stays in the app. + +Local setup clients can pass `{"flow":"browser"}` to use the desktop's normal +sign-in: reuse an eligible saved session, otherwise open +`verification_url_complete` on the same computer. Cloud reuses the browser's +signed-in session or opens Google when needed, then returns to the app through +its PKCE loopback handoff. Open this URL even when `user_code` is null; +`interval` is also null in the browser flow. There is no code confirmation or +separate device approval screen. Remote/headless clients should omit `flow` +or explicitly pass `{"flow":"device"}`. Poll `GET /setup/status` until it +reports `signed_in`, `expired`, `denied`, or `error`. +Browser setup starts with `preparing` while it checks and refreshes existing +logins in the background. If browser sign-in is needed, it moves to +`pending_approval` during polling. Status then includes the same public +approval fields and `sign_in_phase`; open `verification_url_complete` to continue. + +When an eligible same-origin CLI or saved app login is found, status reports +`reused_login:true`; no device code is issued. Workspace connection and the +required new-session baseline continue in the background. Cancellation remains +available while Cloud refreshes an existing login. +A refresh already submitted to Cloud finishes saving its rotated credentials +after cancellation; the cancelled setup does not continue connecting. + +While sign-in is `preparing`, the sign-in response includes `phase`, `message`, +`poll_after_seconds`, and, once the local scan starts, `progress`. Status exposes +the same information as `sign_in_phase`, `sign_in_message`, +`poll_after_seconds`, and `sign_in_progress`. `checking_existing_login` means +the app is looking for a reusable same-origin CLI or saved app login without +yet claiming that one exists; `reusing_existing_login` means that login is +being reused; `waiting_for_browser` means Google sign-in is open; +`waiting_for_device_approval` means the explicit device fallback is waiting +for approval; `connecting_workspace` means sign-in succeeded; and +`preparing_local_sessions` means the probe is +scanning existing Codex and Claude history to establish the automatic-new-upload +baseline. Progress includes the source, processed and total file counts when +known, and locally captured session count. This scan can take about a minute on +a large history and cannot be skipped without risking uploads of pre-existing +sessions. Poll at `poll_after_seconds` until `signed_in`. + +Before sign-in, the shared probe also inspects older managed upload schedules. +An active managed schedule blocks setup. A missing optional `crontab` program +or missing cron spool counts as no legacy schedule; command execution, +permission, and read failures remain an unknown inspection and require the +documented explicit acknowledgement after a manual check. + +Completed status and the completed sign-in response include the signed-in +workspace id and name. Status also names the signed-in person as +`account: {"name", "email"}`, taken from `account.status`; it is `null` while +no one is known on this computer. Multi-server rollouts should always pass +their existing workspace UUID explicitly so every server joins the same +workspace. + +Default registration names include a 128-bit session-derived suffix. Thus +Codex and Claude sessions sharing one host and checkout still receive unique, +unambiguous `name@direct` addresses. A successful `POST /register` returns the +registered `name` and `address`, the caller-derived `session_id`, the effective +`direct_delivery` state, and whether the registration already existed. These +session fields match the values under `session` in `/setup/status`. + +No Cloud bearer, workspace key, or agent token is returned by any route or put +in a session. A newly created webhook is the single exception: its secret is +returned once, in the response to the identified calling session. Later calls +return the webhook id and URL with a null secret. + +Integration setup uses the signed-in session to mint a short-lived, +workspace-bound Relayfile credential and subscribes through relayfile-cloud +directly. The relay core does this on both platforms; the session socket's +`/integrations/subscribe` is the core's route on macOS and Linux alike. An +operator's own Relayfile daemon, active workspace, and polling consumers are +never touched. No Relaycast, agent, or Relayfile credential appears in argv, +route output, or logs. The requested provider must already be connected in +the selected workspace. + +### Resources for other providers + +`POST /integrations/subscribe` reads a GitHub `resource` as `owner/repo`, +`owner/repo#number`, a pull request's github.com address, or a `/github/…` +path. Linear, Slack and Jira take only a raw path inside the provider's own +Relayfile tree, `//…`: native names for them cannot be resolved +without guessing. A path into another provider's tree, a path containing +`..`, or any other spelling is refused. One example each: + +| Provider | `resource` | +| --- | --- | +| Linear | `/linear/issues/ENG-123/**` | +| Slack | `/slack/channels/C0123456789/**` | +| Jira | `/jira/issues/ENG-42/**` | + +```sh +curl -fsS --unix-socket "$(cat "$HOME/.agentworkforce/desktop/relay-socket")" \ + -H 'Content-Type: application/json' \ + --data '{"provider":"linear","resource":"/linear/issues/ENG-123/**"}' \ + http://relay/integrations/subscribe +``` + +## REST polling alongside direct injection + +Direct injection is additive to a workspace-level REST consumer. The desktop +acknowledges only the delivery queue belonging to the registered session +identity; it does not mark workspace messages read or mutate another +consumer's cursor. A polling integration should use its own identity/cursor, +not the injectable agent token's `/v1/deliveries` queue. + +Every injected attribution header includes the canonical Relaycast message id +and a short hash, for example: + +```text +[via Agent Relay — from @reviewer to @codex-api · message msg_123 · ref a1b2c3d4e5f6] +``` + +When a polling MCP and injection can both surface the same workspace message, +deduplicate on that full message id before acting. The short `ref` remains the +reply-correlation marker; it is not the deduplication key. diff --git a/apps/desktop/docs/agent-relay-probe.md b/apps/desktop/docs/agent-relay-probe.md new file mode 100644 index 0000000000..14cbba14a1 --- /dev/null +++ b/apps/desktop/docs/agent-relay-probe.md @@ -0,0 +1,830 @@ +# Native Agent Relay Probe + +`agent-relay-probe` is the native collector for Cloud's new Teams onboarding flow. +It signs a computer in, obtains workspace-scoped RelayHistory credentials, captures +local coding sessions and runs durable delivery in the background. It needs no +Node.js, npm, NAPI addon or separately installed Agent Relay CLI. + +## Source ownership + +The probe lives in [`probe/`](../probe/) and ships only inside the desktop app. +It reads local history through the published `ai-hist` crate and owns its upload +queue, worker, sharing policy, sign-in and transport. Where sessions live and +how they are parsed stay in `ai-hist`. + +`--version`, `status --json`'s `probe_version` and the `cli_version` it reports +are the desktop release the binary ships in: `scripts/build-probe.sh` stamps +`AGENT_RELAY_PROBE_VERSION` at build time. A plain `cargo build` reports the +crate version. + +## Build and run + +```sh +cargo build --manifest-path probe/Cargo.toml --release --bin agent-relay-probe --locked +probe/target/release/agent-relay-probe --help +``` + +For local Cloud development, run in your own terminal: + +```sh +./agent-relay-probe cloud install --site-url http://127.0.0.1:3100 --workspace WORKSPACE_ID --account USER_ID +``` + +The account argument asserts the expected signed-in user; it does not grant +access. The workspace bridge authorizes membership and issues the scoped session. +Without a workspace argument, the probe uses Cloud's current workspace. + +Setup prints the existing Cloud device-approval URL, waits for browser approval, +then offers selected-session uploads as the default. Pressing Enter keeps all +sessions local until selected; automatic uploads of new sessions or all history +remain explicit choices. `--selected-sessions-only`, `--new-sessions-only`, and +`--include-existing` make the choice explicit in a noninteractive terminal. +`--force-login` bypasses eligible existing CLI sign-in. +The shared implementation reuses an unexpired official CLI credential only for +the exact selected Cloud URL, and never writes or refreshes that CLI's auth +file; `CLOUD_API_ACCESS_TOKEN` overrides both. Setup always prints the approval +URL to stdout rather than requiring a terminal on stdin, because the composed +local harness drives it through pipes. + +New-only mode snapshots every session identity the store holds evidence for — +catalog rows, prompts, events and every other evidence table, including +sessions not yet catalogued — before creating its delivery generation. The +baseline is stored as durable delivery exclusions, which +the coordinator rechecks whenever a batch is prepared, claimed and dispatched, +so it does not grow the delivery configuration with local history. A setup +abandoned between that snapshot and its delivery generation leaves the baseline +behind; a later setup that chooses to share existing sessions withdraws it, so +the choice that takes effect is always the one just made. Sessions +discovered later are treated as new; this is a local capture baseline, not a +guarantee about the actual creation time of files added later. The saved sharing +choice is immutable on reconnect: a different explicit choice is refused, and +`--keep-saved-sharing` makes the choice given apply to a new install only, so a +reconnect keeps the saved one. + +Default setup starts a detached process. `--foreground` keeps it in the terminal; +`--once` captures and delivers one bounded cycle. To inspect or stop it: + +```sh +~/.local/bin/agent-relay-probe status --site-url http://127.0.0.1:3100 --workspace WORKSPACE_ID --account USER_ID +~/.local/bin/agent-relay-probe stop --site-url http://127.0.0.1:3100 --workspace WORKSPACE_ID --account USER_ID +``` + +The executable must already be at that location to use these example paths. +There is no launchd/systemd installation or automatic restart after reboot yet. +Run setup again after restarting the machine. It preserves the existing queue. + +### Hosted teardown flush + +Cloud sandbox teardown invokes the hidden `history flush` command with a +push-only session on stdin (or in a session file), a private state directory, +and an absolute wall-clock budget. The state path should normally not exist so +the probe can create it as `0700`. If Cloud creates it first, it must already +be an empty same-owner `0700` directory; a conventional `0755` temporary +directory is deliberately refused rather than silently made private after +another process could have observed it. + +stdout is exactly one JSON summary line on success, refusal, usage error, or +deadline. The command exits at the deadline plus a 250 ms watchdog grace even +if the HTTP library is stuck below its request timeout (for example in DNS). +The pushed bearer is fixed to that invocation: a 401 is final and never causes +the probe to read, refresh, or overwrite a stored desktop credential. + +The state directory may be retained to resume the same durable queue. If Cloud +instead re-flushes the same provider history into a fresh state directory, the +new store has a new origin and origin-bound revision IDs, while logical +`record_id`, source `revision`, and payload remain stable. RelayHistory stores +both origin-scoped deliveries; its downstream projection uses the stable +logical identity to deduplicate what readers see. + +## Indexing order + +Both desktop apps start new installs with selected-session uploads. Discovery +lists metadata newest first without fully indexing or uploading unselected +sessions. Reconnecting preserves the workspace's saved sharing choice. + +For automatic-upload modes, the probe first discovers the catalog and fully +indexes uncaptured sessions in descending last-activity order across providers +and shared Claude folders: the last 24 hours, the rest of the week, then older +history. Completed sessions are skipped if that backfill is interrupted. A +regular provider sweep follows to capture sessionless evidence and retry any +sessions that could not be hydrated individually. Providers without usable +catalog metadata or targeted hydration still rely on that sweep. + +## State and delivery + +Uploads come from `ai-hist`'s change feed. An all-sources job drains every +change of its kinds from the feed's start, which is its bootstrap, and then +follows the feed from where it stopped. A selected-sessions job follows the +feed for its members only; each member added is backfilled with a drain of +its own session from the feed's start to the head at the time it was added. +Nothing is copied out of the evidence tables until a batch is prepared, so +there is no upload journal, retention cap or compaction of pending history. +Selected-session delivery runs before targeted hydration and independently of +unrelated provider discovery. + +A record keeps the identity the receiver already holds: `record_id` is the +SHA-256 of the record's key (its kind and the stored values of its table's +identity columns) as compact JSON, and `payload` is the stored row. Its +`revision` is the feed revision plus the install's `revision_base`, which stays +above every revision the install has issued, so the receiver always replaces +an older copy. A replaced database, whose feed counts from zero again, moves +the base above the highest revision issued and restarts every job. A new +epoch of the same feed, as `ai-hist` draws when it retires a kind without +renumbering anything, keeps every job where it was: the install adopts the +epoch once `SessionStore::resumes_from` confirms that the highest position its +upload tables hold still names the same revision, asked of each kind's stream +on its own, since the probe stops draining a retired kind itself. + +A batch holds at most 100 records, the protocol's limit, and at most 2 MiB +less 64 KiB of JSON, so with its request envelope it stays under the +receiver's 2 MiB request cap. A server that publishes its limits in its +receipts (`limits: {maxRecords, maxRequestBytes}`) raises the record cap for +the batches prepared after that receipt, up to 500, and can lower the byte +cap; a receipt without them returns to 100, and a batch already prepared is +sent as it is (see the probe README); its body as retained in the queue, which +escaping can double, may take up to 4 MiB plus 64 KiB. A batch is sized as it +fills, one record at a time. A delivery generation created by an older build +with that build's defaults (1 MiB batches) runs with these; its stored +configuration is left as written. + +A record too large for a batch on its own, such as a tool result that printed +megabytes, is shortened to fit rather than stopping the job. Its longest text +columns are cut, longest first, and end with `[truncated by Agent Relay to +fit the upload limit]`. A column holding a JSON document is cut inside, at its +longest strings, so it stays valid JSON. A row with a `payload_truncated` +column has it set, while `payload_bytes` and `payload_hash` keep describing +the original. The record keeps its `record_id` and `revision`. A record whose +bulk is not in text it can cut is suppressed like withheld evidence, and the +job moves past it. A batch that cannot be prepared for any other reason is +reported as `upload_preparation_failed`, not as an offline sync. + +Before a request is persisted or sent, every direct payload column whose name +ends in `_json` is decoded as a JSON document. U+0000 in its keys or values and +unpaired UTF-16 surrogate escapes are replaced with U+FFFD, then the document +is encoded again. Invalid JSON text is preserved as an opaque JSON string, so +the receiver can store it without dropping its readable content. If repairing +a legacy prepared body makes it exceed the wire limit, the opaque value is cut +with the same visible truncation marker (and `payload_truncated` is set when +that row has the flag). Ordinary text +columns do not interpret typed `\u0000` text; only an actual U+0000 is replaced. +Clean request bodies remain byte-for-byte identical. This wire check also +repairs prepared bodies persisted by older builds, without changing record IDs, +revisions, or the stored batch. The worker stores each body it prepares with +the build that prepared it (version and Git revision); a body this build +prepared, whose SHA-256 still matches, is already in wire form and is sent +without being decoded and checked again. Every other body is checked before +it is sent. + +Every sharing mode uploads every evidence kind: prompts, session events, tool +calls, file edits, the session catalog, presences, relationships, commit links, +connector observations and their evidence, and session markers. Trajectories +are no longer uploaded: a job stored by an earlier build that still lists them +drops the kind when it is loaded, and a batch it had already prepared with +trajectory records is still sent. +What a mode withholds is consent, not kinds. `all` shares every session that +is not excluded, and prompts logged with no session. `new` withholds the +sessions its baseline excluded and every prompt with no session, since no +exclusion can prove when such a prompt began. `selected` shares only member +sessions. A relationship discloses both its sessions, so it leaves only when +its parent is shared and its child is shared too or unlinked, checked when a +batch is prepared and again before dispatch. Under `selected`, a member added +after its parent receives the edges naming it through its own backfill. + +A build whose `ai-hist` reads a newer schema than local history holds +migrates it only in the collector, the process that holds the collector lock. +On a large store the migration runs for minutes. The desktop runs `status`, +`sessions list`, pause, resume and compaction under timeouts, and a migration +they start and then get killed out of rolls back while holding off the +collector, so none of them migrates: `status` answers with `last_cycle.error_class` +`upgrading`, and the others refuse with the same safe message until the +collector has finished. A migration that fails after readiness (a full disk, +a damaged store) does not end the run: it reports the classified cause as that +run's `last_cycle`, which `status` then shows in place of `upgrading`, and +retries, withdrawing that report once the store opens. When `status` cannot +read the store at all (it cannot open it, or a table is damaged), it answers +with the profile and that report alone rather than failing. `status` and `sessions list` also ask the change feed, +since a store that predates only the feed opens and lists sessions as before. +The collector records readiness before it opens the +store, so setup's startup wait cannot kill it mid-migration either. It also +records the build that launched it in `runtime.json`; `status` reports +`collector_outdated` when that is another build, and `start` stops such a +collector and launches the bundled one, which the desktop does on its next +status read after an update. Replacement uses the collector's +generation-scoped cooperative stop and waits through a long capture, subject +to a ten-minute bound rather than the interactive stop command's 45-second +deadline. If that bound expires, the command hands ownership to a detached +helper; it keeps the generation-scoped stop, waits for the old collector to +release its lock, and starts the bundled build. This covers a stop already +consumed by a long capture instead of leaving the install stopped. While a live +replacement or its helper owns the desktop control lock, status reports +`collector_replacing`; desktop apps show **Replacing the uploader** and do not +start a competing replacement. Sharing changes wait without a client deadline +because the same command that stops the collector must apply the change and +restart it. + +An install whose uploads ran on the former `export` journal is rebuilt the +first time its collector opens it, in one transaction: it keeps `origin_id` +and records `revision_base` from the journal's sequence, drops the journal's +capture triggers, tables and indexes and every unsent batch, and restarts each +job from the feed's start and each member's backfill, keeping sharing mode, +pause state, members and exclusions, and moves each job's stored selection to +every kind. Only the collector lock's holder +rebuilds; pause, resume and compaction never do, so they are safe beside an +older collector still running after an upgrade. + +The desktop status `last_cycle` includes an optional, allowlisted `error_class` +and a safe message for database corruption, disk-space, contention, and +permission failures. These also cover failures before capture starts. No raw +SQLite/provider error, transcript, credential, or path is included. Capture +and delivery are reported separately in `last_cycle.capture` and +`last_cycle.delivery`, each with its own `ok`, `error_class` and message: a +pass that only delivered reports its own delivery outcome and leaves the last +capture verdict standing, so a condition the capture cycle measured stays +visible between those cycles. Each report names the collector run that wrote +it (`startup_id`), and a run carries forward only its own capture verdict: +`status` shows `last_cycle` only when it is the report of the run +`runtime.json` names, so a restarted collector never inherits the previous +run's verdict while its first capture pass, minutes long on a large history, +is still running. Capture runs on its own thread from the start of the run +(see [Live capture](#live-capture)). A run reports as soon as it has opened +the store, before capture or delivery has observed anything, so a run stopped +during its first sweep still leaves a report of its own; each delivery pass +then reports the capture thread's latest verdict. The top-level `ok`, `error_class` and `message` +are the effective verdict — the capture fault when there is one, otherwise the +delivery fault. Database corruption requires separate recovery from a +preserved copy; the collector does not delete or recreate a damaged queue +automatically. + +A pass that drained also reports `last_cycle.delivery_metrics`, numbers only: +`drains`, `drain_ms`, `batches_sent`, `bytes_sent`, `batches_acknowledged`, +`records_acknowledged`, `batches_per_second`, `records_per_second`, +`send_ms_total`, `send_ms_mean` and `send_ms_max` (time inside the request, +per batch), `queue_ms_max` (the longest a batch waited from preparation to +acknowledgment), `event_lag_ms` (how long before its acknowledgment the +newest delivered record was written, by the record's own `ts_ms` or +`timestamp_ms`: end-to-end lag, from the agent writing a line to the +receiver acknowledging it, once the job keeps up, now that capture follows +filesystem events; the age of the history reached during a backfill), and +the queue the pass left, `pending_records`, +`unqueued_changes` (counted up to 256) and `oldest_pending_age_ms`. `status +--json` also reports `delivery.oldest_pending_age_ms`, how long the oldest +unacknowledged batch has waited. Pipelined delivery adds `in_flight_limit` +(the most batches a pass may keep in flight, 4), `max_in_flight` (the most it +did) and `batches_retried` (batches claimed again after an earlier attempt +that did not acknowledge them: a retry, or a resumption after a stop). + +Every pass report also carries `last_cycle.capture_metrics`, the capture +thread's counts since the run started, numbers and fixed labels only: `mode` +(`watch`, `timer` or `members`), `driver` (`fs-events`, `polling`, `timer`, +`member-events` or `member-poll`), `fallback_reason` (see [Live capture](#live-capture)), +`watching_since_ms` (when the watcher attached), +`ticks`, `ticks_by_trigger` (`fs-event`, `poll`), +`ticks_swept`, `ticks_unchanged`, `ticks_contended`, `ticks_failed`, +`ticks_waking_delivery`, `sessions_changed`, `sweeps` with `sweep_ms_last` +and `sweep_ms_max` (the sweeps the thread runs itself: the first, the forced +catch-up once the watcher is attached, and each one of the timer fallback), +`tick_ms_last` and `tick_ms_max` (how long the watch's own sweeps took), +`ticks_paced` and `duty_wait_ms_total` (the event sweeps that waited past +their debounce for the pace, and how long they waited in all), `capture_lag_ms` and `capture_lag_ms_max` (from the +modification time of the newest transcript a tick captured to that tick's +report: debounce, pace, sweep and hand-off together), selected mode's `hydrations` +and `member_polls_changed` (the rounds that hydrated a member), and `last_capture_ms`. +A tick is one sweep of the watch: `fs-event` for an event's, `poll` for the +backstop's or the polling driver's. + +Each `(site origin, user, workspace)` has an independent SHA-256-named directory +under `~/.agentworkforce/probe/`. It contains an SDK-owned `history.db`, persisted +selection/job metadata, scoped RelayHistory auth, a log, a runtime record and a +collector lock. Directories are mode 0700; credentials/config/logs are mode 0600. +On macOS and Linux, `collector.log` stays in this private install directory so +diagnostics survive restarts. It rotates at 10 MiB, retaining three older files +(`collector.log.1` through `.3`, newest first). A dedicated thread checks once a +second, including during long capture/delivery passes and shutdown, and moves +both output streams to the fresh log. Retention is approximately 40 MiB per +install; a file can exceed 10 MiB by the output written between checks. Rotation +is best effort when the filesystem is unavailable. A temporary directory would +not provide this retention policy and could lose diagnostics on reboot. +No Cloud bearer credential is persisted by the probe. Tokens are never command +arguments, and provider errors, response bodies and session content are not logged. +The device approval URL is intentionally displayed in the interactive terminal. + +Each pass delivers through the probe-owned Rust worker, with up to four +batches of the job in flight: the next batch is prepared, persisted and +validated while the others are on the wire. A pass is bounded by wall time +(15 s), so it moves as many batches as the destination accepts in that window; +while the active job has queued batches, unexamined feed changes or an +unfinished backfill it can attempt now, and the pass acknowledged some, the +next pass starts at once, so a backlog drains back to back. A pass that moved +nothing while that backlog remains (it failed, or the destination accepted +nothing) is followed 2 s later, and passes run 20 s apart once the job is +caught up, paused, or waiting out a retry deadline, unless the capture thread +wakes the loop first: a capture tick that wrote starts the next pass at once. +A pass reads the job's +status once for its own checks, and the queue its last drain left decides the +next pass's gap, so passes do not each re-read the change feed. Capture runs on +its own thread and never waits for delivery, and delivery never waits for a +capture sweep: a backlog only means the drain is busy, and new local activity +is captured and queued behind it as it happens. Stop requests are polled +between batches. Consecutive batches reuse +kept-alive HTTP connections and the stored session, which is read again near +its expiry or after a failed send, and a drain opens local history once +rather than per batch. The job's acknowledged position only moves through a +contiguous run of acknowledged batches: a batch that fails while later ones +are acknowledged holds the position back, the later ones stay delivered, and +the failed one alone is retried after its backoff (see the probe README). +A selected-sessions job pipelines the same way: its member backfills share +the job's feed end, so the acknowledged position reaches that end only once +every batch ending there resolves. The relayhistory-cloud +server projects a workspace's batches one at a time, so concurrency mainly +hides network latency and the probe's own preparation; throughput is bounded +by the server's time per batch. The worker owns +immutable batches, leases and their keepalive, prepared-byte persistence, the +eligibility recheck immediately before dispatch, retry/backoff, acknowledgment +and receipt compaction; the receiver owns only the consent and destination guards and +the transport, so progress advances only on a validated receipt. Mapping/account +mismatches block delivery. A failed attempt retains unacknowledged data and the +job waits out its own backoff, which is normal operation rather than a fault. +Transport credentials refresh through the existing RelayHistory auth +implementation. The stored `rth_*` pair is only a cache of what the Cloud +workspace bridge issued: when it dies (a revoked, expired or lost one-time +refresh token), the job blocks on `authentication_required`, and the collector +then asks the workspace bridge for a new session with Agent Relay's own sign-in +— `CLOUD_API_ACCESS_TOKEN`, else the official CLI's credential for the same +Cloud, else the desktop app's kept `cloudSession`, each with more than a minute +left. It asks Cloud at most once every five minutes; finding no sign-in asks +nothing, so it is checked on every pass. It never starts a device login in +the background, and it adopts the new session only when it names the same +history stage, organization and workspace; on success the job resumes with its +queue intact. Otherwise it stays blocked and `status --json` keeps reporting +`authentication_required` with `last_acknowledged_ms`, plus +`delivery.renewal_failure` — `no_cloud_session`, `cloud_refused`, +`cloud_unreachable` or `wrong_destination`, kept in the install's +`renewal.json` — and a fixed sentence naming that cause in `delivery.failure`. + +A receiver HTTP 400 or 422 is treated as a possible record-content rejection. +For a multi-record batch the adapter sends deterministic halves, within a +32-request ceiling. It suppresses a rejected singleton only after a sibling +batch has received a complete durable receipt. Rejected siblings are both +searched, so several bad records can be isolated. Accepted IDs, proven skips, +provisional singleton rejections, and the exact unfinished search frontier are +saved separately on the immutable pending batch whenever the request or time +ceiling interrupts that search; the next attempt resumes those subsets instead +of repeating the same traversal, guaranteeing progress across restarts even +when no accepted sibling or singleton was reached yet. Provisional rejections +never increment the skipped count or advance the cursor. A receiver-wide +rejection that accepts nothing, or an original singleton with no accepted +sibling, remains blocked rather than becoming silent record loss; an explicit +or new-build retry clears provisional rejections so a corrected receiver can +try every record again. Other 4xx responses, +authentication and permission failures, rate limits, oversized requests, +transport errors and 5xx responses are never isolated. Split batch IDs are +derived from the immutable parent ID and exact revision IDs and rely on the +receiver contract's revision idempotency. Receiver-declared +`unsupported_revision_ids` retain their protocol meaning and block for a later +receiver upgrade. All locally omitted records still increment the internal +`suppressed_records` accounting counter, while only receiver-isolated records +increment `skipped_records`; desktop status uses the latter so consent and +selection exclusions are never described as receiver skips. Logs include only +the HTTP status, a validated receiver error code, correlation hashes and counts +— never response bodies or record content. + +An HTTP 409 is recoverable only when `error.code` is exactly +`delivery_conflict` and the receiver supplies the protocol's complete typed +identity and digest detail. The probe compares every reported record with the +exact persisted request body. It atomically archives the attempted batch, +quarantines only verified conflicting revisions, and queues the remaining +records under a deterministic new batch ID. A batch-ID conflict re-keys all +records without quarantining any. Revisions are never raised to defeat the +receiver's fence. The transition, prepared child body, and quarantine evidence +survive a restart; later records keep draining. Missing or mismatched details +block with `delivery_conflict` rather than silently skipping data. Progress +reports the quarantine count and a delivery-conflict phase, and +`agent-relay-probe conflicts` lists affected record and revision IDs without +printing content. A corrected source row must first receive a new change-feed +revision. `status --json` reports `delivery.quarantined_records`; keep +`history.db` when asking support to inspect the archived attempt. + +Each new probe version retries a previously blocked job once, before setup +delivery or background collection begins. Later starts of that version preserve +its blocked verdicts. SQLite retains every `(job, build)` retry record and commits +it atomically with the retry, so detached collectors, restarts and an A→B→A +version rollback cannot grant a second attempt. The former `retry_build` column +is imported into this history and maintained for older probes. File markers are +imported only when `verdict-retry.json` explicitly names the same job; unscoped +markers cannot safely identify a generation and are ignored. New jobs start +their own retry history. Failure to save the compatibility file does not stop +collection. If the database transaction fails, automatic recovery is deferred +without committing either the retry or its build record. Paused and cancelled +jobs remain unchanged. + +Local capture progress is written to private `progress.json` and emitted as +`event: "progress"` on the desktop install JSON stream every three seconds. +The count of captured sessions walks the whole catalog, so a report reuses a +count up to 30 seconds old, and the end of a capture pass counts afresh; while +a backlog's bootstrap is still running, a report does not scan the change feed +for unqueued changes, which cannot change what it shows. +Source names, files processed/total, and sessions captured never leave the Mac. +Cloud heartbeats contain only delivery progress for permitted records; the legacy +capture fields are empty/zero for wire compatibility, and capture-only monitors +never send heartbeats. Changed delivery progress reports at most every three +seconds. Completion and failure transitions report immediately. Unchanged delivery +progress uses a jittered 270–330 second presence heartbeat; empty background +cycles do not produce extra requests. Failed reports retry no faster than every 30 seconds, +except for a new terminal transition or explicit setup/one-shot confirmation. +Cloud determines whether session data has actually arrived. A running process +alone does not mark the dashboard's data step complete. + +An OS file lock permits one collector per destination directory. Stop requests +carry the startup identity, so an old stop file cannot stop a new run. There is +no signaling of arbitrary stored PIDs; startup timeout terminates only the child +process launched by that setup invocation. + +Setup and every delivery pass check legacy managed launchd/cron uploaders +without changing them, and the receiver applies the verdict before mapping a +batch and again before sending it, inspecting again once its verdict is 60 +seconds old; the collector's passes share one verdict, so back-to-back passes +inspect at most once a minute (see the probe README's legacy scheduler guard). An active old +uploader blocks setup and delivery. An +unavailable inspection requires the explicit `--acknowledge-uninspected-schedules` +flag after the user has checked their older schedules. Arbitrarily named/manual uploaders are not discoverable by this check. + +## Live capture + +A line an agent writes to its transcript reaches the receiver within about a +second of the write (p50 about 520 ms, p95 about 1 s on a loopback receiver +with appends half a second apart, against 60 s under the former capture +timer). In `all` and `new` sharing modes the collector's capture thread runs +one full, cancellable sweep of local history (waiting, if another process's +sweep holds the store's sync lock, rather than failing), then registers its +own filesystem watcher over every path `ai-hist`'s watch covers (filtered by +its own `WatchedPath::admits`, as selected mode's inventory is). Once the +watcher is attached, the thread sweeps once more, forced past the stat-only +source fingerprint so each session's own stamp decides what is read, as an +event's sweep is: it reads whatever was written while the first sweep ran, +which no watcher was there to report. From then on every write is an event, +so nothing written after the collector started waits for the backstop. + +- A write under a provider root (FSEvents on macOS, inotify on Linux, through + `notify`) owes a forced sweep. It runs once a 200 ms debounce has passed + (`live::WATCH_DEBOUNCE`) and the pace allows: the next sweep starts no + sooner than 10 times as long as the last one took after it ended + (`live::SWEEP_DUTY_FACTOR`), and never sooner than 1 s + (`live::SWEEP_MIN_GAP`), so capture holds at most about a tenth of a core + however busy the providers are. A live session writing its transcript + every couple of seconds used to drive a full sweep for every write, back to + back; it is now swept about once a second while sweeps are cheap, and at + ten times a sweep's cost apart when they are not. Events that arrive while + a sweep waits or runs are one sweep. Reads and metadata changes (opening a + provider's database, its `-shm` index) are not writes and ask for nothing. + A rescan (an inotify queue overflow, FSEvents reporting dropped events), + which names no path, a write event naming none, or a watcher error owes + the same forced sweep of every provider, and the watcher is registered + again before it. +- Each sweep that swept or changed a catalog row wakes the delivery loop, + which drains at once instead of at its idle cadence. +- The backstop runs when no sweep has for 60 s + (`live::CAPTURE_BACKSTOP_INTERVAL`): the same fingerprint-checked sweep the + former capture timer ran, for writes a platform never reports (network + mounts, some container filesystems). Before it, the watcher is registered + again if that would watch more: a provider root that did not exist yet, or + one whose registration failed. The backstop also bounds the pace, so a + change waits at most 60 s for its sweep however costly sweeps have become. +- When no filesystem watcher can run, the loop polls at that same 60 s + cadence, which is the former timer, and `capture_metrics` says why in + `fallback_reason`: `fs_events_unavailable` (the backend could not attach: + no backend, too many watches, `EMFILE`), `no_provider_roots` (nothing to + attach to yet), or `disabled` (`AGENT_RELAY_PROBE_FS_EVENTS=0` in the + collector's environment). If the store cannot be opened for the watch, the + thread runs the timer itself (`mode: timer`, `fallback_reason: + watch_unavailable`). Each change of driver is logged as one content-free + JSON line. + +The detached collector (`run`, the session collector the relay core and +`start` launch; in the Mac app it runs as the bundle's `Agent Relay Helper +(Sessions)` hard link, a sibling ` (Sessions)` file being preferred +whenever one exists) is a background process as a whole: on macOS a Darwin background +process (`PRIO_DARWIN_BG`), whose CPU, disk and network use the system +throttles; on Linux nice 19, set on its main thread before any other starts +so every thread inherits it. A sweep and its delivery never compete with the +agent writing the transcript it reads. In a process that stays at normal +priority (a `start --foreground`), the capture thread and selected mode's +inventory thread alone run at background priority (the background QoS class +on macOS, which also throttles their disk I/O; nice 19 for the thread on +Linux). The relay core itself keeps normal priority: the agents it starts +would inherit a lower one. A platform that refuses either change is logged as +one content-free JSON line (`stage: process_priority` or `thread_priority`) +and capture runs as before. + +Every sweep is the same locked sweep as a full capture: it takes the store's +sync lock itself, so `ai-hist`'s one-writer rules hold unchanged; one that +finds the lock held reads nothing and, if it was an event's, is retried at +the pace, at least a second apart. Delivery runs beside it on its own +connections, reading the change feed and writing only its upload tables; +SQLite's WAL lets the feed be read during a sweep, and the busy handlers on +both sides (`ai-hist`'s retrying handler and the delivery connection's 30 s +busy timeout) outlast a capture transaction, so neither side reports a +locked database. Writes from the two threads overlap in tests without a busy +error. + +A stop request ends the capture thread promptly: every sweep, the startup +sweep and each event's or backstop's alike, is the store's own cancellable +`sync`, and stops at its next provider, file or record boundary. The +collector lock is released only after the thread has ended. While the +startup sweep reports its file counts, delivery passes leave `progress.json` +to it, so the desktop is not switched between the two every few seconds; +Cloud heartbeats, which carry delivery only, are unaffected. + +Selected mode does not run this watch, whose sweeps enumerate every provider; +it watches only its members' transcripts. See +[Desktop bridge v1](#desktop-bridge-v1) for its member-scoped hydration. + +## Companion changes and distribution + +The probe depends on: + +- **Cloud:** device start/token/approval, `auth/whoami`, and the authenticated + workspace `relayhistory/session` bridge. The bridge returns an RTH sync session. +- **RelayHistory service:** existing durable `/v1/delivery/batches` and the new + `/v1/onboarding/heartbeat` endpoint. The dashboard also uses the companion + onboarding-status route. These server changes live in their own repositories. +- **agentrelay.com:** site-root `/install.sh` and platform downloads. The installer + should select the OS/architecture, verify SHA-256, install atomically to + `~/.local/bin/agent-relay-probe`, and invoke `cloud install` with the dashboard's + site/account/workspace. A checksum from the same origin detects corruption; + it is not a separate publisher signature. +- **Official Relay CLI:** an optional `agent-relay cloud install` alias may delegate + to the installed probe. The native onboarding path does not depend on it. + +### Distribution + +The desktop release workflow builds the probe for macOS (arm64, x64) and Linux +(x64, arm64 glibc) and bundles it into the app and Linux packages; there is no +standalone probe download. `probe-version.txt` beside each build records the +version it reports. + +## Verification + +```sh +cargo test --manifest-path probe/Cargo.toml --bin agent-relay-probe --locked +cargo test --manifest-path probe/Cargo.toml --lib cloud:: --locked +``` + +`probe/tests/live_capture.rs` runs the collector against a synthetic home and +a loopback receiver and requires a transcript line to be acknowledged within +seconds; `LIVE_CAPTURE_APPENDS=10` prints the latency of each of ten appends +with p50 and p95, `LIVE_CAPTURE_BINARY` measures another build, and the +ignored `idle_cost` test reports an idle collector's CPU time. + +Regression tests cover Cloud's `201 Created` device grant, authorization polling, +origin binding, official-CLI credential reuse, sharing choices, complete exclusion +pagination, private atomic state, collector locking and stale stop requests. The +sign-in tests live with the shared implementation, in the library's `cloud` module. + +For a composed local test, start the companion marketing proxy on 3100, Cloud +on 3101 with its development-only Teams login and a local DB, and the local +RelayHistory service. Then: + +```sh +node scripts/test-probe-local.mjs --binary /absolute/path/to/agent-relay-probe +``` + +Node is used only by this test harness. The child probe gets a fresh synthetic +home and `/usr/bin:/bin` PATH with no inherited provider credentials. The harness +approves its own development device grant, sends a uniquely identified synthetic +Claude session, reads that exact stored session, checks Cloud's `receiving` state, +verifies private storage and stops the collector. It keeps credentials and the +captured approval URL out of the test output, then removes its fixture directory. +It refuses remote origins and must never run against production. + +## Desktop bridge v1 + +The macOS companion uses `agent-relay-probe` for authentication, local credential +storage, capture and delivery. All bridge commands accept `--json`; every JSON +object has `bridge_version: 1`. Status output contains no credentials. Existing +human-readable install/status/stop commands remain available. + +- `installs --json`: discover configured probe directories. Every directory + with a `config.json` is listed, whatever this build can read of it: + `upgrade_required` marks one a newer probe set up or last ran, + `collector_version` names the build that last ran its collector, and + `paused` is null when this build cannot read its store. +- `cloud install --json --include-existing|--new-sessions-only|--selected-sessions-only`: + emit NDJSON `approval`, `authenticated`, `connected`, and `ready` events. The + browser approves the device; `authenticated` establishes the Agent Relay + login boundary before the probe provisions RelayHistory upload credentials. + `connected` confirms those upload credentials are stored. JSON setup requires + one explicit sharing choice and runs in the background. Reconnecting a + workspace whose collector is running stops that collector and starts this + build's when setup completes, or restarts the stopped one if setup fails. A + collector a newer probe runs or last ran is left alone, and setup and + `start` ask for an update. The desktop apps sign in with + `--selected-sessions-only --keep-saved-sharing` (`account.sign_in` with + `sharing:"selected"`): a fresh install uploads nothing until a session's + Upload switch is turned on, and an enabled session keeps uploading as it + changes, while signing in again to a workspace already set up here keeps + its saved choice (`new` or `all` included) rather than refusing it. Settings › + Session sharing still offers `new` and `all` (`sharing set`). Selected + setup reads no transcript; it runs one shallow catalog pass of the newest + 1,000 sessions into the install's store (`SETUP_CATALOG_LIMIT`, metadata + only, as the pre-install preview reads) before it reports ready, so the + session list is filled the moment the app attaches rather than once the + collector's first inventory pass has run. +- `start`, `status`, `pause`, `resume`, `disconnect`, `compact`: pass + `--account ID`, `--workspace ID`, and optionally `--site-url URL`, plus + `--json`. +- `compact --json`: releases every settled batch receipt, under the + desktop control lock. Pending and unacknowledged batches are untouched, so it + is safe while the collector runs. It returns the receipts removed: + + ```json + { "removed_records": 42 } + ``` + +- `sessions list --json --limit 500`: newest sessions with title, + source, project path, activity and upload status. `uploading` means a record + from the session is in a currently leased batch; `queued` means a pending + batch. `uploaded` means the job has examined every change of the session it + sends and none is in an unacknowledged batch, even while the rest of the + backlog is still draining: each listed session is one indexed read of its own + changes after the job's position (or, for a member still backfilling, not yet + uploaded; nor is a member that parents a relationship to a member still + backfilling, whose backfill sends that edge). The job's positions are read + before its pending batches, so a batch prepared during the listing is either + seen or still ahead of those positions. `shared` is used while a session has + changes still to examine. `checking` is used for an enabled session the + listing's five-second budget did not reach: it is neither proven uploaded + nor known to be waiting, and the apps show it as "Checking…" outside the + in-progress count. A listing keeps each proof in `session-status.json` beside + the store, with the feed head it holds through, so the next listing re-reads + only sessions changed since: one scan of the feed from the oldest proof finds + them. An all-sources job whose position is close to the feed head is decided + by one scan from that position instead of a read per session. A proof is + dropped when the job, its generation, selection, members or the stored + exclusions change, when the feed epoch changes, or when the job's position + moves back; an unreadable file only costs a slower listing. +- `sessions include|exclude --json --session SOURCE:ID …`: explicitly + share one session or a batch, or stop sharing them. This does not erase + already uploaded history. +- `sharing set --json --mode all|new|selected`: share everything, + establish a new-only baseline, or share only explicitly selected sessions. + Previously selected sessions are retained across mode changes. +- `sharing status [--site-url URL] [--account ID --workspace ID] [--json]`: + what each install on this computer shares, from each install's + `config.json` alone, so it answers while a sync holds local history. With no + account and workspace it lists every install (optionally only one site's); + with both it names that install, on `--site-url` or agentrelay.com, and fails + when it is not set up here. Each entry has `directory`, `site_url`, + `account_id`, `workspace_id`, `sharing_mode`, `change_pending` (a + `sharing-change.json` is still being applied) and `upgrade_required`: an + install a newer build set up is listed with `sharing_mode: null`. Plain `status ` ends + with a `Sharing:` line naming the same mode. + +Selected mode uses the core's normalized, deny-by-default session membership. +A subagent or child thread is part of the session that delegated work to it, +not a session of its own: the session list does not offer it, it cannot be +selected or withheld on its own (`sessions include`/`exclude` refuse it and +name its session), and it is shared with its session. Hydrating a member +captures its delegated work with it, and after each hydration round the +capture thread makes every delegated descendant of a selected session a +member too (`delegated` in `delivery_session_members`), backfilled from the +feed's start like any member and dropped, fenced, when its session is +deselected. Forks, resumes and continuations are sessions of their own. +Adding a session backfills it from the feed's start. Adding B keeps A's +acknowledged progress and pending immutable batch; repeating an inclusion is a +no-op. A removal fences an in-flight lease and is checked again before +dispatch. Re-inclusion backfills the session again from the start. New +discoveries stay private without writing an exclusion for every catalog row. +The selected-session manifest is still written for desktop compatibility; its +size follows explicit selections, not the machine's history. Session lists and +status remain read-only. + +Selected delivery and targeted hydration run on separate threads, so a backlog +never delays hydration and hydration never delays a drain. Hydration is +scoped to what changed and follows filesystem events: + +- The capture thread watches the directory of each member's transcript (its + catalog row's file), without recursing. A write there that names a + member's transcript opens a 200 ms debounce window (`WATCH_DEBOUNCE`); when + it closes, one round compares each named member's transcript (modification + time and length) with what it last hydrated and hydrates the ones that + moved, then wakes delivery. A burst of appends is one round; sustained + writes hydrate about once per window. Writes to other sessions in the same + directory hydrate nothing. +- A member of a source whose file holds many sessions (OpenCode's database) + moves with its own catalog row instead. Each inventory pass, which + refreshes the rows after a write to that database, has the next round + compare those members' rows, so another session's write does not + re-hydrate them. +- Once a second (`MEMBER_POLL_INTERVAL`) the thread reads the job's state and + membership, so a member added or a job resumed is hydrated within about a + second, and stats the transcripts no watch covers: one whose directory + does not exist yet or could not be registered. +- Every member is hydrated at startup and again every ten minutes + (`MEMBER_BACKSTOP_INTERVAL`), which covers a member with no local file to + compare and a write no event reported; each backstop also registers the + watch afresh. +- When the watcher reports lost events (a queue overflow or a rescan) or + fails, the next round compares every member's transcript. +- With `AGENT_RELAY_PROBE_FS_EVENTS=0`, where no watcher can run, or while no + registration covers any member's transcript (out of watches, too many open + files), every member's transcript is stat'ed once a second, and + `capture_metrics` reports `driver: member-poll` with `fallback_reason` + `disabled` or `fs_events_unavailable`. Each round decides the driver + afresh. + +The rounds keep their read connections from one round to the next, reopening +them only after a failed round or once `history.db` is replaced; a failed +round has the next one compare every member. Hydration uses the core's parser, observation locks, cancellation +and checkpoints. Selected setup does not run a full-history capture. Paused +selected jobs do not hydrate or deliver; the inventory can still discover +metadata. + +Selected mode keeps only its members' evidence. On the first poll of a run, +and whenever the members change, the capture thread asks `ai-hist` to forget +the evidence of every other session (`SessionStore::forget_evidence` with +`ForgetScope::AllExcept`): events, full-text rows, tool calls, edits, markers +and checkpoints go, catalog rows stay as `Shallow` so the session list is +unchanged, and a member's delegated children stay with it. A session whose +evidence no local hydration could read back (its transcript deleted, or its +evidence from a remote or plugin source) is kept. When the forget frees at +least 64 MiB and a quarter of the file, the store is rewritten in place +(`SessionStore::compact`) to give the space back. The rewrite holds the +store's write lock for its whole length, so the delivery loop runs it between +its passes rather than the capture thread beside one, and reports it in +`compaction-diagnostic.json`; a volume too full for the rewrite is asked for +again by the next run's pruning. A selection with no members drops nothing. +A re-selected session is hydrated in full again by the next poll. Nothing is +retracted from the destination: what was delivered stays delivered. +`pruning-diagnostic.json` reports the last pass's counts, including +`skipped_unrecoverable`; a failed pass is reported in +`capture-diagnostic.json` as stage `evidence_pruning` and retried at the next +backstop. + +The session list reads a catalog that a separate inventory thread keeps, +apart from the selected delivery scheduler. Shallow discovery reads every +provider's metadata, which on a large history costs seconds of CPU even when +nothing changed, so the inventory follows filesystem events rather than a +timer: a write under a provider's roots (the paths `ai-hist`'s own watch +covers, filtered by its own `WatchedPath::admits`) marks that source, and the next pass discovers only the marked +sources; a rescan or a watcher error, which names no path, has the next +pass discover every source. A pass waits at least 50 times as long as the last one took, and at +least 30 s, so discovery holds at most about 2% of a core however busy the +providers are; an idle machine runs no passes. Every 30 minutes a pass +discovers every source, for writes no event reported, and registers the +watcher again, for provider roots created since. With +`AGENT_RELAY_PROBE_FS_EVENTS=0`, or where no watcher can run, every pass +discovers every source at that spaced cadence. All/new sharing modes +capture by [live capture](#live-capture). A one-shot `--once` setup still +runs one bounded cycle: delivery, then capture, then delivery again. + +Sharing mutations serialize on `desktop.lock`, stop the collector, and take +its lock. A durable `sharing-change.json` records the intended change before +any writes; replay completes it before delivery can restart. Selected-mode +include/exclude operations update only changed membership. Explicit sharing +mode changes retain generation replacement semantics and preserve pause state. + +A selected install whose job predates session membership is adopted in place: +the same job keeps its destination generation, pending batches, retry and +pause state, and its selected sessions become members, each backfilled from +the feed's start. Existing exclusions remain privacy guards, including +exclusions needed by another active destination. Read-only commands also +accept the schema of an install that has not been rebuilt yet. + +`capture-diagnostic.json` reports capture stage (`broad_capture`, +`targeted_hydration`, or `live_capture` for a failed watch tick), elapsed time, counts and an +allowlisted error class; inventory diagnostics use a separate file. Logs never +include raw provider errors, transcript content, paths or credentials. A +successful include command means consent and preparation were recorded, not +that a receiver acknowledged the session. `shared`, `queued`, `uploading` and +`uploaded` retain their existing bridge meanings. The historical 77/86-second +capture failures cannot be diagnosed from the old generic logs; their cause +remains unknown until a redacted diagnostic is reproduced. + +Disconnect stops the collector, cancels jobs, best-effort revokes the workspace +RelayHistory token, and removes this install's stage credentials/configuration. +The local history database is retained. It does not remove shared Cloud login +credentials belonging to other Cloud clients. + +### Local title preview + +`agent-relay-probe sessions preview --json --limit 100 [--refresh]` has no +Cloud target, credentials, or transport. `--refresh` runs `SessionStore::discover`, +the shallow catalog sweep, into a separate private metadata catalog under +`~/.agentworkforce/session-preview/`; it does not contend with full capture's +writer or change inclusion. The cache-only form lists recent metadata and writes +`sessions.json` for immediate desktop startup. Both return the desktop session +shape with `included: false` and `status: "unknown"`. These rows are previews; +only the normal target-scoped session list can make them selectable for upload. + +The desktop prewarms discovery during sign-in and displays cached titles before +awaiting capture or delivery status. The preview is limited to 100 recent sessions; +full browsing continues through the normal session list when preparation finishes. + +### Relay core + +`agent-relay-probe relay serve [--socket PATH] [--app-data-dir PATH] +[--exit-on-stdin-eof]` runs the relay core, the desktop's non-UI runtime, on +the private bridge socket `~/.agentworkforce/desktop/core.sock`. +`agent-relay-probe relay call METHOD [--params JSON] --json` sends one bridge +request to it. The contract, lifecycle and module map are in +[the relay core](relay-core.md). diff --git a/apps/desktop/docs/agents-people.md b/apps/desktop/docs/agents-people.md new file mode 100644 index 0000000000..4e8932bb7c --- /dev/null +++ b/apps/desktop/docs/agents-people.md @@ -0,0 +1,127 @@ +# People and their agents + +My agents and Team show live work rather than a history of every session the +desktop has discovered. Local sessions are visible in the main list only while +the provider is running or recently reachable (`sessions.list`'s `live`). Both +apps keep an ended session listed while it is still on the relay, marked +"Waiting — session not running", so its switch can take it off. + +## Person and avatar data + +The signed-in Agent Relay Cloud member roster supplies each person's id, name, +email and optional `avatarUrl`. The UI keeps this roster in memory only. It +uses the account avatar when present, otherwise the standard lowercased-email +Gravatar URL. Logout clears the roster. The relay core serves it as +`people.list`; the signed-in person is `status.account.person`. + +Local-session registrations carry their owner in Relaycast metadata: +`cloud_user_id` and `cloud_workspace_id`, which the engine uses to credit the +agent's messages to that person, and an `owner_hash` fingerprint for the +roster. The owner is the attached install's person; when it changes, stored +registrations are updated through `PATCH /v1/agents/{name}`. The desktop +retains only the hash while decoding `/v1/agents`, joins it to the +in-memory Cloud member roster for presentation, and never exposes it through +the private `GET /agents` socket. Registrations without an owner remain visible +under **Other live agents**; arbitrary metadata is never an authorization +boundary. + +Team shows one card per teammate, those with agents first; the sidebar lists +every teammate with their live count and narrows the page to one person. A live +entry uses its canonical `agent@machine` address for Message. Workspace members +remain listed with **No agents on the relay** when their active roster is empty. +The signed-in person's agents on other machines appear under My agents. + +## Recipient liveness and message state + +What happened to a message is what Relaycast holds for it; whether its +recipient runs is only a hint beside it. + +**Message state.** `GET /v1/agents/{name}` lists the deliveries Relaycast +still holds for the agent (`pending_deliveries`, oldest 50). The core reads +the sent message's row there: + +| `message` | Relaycast | +|---|---| +| `queued` | its row is `queued`: not yet handed to the agent's host | +| `delivered` | its row is `delivered`: handed to the host, not yet acknowledged | +| `picked_up` | no row: the agent acknowledged (or refused) it | +| `no_such_agent` | no agent has the name or address | +| `null` | the record cannot tell: a server without `pending_deliveries`, an unknown row status, or a full list without the message | + +**Liveness.** Every roster agent carries `liveness` from Relaycast's +presence and, for agents registered from this computer, its own session +discovery: + +| `liveness` | Source | +|---|---| +| `live` | `status` `active` or `online` with `last_seen` within Relaycast's five-minute window; a session on this computer that is running | +| `idle` | `status` `idle`, `waiting` or `blocked` with `last_seen` within the window | +| `offline` | anything else; a desktop registration marked `idle` (its session ended); a session on this computer that is not running | + +An agent without `address` has no node (SDK and MCP agents register +without one); that alone says nothing about whether it runs. A released +agent's name resolves to no agent. Relaycast expires `active` itself; +`idle`, `waiting` and `blocked` are self-reported and never expire there, so +the core applies the same window to them. A desktop registration +(`metadata.source` `relay-desktop`) is marked `idle` by its desktop when its +session ends and `active` when it runs again, so for it `idle` is `offline` +and an ended session is offered on no device. This computer's own sessions +are read from discovery. + +**Workspace.** The roster is read with this computer's workspace key. It +answers for your messages only when your relay identity sends on the same +origin and the account does not use another workspace; otherwise +`roster.get` and `messages.send`'s `recipient` have `known: false` and say +nothing. + +`roster.get {to|address, message_id?}` looks up one recipient and, with an +id, that message; `messages.send` answers with the same lookup read just +after the send. A send to no agent is `not_found` and nothing is queued. +`roster.list` without `include_idle` reads the running statuses (`active`, +`online`, `idle`, `waiting`, `blocked`, one request each: Relaycast filters by one +status) and lists the agents that are `live` or `idle`; with it, every agent +(`status=all`). A status whose read fails leaves out only its agents, is +named in `unread_statuses` and logged; the list fails only when every read +does. + +macOS's Message an Agent sheet looks the recipient up as it is entered and +warns inline before queueing for one that is `offline` (**Queue Anyway**). +After a send it shows the message state (**Queued — agent not running** or +**Queued — not yet handed to the agent**, **Delivered**, **Picked up by the +agent**, **No such agent**, or **Sent — delivery unknown**), read again two +seconds after the send, on `sessions`, `fleet`, `roster` and `messages` +invalidations, and every 10 seconds while the sheet is open. Writing the next +message or closing the sheet drops the note. Beneath the composer it says +whether a reply is expected, with when the recipient was last seen. My agents +and Team offer agents that are `live`, and `idle` ones dimmed with a ring. + +The Linux app's Messages window does the same for its conversations: a +recipient is looked up when its conversation is shown and two seconds after a +send. On the same invalidations and every 10 seconds it is read for the +conversation shown and for any whose last message is not yet picked up or +refused, one read per conversation at a time, and no longer once the +conversation closes. + +## Person inbox decision + +Current Relaycast DMs and delivery acknowledgements belong to agents, not to a +workspace person. They cannot durably hold a message for a teammate with no +online agent, and agent registration metadata is not authoritative ownership. +[relaycast#457](https://github.com/AgentWorkforce/relaycast/issues/457) tracks +the required person principal, waiting/picked-up state and unread counts. + +The chosen routing design is explicit recipient selection: a waiting message +stays in the person's inbox until they choose a live agent (or opt into a saved +preferred agent). “First agent online” is intentionally rejected because it +could deliver a private teammate message to unrelated automation. Until the +server contract ships, desktop shows the Inbox affordance disabled; it does not +pretend that an agent DM is a person inbox. + +## Codex presence + +macOS reads only user-facing `cli` and `vscode` threads, skips Relay-spawned +system-reminder titles, waits up to eight seconds for Codex's SQLite writer, +and reads extra rows before applying the automation filter. A thread is live +when a signed Codex resume process names it or its database row was updated in +the last five minutes. This prevents old threads and `exec` automation from +crowding live work out of the main list. diff --git a/apps/desktop/docs/capy-thread-spawn.md b/apps/desktop/docs/capy-thread-spawn.md new file mode 100644 index 0000000000..722434837a --- /dev/null +++ b/apps/desktop/docs/capy-thread-spawn.md @@ -0,0 +1,94 @@ +# Starting a Capy thread from Relay Desktop + +Relay Desktop can start a headless Capy thread without receiving the +workspace's Capy key. The macOS and Linux apps call typed relay-core methods; +the core uses the signed-in person's Agent Relay Cloud session, and Cloud +makes the Capy request with the encrypted workspace key. + +## Ownership and authorization + +The Capy service-user key stays in Agent Relay Cloud under **Settings → +Workspace → Capy**. Cloud stores the encrypted key and never returns it to the +desktop app or relay core. Thread creation, binding, delivery subscriptions and +grants therefore remain in the same server-side trust boundary. + +The workspace routes require a person connection whose current workspace is +the workspace in the path. Only a current workspace owner or admin may list +projects, start a thread or read its spawn status. + +## Shipped Cloud API + +Relay Desktop uses these session-authenticated routes under `/api/v1`: + +- `GET /workspaces/{workspaceId}/capy/projects?cursor=` returns + `{"projects":[{"id","name"}],"truncated","nextCursor"}`. When + `truncated` is true, the app passes `nextCursor` to the next request. +- `POST /workspaces/{workspaceId}/capy/thread-spawns` accepts + `clientRequestId`, `projectId`, `task`, optional `model`, optional + `pullRequestAuthor` (`user` or `capy`), and optional `reportTo`. +- `GET /workspaces/{workspaceId}/capy/thread-spawns/{threadId}` returns the + current thread and relay-binding status. The path uses Capy's thread id, not + the client request id. + +The create and status responses are: + +```json +{ + "thread_id": "jam_…", + "title": "…", + "status": "…", + "binding": { "state": "pending" } +} +``` + +After the first turn binds the thread, `binding` is +`{"state":"bound","agent":"capy-…"}`. Bound means the binding is active, +its delivery subscription is installed, and its grant is live. + +Cloud returns safe JSON errors as `{"error":"…","retryAfterSeconds":…}`. +Relevant statuses are 401, 403, 404, 409 when Capy is not connected, 429 and +502. Neither Cloud nor the desktop logs the Capy key or returns a raw provider +response. + +## Idempotency and first-turn binding + +Before either UI makes the create call, it saves a `capyThreadSpawn.v1` +operation in its native preferences. That record contains the workspace, +form values and one `relay-desktop-` `clientRequestId`. A failed call, +timeout or app restart reuses the same record. **Start another** clears it and +creates a new id. + +Cloud derives Capy's required request id from the workspace, signed-in person +and client request id: + +```text +agent-relay- +``` + +Cloud always creates an organization-visible thread, does not export the +service-user key, and prefixes the task with an instruction to call +`bind_capy_thread` before doing the work. Replaying a create with the same +client id therefore returns the same Capy thread. + +## Desktop behavior + +The relay core exposes `capy.projects.list`, `capy.threads.spawn` and +`capy.threads.get`. It supplies the current workspace from its Cloud session, +reduces responses to the shared contract models and never exposes credentials. + +Starting Capy threads is an agent operation. macOS and Linux omit +**Start Capy thread…** from the account and Agents/Team overflow menus. +The retained form implementation selects a project, takes the task and optionally +selects a model and pull-request author. Both apps use the same states: + +- **Starting Capy thread…** while the create call is unresolved. +- **Thread started · Waiting for Capy to join the relay** after Capy returns + but before the first turn binds. +- **Running on the relay**, the thread id, `@agent`, **Message**, and **Start + another** once Cloud reports the binding. + +When the workspace has no Capy connection, the error offers **Agent Relay +Cloud → Settings → Workspace → Capy**. Other Cloud errors remain bounded to +project access, write access, rate limiting, reachability and binding status. +A binding-status error stays visible beside the waiting state with **Check +again**; retrying never replaces the saved idempotency key. diff --git a/apps/desktop/docs/cursor-cloud.md b/apps/desktop/docs/cursor-cloud.md new file mode 100644 index 0000000000..5ad4dadb85 --- /dev/null +++ b/apps/desktop/docs/cursor-cloud.md @@ -0,0 +1,68 @@ +# Cursor Cloud agents + +Cursor Cloud is a hosted Relay provider, identified as `cursor-cloud`. It is +not the local `cursor-agent` CLI and does not use terminal wrapping, readiness +polling or prompt injection. + +It does not use the Fleet API. Relay Desktop already owns the shared core +contract and roster, while Agent Relay Cloud owns the provider credential, +native Cursor calls and hosted Relay participant. + +Agent Relay Cloud owns the Cursor API credential. The desktop core authenticates +only to Agent Relay Cloud and exposes typed `cursor_cloud.*` contract methods; +provider credentials never enter the app contract, diagnostic logs or an agent +sandbox. Cloud calls Cursor's public v1 API, whose durable agent and per-prompt +run model supplies the native operations Relay needs: + +- list supported models, cached repositories, environments, durable agents and + their runs; +- create an agent and its initial run; +- inject a follow-up with Cursor's `delivery: "steer"`, which steers the + current turn or queues the next one; +- read or cancel run state, final text and pushed branches or pull requests; +- archive and unarchive an agent. Relay never maps release to Cursor's + irreversible delete operation. + +Cloud registers a launched agent in Relay with `session_kind: cursor-cloud`. +Before Cursor creates the agent, Cloud registers its hosted Relay identity and +adds a pinned `agent-relay@14.0.0` stdio MCP server to Cursor's create request. +Only that identity's scoped agent token and Relay URL enter the Cursor VM; the +Cursor API key and Relay workspace key do not. This gives the agent native +roster and peer-messaging tools. The registration's delivery adapter separately +turns a Relay direct message into a native Cursor follow-up and reports its +accepted or queued state. `client_request_id` is the Relay-side idempotency key +for create and follow-up operations because Cursor's steer response does not +include a run id. + +Cloud durably polls accepted work because Cursor v1 has no webhooks. Terminal +results and pull-request links are posted back to the originating Relay message +with a stable writeback key. + +RelayHistory records each run from Cursor's run event stream, which Cursor +retains for a limited window after the run ends. While that stream is +available, RelayHistory stores its assistant, status, thinking, tool-call and +result events and marks the run's coverage `retained_stream`. Once the window +has passed, only the run summary (final text, status and pushed branches or +pull requests) remains, and RelayHistory marks the run as summary-only rather +than presenting it as a complete transcript. + +## Desktop surface + +`cursor_cloud.*` is a contract surface for agents, the CLI and Cloud-driven +flows. Like Replicas, neither the macOS app nor the GTK app (Linux and +Windows) has a dedicated Cursor Cloud management screen. Both apps show +Cursor Cloud agents in the roster with the Cursor Cloud name and icon, and +both classify the same read-only methods as safe to repeat after a reconnect. +A management screen, if one is added, must ship on macOS and GTK together. + +`client_request_id` is required on `cursor_cloud.agents.spawn` and +`cursor_cloud.runs.create`: up to 128 letters, digits, `-`, `_`, `.` or `:`. +Reuse it when you retry a call so Cloud returns the original result instead +of launching a second agent or sending the follow-up twice. Releasing an +agent is `cursor_cloud.agents.archive`, which `cursor_cloud.agents.unarchive` +reverses. + +Cursor's v1 Cloud Agents API is public beta. The Cloud adapter normalizes its +camel-case provider responses into this core's snake-case contract so provider +schema changes remain at the hosted boundary. The upstream reference is +. diff --git a/apps/desktop/docs/existing-session-injection.md b/apps/desktop/docs/existing-session-injection.md new file mode 100644 index 0000000000..1019d22936 --- /dev/null +++ b/apps/desktop/docs/existing-session-injection.md @@ -0,0 +1,189 @@ +# Existing-session injection adapters + +The desktop's delivery runtime is the Rust `agent-relay-probe` core on macOS +and Linux. `probe/src/relay/existing_session.rs` provides an explicit adapter +boundary for sessions running outside the Relay broker. OpenCode is exposed +only through the probe-owned managed-launch path described below. Grok has +that path too, and an on-by-default route to the leader a Grok TUI runs on in +leader mode. The managed rollout gate defaults to empty, so current Claude +and Codex delivery is unchanged. + +| Provider | Eligible route after explicit enablement | Current exposure | +|---|---|---| +| Devin | Local CLI session: the `devin` process's held `session_locks/.lock` proves identity. Delivery writes a `session/prompt` frame into the session's `acp` stdin pipe through `/proc` when reachable — an immediate user turn that wakes an idle session (Linux terminal sessions only; macOS has no `/proc`, and Devin Desktop's `acp` talks over a socketpair, so those sessions queue until the next boundary) — else stages a file into `relay/inbox.d`, which Agent Relay's managed lifecycle hooks in `~/.config/devin/config.json` (`devin_direct_delivery`) drain into the conversation at the next hook boundary. Cloud sessions (`devin-*`), including sessions displayed in Devin Desktop and outpost-placed sessions running on a local machine: official v3 organization session-message API | Local discovery, caller identification, registration, pipe/inbox delivery and managed hooks; cloud adapter for `devin-*` sessions | +| OpenCode | Probe-launched authenticated loopback server | Default-off managed picker and injector | +| Grok | Probe-launched ACP leader and private Unix socket; or the leader a `grok --leader` TUI spawns | Default-off managed picker and injector; on-by-default `grok-open` listing, binding on add, and injector | +| OpenClaw | Loopback operator client of the person's OpenClaw Gateway; one session per relay sender ([openclaw-hermes-delivery](openclaw-hermes-delivery.md)) | On wherever OpenClaw is set up (`openclaw_gateway`) | +| Hermes | Hermes's loopback API server, `/v1/runs` with durable keyed admission; one session per relay sender | On wherever Hermes has run (`hermes_agent`); listed while the API answers | +| Pi | Bundled extension loaded with `/reload` in an existing persistent UI session; private per-runtime inbox ([Pi delivery](pi-existing-session-injection.md)) | Default-on installation where Pi exists, live discovery, pinned registration, history-confirmed delivery and final answers | +| Any legacy TUI | None | Rejected | + +The [Devin v3 OpenAPI contract](https://docs.devin.ai/v3-openapi.yaml) +documents `GET /v3/organizations/{org_id}/sessions/{devin_id}` and +`POST /v3/organizations/{org_id}/sessions/{devin_id}/messages` with a `message` +body. The POST has no documented idempotency key. The adapter therefore checks +the exact organization, session and owner with the same bearer before POST; +the caller creates and syncs an in-doubt journal record before any POST. A +timeout or uncertain response stays in doubt and is never resent. Only an +exact successful session response produces a receipt. Redirects and ambient +HTTP proxies are disabled; errors exposed to the desktop are fixed codes, +never response bodies or credentials. + +The v3 route cannot address local `devin-local` sessions (the `windsurf` +backend a terminal `devin` or Devin Desktop runs): they are not registered +in the organization session list — `GET .../sessions?origins=desktop|cli` +and `session_ids=` both return empty for them — and no public API +maps a local session id onto a remote one. Those sessions therefore remain +pipe/inbox-only. This is an upstream boundary, not a gap Agent Relay can +close: a first-party channel (a `devin acp` inbox socket like Claude Code's +per-session peer inbox, an `inbox.d` watch, or a `devin prompt --session` +command) would make idle local sessions wakeable everywhere. + +[OpenCode's server contract](https://opencode.ai/docs/server/) documents an +HTTP session prompt route and optional basic authentication. An arbitrary +OpenCode TUI chooses a random server port, so a title or transcript ID alone +does not establish an authorized endpoint. Keystrokes into legacy terminals +are never a fallback. + +`sessions.managed.launch` is available only on the core's private local RPC +socket (0700 parent, 0600 socket, kernel peer-UID check). Its request contains +only provider kind, executable, directory and requested provider session ID. +It does not accept an endpoint, PID, socket, provider workspace, credential, +credential reference or prebuilt binding, and unknown fields are rejected. +The route is separately default-off for each provider: + +- `AGENT_RELAY_ENABLE_MANAGED_GROK=1` +- `AGENT_RELAY_ENABLE_MANAGED_OPENCODE=1` + +When enabled, the probe canonicalizes a trusted executable and directory, +launches the provider endpoint with a random probe-only marker (and random +OpenCode Basic authentication), verifies PID/start/UID/executable/arguments, +verifies the socket peer or literal loopback endpoint, and loads/reads the exact +provider session. Only then does it atomically persist a private immutable +binding of provider, Relaycast workspace, signed-in owner, UID, session, +endpoint/socket and process identity, plus a hash reference to the marker. Raw +markers and Basic credentials are never written. The picker lists only enabled, +live records from that registry; registration repeats the workspace/owner/UID +check, and `LocalInjector` refuses a route absent from it. Restart reloads and +re-verifies the same records. Editing, replacing, making unreadable or making +non-private any record hides it and disables injection rather than adopting a +replacement process. + +Grok also has a second route, for the leader Grok spawns when a TUI runs in +leader mode (or one the person started). It is on unless +`AGENT_RELAY_ENABLE_EXISTING_GROK=0`, and has its own registry and journal. +Live Grok TUI sessions are listed from Grok's own `active_sessions.json`, and +a Grok session Paseo drives with `grok agent stdio` is listed from that +process and the session id Paseo recorded for it. Adding one binds the leader +that client is attached to. A client started with `--no-leader` is listed and +is not bound, so the message is not delivered to a different Grok process. The +already-open authority accepts only a leader *without* +the managed marker, so it cannot adopt a managed leader. A session that both +routes claim is neither listed nor delivered to. See +[Grok existing-session injection](grok-existing-session-injection.md#already-open-leaders). + +The requested executable path itself must have the exact leaf name `grok` or +`opencode` before canonicalization. A correctly named symlink may resolve to a +trusted installation, but wrapper names and versioned shims such as +`opencode-1.18` are deliberately rejected. + +Before enabling a provider, the core must bind the Relaycast workspace and +registration to an immutable provider session, endpoint, owner and local +credential reference. The provider adapter must recheck those values before +sending. The journal uses a private 0700 directory, 0600 records and locks, +`O_NOFOLLOW`, an exclusive per-session lock, a synced pending record before +sending, and a synced receipt before returning `Delivered`. Future Relaycast +wiring must acknowledge only after that return. A corrupt or unreadable record +fails closed. For OpenCode, an in-doubt record is reconciled only by readback +of the exact deterministic user message and correlated terminal assistant +response. For Grok, which has no idempotent prompt/readback contract, an +in-doubt record stays quarantined for operator resolution. Neither path +automatically resends. + +## OpenCode port selection + +`opencode serve` binds its own listener from `--hostname` and `--port`; it +has no interface for adopting an inherited socket. The probe therefore asks +the kernel for a free `127.0.0.1` port, releases it, and passes the number to +`opencode serve`. Between that release and OpenCode's bind, another local +process can take the port: + +- Such a squatter never receives the Basic credential. Every authenticated + request first proves that the exact recorded OpenCode PID owns the + listening socket on that port. +- A launch whose OpenCode process exits before its endpoint verifies is + retried on a fresh port. A launch makes at most three attempts, and all of + them share one 60-second deadline. An attempt whose process stays alive + but never verifies uses the remaining deadline, and the launch then fails. +- A squatter can therefore make a launch fail, never take over its route. + +## Registry and runtime files + +- The registry is versioned. A v1 registry, whose Grok records carry two + `provider` keys and cannot be decoded, is moved aside as + `bindings.json.bak-