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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,6 @@ In practice, most work follows the same pattern:

In non-JSON mode, core mutating commands print a short success acknowledgment so agents and humans can distinguish successful actions from dropped or silent no-ops.

## Performance Metrics

`agent-device perf --json` (alias: `metrics --json`) returns session-scoped metrics data.

- Startup timing is available on iOS and Android from `open` command round-trip sampling.
- Android app sessions also sample CPU (`adb shell dumpsys cpuinfo`) and memory (`adb shell dumpsys meminfo <package>`) when the session has an active app package context.
- Apple app sessions on macOS and iOS simulators sample CPU and memory from process snapshots resolved from the active app bundle ID.
- Physical iOS devices sample CPU and memory from a short `xcrun xctrace` Activity Monitor capture against the connected device, so `perf` can take a few seconds longer there than on simulators or macOS.

## Where To Go Next

For people:
Expand Down
1 change: 1 addition & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,4 @@ Use this skill as a router with mandatory defaults. Read this file first. For no
- Need screenshots, diff, recording, replay maintenance, or perf data: [references/verification.md](references/verification.md)
- Need desktop surfaces, menu bar behavior, or macOS-specific interaction rules: [references/macos-desktop.md](references/macos-desktop.md)
- Need remote HTTP transport, `--remote-config` launches, or tenant leases on a remote macOS host: [references/remote-tenancy.md](references/remote-tenancy.md)
This includes remote React Native runs where `agent-device` now prepares Metro locally and manages the local Metro companion tunnel automatically.
2 changes: 2 additions & 0 deletions skills/agent-device/references/remote-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ agent-device open com.example.myapp --remote-config ./agent-device.remote.json -
```

- This is the preferred remote launch path for sandbox or cloud agents.
- `agent-device` prepares local Metro and auto-starts the local Metro companion tunnel when the remote bridge needs a path back to the developer machine.
- `close --remote-config ...` cleans up the managed companion process for that project/profile, but leaves the developer’s Metro server running.
- For Android React Native relaunch flows, install or reinstall the APK first, then relaunch by installed package name.
- Do not use `open <apk|aab> --relaunch`; remote runtime hints are applied through the installed app sandbox.

Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,9 @@ test('open with --remote-config prepares Metro and forwards inline runtime hints
publicBaseUrl: 'https://sandbox.example.test',
proxyBaseUrl: 'https://proxy.example.test',
bearerToken: undefined,
launchUrl: undefined,
companionProfileKey: remoteConfigPath,
companionConsumerKey: undefined,
port: 9090,
listenHost: undefined,
statusHost: undefined,
Expand Down
259 changes: 259 additions & 0 deletions src/__tests__/client-metro-auto-companion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { afterEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

vi.mock('../client-metro-companion.ts', () => ({
ensureMetroCompanion: vi.fn(),
}));

import { ensureMetroCompanion } from '../client-metro-companion.ts';
import { prepareMetroRuntime } from '../client-metro.ts';

afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

test('prepareMetroRuntime starts the local companion only after bridge setup needs it', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-'));
const projectRoot = path.join(tempRoot, 'project');
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({
name: 'metro-auto-companion-test',
private: true,
dependencies: {
'react-native': '0.0.0-test',
},
}),
);

vi.mocked(ensureMetroCompanion).mockResolvedValue({
pid: 123,
spawned: true,
statePath: path.join(projectRoot, '.agent-device', 'metro-companion.json'),
logPath: path.join(projectRoot, '.agent-device', 'metro-companion.log'),
});

const fetchMock = vi.fn();
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => 'packager-status:running',
});
fetchMock.mockResolvedValueOnce({
ok: false,
status: 409,
text: async () => JSON.stringify({ ok: false, error: 'Metro companion is not connected' }),
});
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
ok: true,
data: {
enabled: true,
base_url: 'https://proxy.example.test',
status_url: 'https://proxy.example.test/status',
bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
ios_runtime: {
metro_host: '127.0.0.1',
metro_port: 8081,
metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
},
android_runtime: {
metro_host: '10.0.2.2',
metro_port: 8081,
metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=android',
},
upstream: {
bundle_url:
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
host: '127.0.0.1',
port: 8081,
status_url: 'http://127.0.0.1:8081/status',
},
probe: {
reachable: true,
status_code: 200,
latency_ms: 5,
detail: 'ok',
},
},
}),
});
vi.stubGlobal('fetch', fetchMock);

try {
const result = await prepareMetroRuntime({
projectRoot,
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'shared-token',
metroPort: 8081,
reuseExisting: true,
installDependenciesIfNeeded: false,
});

assert.equal(result.started, false);
assert.equal(result.reused, true);
assert.equal(result.bridge?.enabled, true);
assert.equal(
result.iosRuntime.bundleUrl,
'https://proxy.example.test/index.bundle?platform=ios',
);
assert.equal(
result.androidRuntime.bundleUrl,
'https://proxy.example.test/index.bundle?platform=android',
);
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 1);
assert.deepEqual(vi.mocked(ensureMetroCompanion).mock.calls[0]?.[0], {
projectRoot,
serverBaseUrl: 'https://proxy.example.test',
bearerToken: 'shared-token',
localBaseUrl: 'http://127.0.0.1:8081',
launchUrl: undefined,
profileKey: undefined,
consumerKey: undefined,
env: process.env,
});
assert.equal(fetchMock.mock.calls.length, 3);
assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge');
assert.equal(fetchMock.mock.calls[2]?.[0], 'https://proxy.example.test/api/metro/bridge');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});

test('prepareMetroRuntime preserves the initial bridge error if companion startup fails', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-error-'));
const projectRoot = path.join(tempRoot, 'project');
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({
name: 'metro-auto-companion-error-test',
private: true,
dependencies: {
'react-native': '0.0.0-test',
},
}),
);

vi.mocked(ensureMetroCompanion).mockRejectedValue(new Error('companion startup failed'));

const fetchMock = vi.fn();
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => 'packager-status:running',
});
fetchMock.mockRejectedValueOnce(new Error('initial bridge auth failed'));
vi.stubGlobal('fetch', fetchMock);

try {
await assert.rejects(
() =>
prepareMetroRuntime({
projectRoot,
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'shared-token',
metroPort: 8081,
reuseExisting: true,
installDependenciesIfNeeded: false,
probeTimeoutMs: 10,
}),
(error: unknown) => {
assert(error instanceof Error);
assert.match(error.message, /bridgeError=companion startup failed/);
assert.match(error.message, /initialBridgeError=initial bridge auth failed/);
assert.doesNotMatch(error.message, /metroCompanionLog=/);
return true;
},
);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});

test('prepareMetroRuntime fails fast on non-retryable bridge errors after companion startup', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-'));
const projectRoot = path.join(tempRoot, 'project');
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({
name: 'metro-auto-companion-non-retryable-test',
private: true,
dependencies: {
'react-native': '0.0.0-test',
},
}),
);

vi.mocked(ensureMetroCompanion).mockResolvedValue({
pid: 123,
spawned: true,
statePath: path.join(projectRoot, '.agent-device', 'metro-companion.json'),
logPath: path.join(projectRoot, '.agent-device', 'metro-companion.log'),
});

const fetchMock = vi.fn();
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => 'packager-status:running',
});
fetchMock.mockResolvedValueOnce({
ok: false,
status: 409,
text: async () => JSON.stringify({ ok: false, error: 'Metro companion is not connected' }),
});
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
text: async () => JSON.stringify({ ok: false, error: 'invalid token' }),
});
vi.stubGlobal('fetch', fetchMock);
vi.useFakeTimers();

try {
let settled: unknown = 'pending';
const preparePromise = prepareMetroRuntime({
projectRoot,
publicBaseUrl: 'https://public.example.test',
proxyBaseUrl: 'https://proxy.example.test',
proxyBearerToken: 'shared-token',
metroPort: 8081,
reuseExisting: true,
installDependenciesIfNeeded: false,
probeTimeoutMs: 10,
});
void preparePromise.then(
() => {
settled = 'resolved';
},
(error) => {
settled = error;
},
);

await vi.advanceTimersByTimeAsync(1);

assert.notEqual(settled, 'pending');
assert(settled instanceof Error);
assert.match(settled.message, /bridgeError=\/api\/metro\/bridge failed \(401\)/);
assert.match(settled.message, /initialBridgeError=\/api\/metro\/bridge failed \(409\)/);
assert.match(settled.message, /metroCompanionLog=.*metro-companion\.log/);
assert.equal(fetchMock.mock.calls.length, 3);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
Loading
Loading