Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions .github/actions/setup-apple-replay/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ inputs:
required: false
default: ""
build-command:
description: "Command used to build replay artifacts when the cache is cold"
description: "Command used to refresh replay artifacts before use"
required: true
xcuitest-platform:
description: "Optional AGENT_DEVICE_XCUITEST_PLATFORM value"
Expand All @@ -23,12 +23,8 @@ inputs:
description: "Optional AGENT_DEVICE_XCUITEST_DESTINATION value"
required: false
default: ""
clean-derived:
description: "Optional AGENT_DEVICE_IOS_CLEAN_DERIVED value"
required: false
default: ""
build-on-miss:
description: "Whether this setup action should build replay artifacts on cache miss"
build-before-use:
description: "Whether this setup action should refresh replay artifacts after restoring the cache"
required: false
default: "true"

Expand All @@ -37,7 +33,7 @@ outputs:
description: "Resolved agent-device home directory"
value: ${{ steps.agent-home.outputs.dir }}
cache-hit:
description: "Whether the replay cache was restored"
description: "Whether a replay cache was restored before any build refresh"
value: ${{ steps.restore-prebuilt.outputs.cache-hit }}

runs:
Expand All @@ -56,7 +52,7 @@ runs:
id: source-hash
run: |
set -euo pipefail
echo "value=${{ hashFiles('ios-runner/**', 'package.json', 'pnpm-lock.yaml') }}" >> "$GITHUB_OUTPUT"
echo "value=${{ hashFiles('ios-runner/**', 'scripts/build-xcuitest-apple.sh', 'scripts/patch-xcuitest-runner-icon.ts', 'scripts/write-xcuitest-cache-metadata.mjs', 'src/platforms/ios/apple-runner-platform.ts', 'src/platforms/ios/runner-icon.ts', 'src/platforms/ios/runner-xctestrun.ts', 'src/platforms/ios/runner-xctestrun-products.ts', '.github/actions/setup-apple-replay/action.yml', 'package.json', 'pnpm-lock.yaml') }}" >> "$GITHUB_OUTPUT"
shell: bash

- name: Cache replay prebuilt
Expand All @@ -68,15 +64,14 @@ runs:

- name: Resolve agent-device home
id: agent-home
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"
run: echo "dir=${AGENT_DEVICE_STATE_DIR:-$HOME/.agent-device}" >> "$GITHUB_OUTPUT"
shell: bash

- name: Build replay artifacts
if: inputs.build-on-miss == 'true' && steps.restore-prebuilt.outputs.cache-hit != 'true'
if: inputs.build-before-use == 'true'
run: ${{ inputs.build-command }}
shell: bash
env:
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ inputs.derived-path }}
AGENT_DEVICE_XCUITEST_PLATFORM: ${{ inputs.xcuitest-platform }}
AGENT_DEVICE_XCUITEST_DESTINATION: ${{ inputs.xcuitest-destination }}
AGENT_DEVICE_IOS_CLEAN_DERIVED: ${{ inputs.clean-derived }}
6 changes: 6 additions & 0 deletions .github/actions/upload-agent-device-artifacts/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ inputs:
agent-home-dir:
description: "Resolved agent-device home directory"
required: true
runner-derived-path:
description: "Optional Apple runner derived data path to upload cache metadata and Xcode logs"
required: false
default: ".agent-device-no-runner-derived"

runs:
using: "composite"
Expand All @@ -21,5 +25,7 @@ runs:
${{ inputs.agent-home-dir }}/daemon.log
${{ inputs.agent-home-dir }}/logs/**
${{ inputs.agent-home-dir }}/sessions/**
${{ inputs.runner-derived-path }}/.agent-device-runner-cache.json
${{ inputs.runner-derived-path }}/Logs/**
test/artifacts/**
test/screenshots/**
4 changes: 3 additions & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
timeout-minutes: 80
env:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
steps:
- name: Checkout
Expand Down Expand Up @@ -76,4 +77,5 @@ jobs:
uses: ./.github/actions/upload-agent-device-artifacts
with:
artifact-name: ios-artifacts
agent-home-dir: ${{ steps.apple-replay.outputs.agent-home-dir }}
agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }}
runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
6 changes: 5 additions & 1 deletion .github/workflows/macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
runs-on: macos-26
timeout-minutes: 80
env:
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/macos-runner-derived
steps:
- name: Checkout
Expand All @@ -42,6 +43,8 @@ jobs:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: macos-runner-prebuilt
build-command: pnpm build:xcuitest:macos
xcuitest-platform: macos
xcuitest-destination: platform=macOS,arch=arm64

- name: Build macOS helper
run: pnpm build:macos-helper
Expand All @@ -54,4 +57,5 @@ jobs:
uses: ./.github/actions/upload-agent-device-artifacts
with:
artifact-name: macos-artifacts
agent-home-dir: ${{ steps.apple-replay.outputs.agent-home-dir }}
agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }}
runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
2 changes: 1 addition & 1 deletion .github/workflows/perf-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
timeout-minutes: 80
env:
IOS_RUNTIME_VERSION: "26.2"
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
steps:
- name: Checkout
Expand All @@ -52,7 +53,6 @@ jobs:
build-command: sh ./scripts/build-xcuitest-apple.sh
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
clean-derived: "1"

- name: Boot iOS test simulator
uses: ./.github/actions/boot-ios-test-simulator
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/replays-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
timeout-minutes: 80
env:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
steps:
- name: Checkout
Expand All @@ -69,7 +70,6 @@ jobs:
build-command: sh ./scripts/build-xcuitest-apple.sh
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
build-on-miss: 'false'

- name: Boot iOS test simulator
uses: ./.github/actions/boot-ios-test-simulator
Expand Down Expand Up @@ -97,13 +97,15 @@ jobs:
uses: ./.github/actions/upload-agent-device-artifacts
with:
artifact-name: replay-nightly-ios-artifacts
agent-home-dir: ${{ steps.apple-replay.outputs.agent-home-dir }}
agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }}
runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}

nightly-macos:
name: macOS Replay Suite
runs-on: macos-26
timeout-minutes: 80
env:
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/macos-runner-derived
steps:
- name: Checkout
Expand All @@ -119,6 +121,8 @@ jobs:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: macos-runner-prebuilt
build-command: pnpm build:xcuitest:macos
xcuitest-platform: macos
xcuitest-destination: platform=macOS,arch=arm64

- name: Build macOS helper
run: pnpm build:macos-helper
Expand All @@ -131,4 +135,5 @@ jobs:
uses: ./.github/actions/upload-agent-device-artifacts
with:
artifact-name: replay-nightly-macos-artifacts
agent-home-dir: ${{ steps.apple-replay.outputs.agent-home-dir }}
agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }}
runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect

## Toolchain Snapshot
- Package manager: `pnpm` only. Do not add or restore `package-lock.json`.
- Packaged installs use `~/.agent-device` as the implicit daemon state dir. Source checkouts default to a worktree-scoped daemon state dir under `~/.agent-device/dev/<basename-slug>-<hash>` so local branches do not block each other. Use `pnpm daemon:state-dir` to print the effective path for the current worktree; `--state-dir` and `AGENT_DEVICE_STATE_DIR` remain authoritative overrides. Daemons are isolated by worktree, but devices are not; target different devices or simulators when running multiple worktrees concurrently. After pulling the worktree-scoped daemon change for the first time, stop any legacy source-checkout daemon with `AGENT_DEVICE_STATE_DIR=~/.agent-device pnpm clean:daemon`.
- Runtime baseline is Node >= 22. Prefer built-in Node APIs such as global `fetch`, Web Streams, and `AbortSignal.timeout` over compatibility wrappers unless the surrounding code needs a lower-level transport.
- Lint/format stack is OXC:
- config: `.oxlintrc.json`, `.oxfmtrc.json`
Expand Down Expand Up @@ -187,7 +188,7 @@ Command-only flags (like `find --first`) that do not flow to the platform layer

## Manual Device Session Hygiene
- Treat every manually opened `agent-device` session as a resource that must be closed, including exploratory sessions and failed verification attempts.
- For experiments, use a purpose-specific session name and, when practical, an isolated `--state-dir` under `/private/tmp` so stale metadata does not poison the default daemon.
- For experiments, use a purpose-specific session name and, when practical, an isolated `--state-dir` under `/private/tmp` when you need cleanup isolation beyond the current worktree's default daemon.
- Keep track of each opened session in the working notes. Before final response, close each one with the same flags used to open it.
- If `close` or a later command is blocked by stale daemon metadata, inspect running processes first with `ps -ax | rg "agent-device|xcodebuild test-without-building"`. Stop only exact stale PIDs that belong to the verification run, then run `pnpm clean:daemon`.
- If cleanup cannot be completed, report the remaining session name, state dir, process IDs, and metadata paths as a blocker.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"scripts": {
"build": "rslib build",
"clean:daemon": "node --experimental-strip-types scripts/clean-daemon.ts",
"daemon:state-dir": "node --experimental-strip-types scripts/print-daemon-state-dir.ts",
"clean:xcuitest": "node scripts/clean-xcuitest-derived.mjs",
"clean:xcuitest:ios": "node scripts/clean-xcuitest-derived.mjs ios",
"clean:xcuitest:macos": "node scripts/clean-xcuitest-derived.mjs macos",
Expand Down
5 changes: 5 additions & 0 deletions scripts/print-daemon-state-dir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { resolveDaemonPaths } from '../src/daemon/config.ts';

const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR);

process.stdout.write(`${paths.baseDir}\n`);
3 changes: 2 additions & 1 deletion scripts/write-xcuitest-cache-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';

const [platform, derivedPath, destination] = process.argv.slice(2);
const args = process.argv.slice(2);
const [platform, derivedPath, destination] = args;

if (!platform || !derivedPath || !destination) {
console.error('Usage: write-xcuitest-cache-metadata.mjs <ios|macos|tvos> <derived> <destination>');
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/cli-session-state-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
import { runCliCapture } from './cli-capture.ts';

test('session state-dir prints the resolved source-checkout daemon state dir without daemon startup', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-state-dir-'));
const home = path.join(root, 'home');
fs.mkdirSync(home, { recursive: true });
try {
const result = await runCliCapture(['session', 'state-dir', '--json'], {
env: { HOME: home },
});

assert.equal(result.code, null);
assert.equal(result.calls.length, 0);
const payload = JSON.parse(result.stdout) as { success: boolean; data: { stateDir: string } };
assert.equal(payload.success, true);
assert.match(payload.data.stateDir, /\/\.agent-device\/dev\//);
assert.equal(payload.data.stateDir.startsWith(home), true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

test('session state-dir respects explicit state dir overrides', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-state-dir-'));
try {
const result = await runCliCapture(['session', 'state-dir', '--state-dir', './custom-state'], {
cwd: root,
});

assert.equal(result.code, null);
assert.equal(result.calls.length, 0);
assert.equal(result.stdout.trim(), path.join(fs.realpathSync.native(root), 'custom-state'));
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
22 changes: 22 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
parsedBatchSteps = readBatchSteps(flags);
}

if (tryPrintSessionStateDir({ command, positionals, flags: effectiveFlags, daemonPaths })) {
return;
}

if (shouldResolveRemoteAuth(command)) {
const authResolution = await resolveRemoteAuthForCli({
command,
Expand Down Expand Up @@ -374,6 +378,24 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
);
}

function tryPrintSessionStateDir(options: {
command: string;
positionals: string[];
flags: CliFlags;
daemonPaths: ReturnType<typeof resolveDaemonPaths>;
}): boolean {
if (options.command !== 'session' || options.positionals[0] !== 'state-dir') return false;
if (options.positionals.length > 1) {
throw new AppError('INVALID_ARGS', 'session state-dir does not accept additional arguments.');
}
if (options.flags.json) {
printJson({ success: true, data: { stateDir: options.daemonPaths.baseDir } });
} else {
process.stdout.write(`${options.daemonPaths.baseDir}\n`);
}
return true;
}

function isDebugRequested(argv: string[]): boolean {
try {
const parsed = parseRawArgs(argv);
Expand Down
5 changes: 3 additions & 2 deletions src/commands/cli-grammar/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,11 @@ function installInputFromCli(
};
}

function readSessionAction(value: string | undefined): 'list' {
function readSessionAction(value: string | undefined): 'list' | 'state-dir' {
const action = value ?? 'list';
if (action === 'list') return action;
throw new AppError('INVALID_ARGS', 'session only supports list');
if (action === 'state-dir') return action;
throw new AppError('INVALID_ARGS', 'session only supports list or state-dir');
}

function openPositionals(input: CommandInput): string[] {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/command-descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const COMMAND_DESCRIPTIONS = {
devices: 'List available devices.',
boot: 'Boot or prepare a selected device without using CLI positional arguments.',
apps: 'List installed apps.',
session: 'List active sessions.',
session: 'List active sessions or print daemon state directory.',
open: 'Open an app, deep link, URL, or platform surface.',
prepare: 'Prepare platform helper infrastructure.',
close: 'Close an app or end the active session.',
Expand Down
64 changes: 64 additions & 0 deletions src/daemon/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
import { resolveDaemonPaths } from '../config.ts';

test('resolveDaemonPaths keeps explicit state directories authoritative', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-config-home-'));
try {
const paths = resolveDaemonPaths('~/custom-daemon', { env: { HOME: home } });
assert.equal(paths.baseDir, path.join(home, 'custom-daemon'));
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});

test('resolveDaemonPaths keeps packaged installs on the global daemon state directory', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-config-home-'));
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-package-root-'));
try {
fs.writeFileSync(path.join(packageRoot, 'package.json'), '{"name":"agent-device"}\n');

const paths = resolveDaemonPaths(undefined, {
env: { HOME: home },
projectRoot: packageRoot,
});

assert.equal(paths.baseDir, path.join(home, '.agent-device'));
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(packageRoot, { recursive: true, force: true });
}
});

test('resolveDaemonPaths scopes source checkout defaults by project root', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-config-home-'));
const firstRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-source-a-'));
const secondRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-source-b-'));
try {
for (const root of [firstRoot, secondRoot]) {
fs.writeFileSync(path.join(root, 'package.json'), '{"name":"agent-device"}\n');
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'daemon.ts'), 'export {};\n');
}

const firstPaths = resolveDaemonPaths(undefined, {
env: { HOME: home },
projectRoot: firstRoot,
});
const secondPaths = resolveDaemonPaths(undefined, {
env: { HOME: home },
projectRoot: secondRoot,
});

assert.match(firstPaths.baseDir, /^.+\/\.agent-device\/dev\/agent-device-source-a-/);
assert.match(secondPaths.baseDir, /^.+\/\.agent-device\/dev\/agent-device-source-b-/);
assert.notEqual(firstPaths.baseDir, secondPaths.baseDir);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(firstRoot, { recursive: true, force: true });
fs.rmSync(secondRoot, { recursive: true, force: true });
}
});
Loading
Loading