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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- Added `agent-device acp`: a deterministic stdio ACP (Agent Client Protocol) agent for Zed and other ACP clients. Prompt lines are agent-device commands in CLI syntax, executed through the same client path as MCP tools, with tool-call streaming, sticky per-session target flags, and inline screenshot images.

## 0.15.0

- Breaking: `apps` discovery and public app-list helpers now default to user-installed apps. Use `--all` or `filter: 'all'` to include system/OEM apps.
Expand Down
7 changes: 4 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
start-time/command reads, process listing, process-tree expansion, PID de-duplication, and
best-effort signaling. It must not own domain cleanup policy such as browser ownership markers,
runner lease reclamation, daemon takeover checks, or app-log PID metadata verification.
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints.
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, ACP, and batch entrypoints.
- MCP/ACP adapters: MCP is the structured-tool projection of the command surface; ACP is the deterministic prompt-line projection for ACP clients. Both reuse command metadata and `AgentDeviceClient` execution, and neither owns daemon route policy.
- Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution.
- Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior, distinct from the public command surface and daemon command registry. The Swift runner traits classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the alert command as read-only only for its `get` action. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type.
- Coordinate-first resolved element activation: iOS/macOS runner interaction pattern where a selector or text query resolves the semantic `XCUIElement`, then activation uses the element's resolved center coordinate when a frame is available. This keeps target selection semantic while avoiding `XCUIElement.tap()` post-action element re-resolution after normal navigation. tvOS remains focus/remote-driven.
Expand All @@ -69,8 +70,8 @@ The perfect-shape refactor is complete and merged. Its end-state:

- Two derivation registries. One `CommandDescriptor` per command
(`src/core/command-descriptor/registry.ts`) is the single declaration site from which the public
catalog, capability matrix, daemon command registry, batch allowlist, MCP tools, CLI schema, and
the Node client surface are *derived* by parity-tested projection; the dispatch `switch` became a
catalog, capability matrix, daemon command registry, batch allowlist, MCP tools, ACP available
commands, CLI schema, and the Node client surface are *derived* by parity-tested projection; the dispatch `switch` became a
total map keyed on the command-name union (a missing handler is a compile error). One
`PlatformPlugin` per platform family (`src/core/platform-plugin/`) stops core/daemon from branching
on platform, with the Apple plugin the first instance. See
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Snapshots come from the app's accessibility tree, so high-quality labels, roles,

## Next Steps

- **Set up your agent**: run the CLI from Cursor, Codex, Claude Code, Windsurf, or another agent terminal. For skills, rules, direct MCP tools, and client-specific setup, see [AI Agent Setup](https://oss.callstack.com/agent-device/docs/agent-setup).
- **Set up your agent**: run the CLI from Cursor, Codex, Claude Code, Windsurf, or another agent terminal. For skills, rules, direct MCP tools (`agent-device mcp`), the ACP agent for Zed and other ACP clients (`agent-device acp`), and client-specific setup, see [AI Agent Setup](https://oss.callstack.com/agent-device/docs/agent-setup).
- **Try the sample app**: clone the repo and run the bundled Expo fixture when you want a guided first dogfood run with screenshots, replay, and performance evidence. See [Quick Start](https://oss.callstack.com/agent-device/docs/quick-start).
- **Go deeper**: use [Commands](https://oss.callstack.com/agent-device/docs/commands), [Replay & E2E](https://oss.callstack.com/agent-device/docs/replay-e2e), and [Debugging & Profiling](https://oss.callstack.com/agent-device/docs/debugging-profiling) for production workflows.

Expand Down
17 changes: 9 additions & 8 deletions docs/adr/0003-daemon-command-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ registry instead of recreating command string sets. Handler modules own executio
not export duplicate coverage tables to prove route membership.

The daemon registry is internal-only. It must not define CLI grammar, Node.js client options, MCP
schemas, user-facing help, or platform capability support. Those remain owned by the command
contract, projection, help, and capability modules.
schemas, ACP slash/prompt-line behavior, user-facing help, or platform capability support. Those
remain owned by the command contract, projection, help, and capability modules.

## Alternatives Considered

Expand Down Expand Up @@ -57,9 +57,9 @@ owns the rationale so future changes do not need to infer it from agent instruct
## Update (2026-06): single-declaration / derivation model

A later proposal (the `CommandDescriptor` direction, now [ADR 0008](0008-command-descriptor-registry.md)) unifies a command's
declarations so the public catalog, capability matrix, CLI/MCP projections, batch allowlist, and this
daemon registry are *derived* from one registration site, to remove the cross-table drift that several
of these surfaces are kept aligned against by convention.
declarations so the public catalog, capability matrix, CLI/MCP/ACP projections, batch allowlist, and
this daemon registry are *derived* from one registration site, to remove the cross-table drift that
several of these surfaces are kept aligned against by convention.

**This ADR's decision stands.** Its boundary is about *ownership* and the *predicate interface*, not
about the physical file a trait is typed in. "Separate source of truth" means separately owned and
Expand All @@ -75,9 +75,10 @@ derived daemon registry is therefore permitted **only if** it preserves all of t
predicates (`getDaemonCommandRoute`, `isLeaseAdmissionExempt`, `shouldLockSessionExecution`, …). The
daemon registry remains their sole exposer; derivation changes how the backing table is *built*, not
how it is *read*.
3. **No leakage into public projections.** The catalog/CLI/MCP/help/capability projections must be
type-prevented from reading daemon-only traits, and the daemon registry must still not define CLI
grammar, Node.js options, MCP schemas, user-facing help, or capability support.
3. **No leakage into public projections.** The catalog/CLI/MCP/ACP/help/capability projections must
be type-prevented from reading daemon-only traits, and the daemon registry must still not define
CLI grammar, Node.js options, MCP schemas, ACP prompt-line behavior, user-facing help, or
capability support.
4. **One declaration per concern, enforced by types.** The single registration site must make a missing
or duplicated daemon trait a *compile error* — replacing today's "aligned by convention". This is the
structural improvement that justifies derivation over a separately hand-authored table.
Expand Down
31 changes: 20 additions & 11 deletions docs/adr/0008-command-descriptor-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ A command's identity is restated, by hand, across roughly ten tables that must s
convention: `PUBLIC_COMMANDS` (`src/command-catalog.ts`), the per-command metadata and family facets
(`src/commands/**`), the capability matrix (`src/core/capabilities.ts`), the daemon command registry
(`src/daemon/daemon-command-registry.ts`, ADR 0003), the structured-batch allowlist
(`src/batch-policy.ts`), the MCP exposure sets, the Node client interface and impl (`src/client-types.ts`,
`src/client.ts`), and the generic-dispatch `switch` (`src/core/dispatch.ts`, whose `default: throw` makes a
missing or renamed command a runtime error, not a compile error). Adding one command touches ~24 files, the
argument shape is (de)serialized ~4 times, and the gesture set is retyped in three places.
(`src/batch-policy.ts`), the MCP exposure sets, ACP available-command projection, the Node client
interface and impl (`src/client-types.ts`, `src/client.ts`), and the generic-dispatch `switch`
(`src/core/dispatch.ts`, whose `default: throw` makes a missing or renamed command a runtime error,
not a compile error). Adding one command touches ~24 files, the argument shape is (de)serialized
~4 times, and the gesture set is retyped in three places.

The codebase already proves the cure works for part of this: the `CommandFamilyFacet`
(`src/commands/family/`) derives the MCP tools, the CLI schema, and the batch writer from a single array.
It simply stops at the command-surface boundary; everything past it is hand-maintained.
(`src/commands/family/`) derives the MCP tools, ACP runnable command list, CLI schema, and the batch
writer from a single array. It simply stops at the command-surface boundary; everything past it is
hand-maintained.

ADR 0003 deliberately separated daemon route/policy into its own internally-owned registry with a small
predicate interface, and its 2026-06 update set four invariants that any single-declaration/derivation
Expand All @@ -28,16 +30,23 @@ model must preserve. This ADR is that model.
Introduce one `CommandDescriptor` per command that **composes facets owned by their domains** and from which
every consumer table is **derived** by pure, parity-tested projection:

- The descriptor composes a `surface` facet (owned by `src/commands/**`: identity, CLI schema/reader, MCP),
a `capability` facet (owned by `src/core/capabilities`), and a `daemon` facet (route + request-policy
traits, **owned under `src/daemon/`** per ADR 0003), plus a typed result.
- The public catalog, capability matrix, daemon command registry, batch allowlist, MCP tool list, CLI
schema, and the Node client surface become pure projections of the descriptor set. The
- The descriptor composes a `surface` facet (owned by `src/commands/**`: identity, CLI schema/reader,
MCP, and ACP prompt-line exposure), a `capability` facet (owned by `src/core/capabilities`), and a
`daemon` facet (route + request-policy traits, **owned under `src/daemon/`** per ADR 0003), plus a
typed result.
- The public catalog, capability matrix, daemon command registry, batch allowlist, MCP tool list, ACP
available-command list, CLI schema, and the Node client surface become pure projections of the
descriptor set. The
`src/core/dispatch.ts` `switch` is replaced by a total map keyed on the command-name union, so a missing
handler is a compile error.
- The cross-process `invoke` (client) and in-daemon `execute` seams stay distinct; the process boundary is
never collapsed.

ACP is a consumer of the command surface, not a new command registry. It advertises the same runnable
commands through `available_commands_update` and parses prompt text into CLI-shaped command lines
before executing through the CLI command reader, existing command contracts, and `AgentDeviceClient`.
Its slash-command syntax is an ACP transport affordance, not a separate command identity.

This **composes with**, and is bound by, ADR 0003's four invariants: daemon-owned declaration (never inlined
into the public surface), the predicate interface unchanged, no leakage of daemon-only traits into public
projections, and one declaration per concern enforced by the type system.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
| [0005 iOS Runner Interaction Lifecycle](0005-ios-runner-interaction-lifecycle.md) | XCTest runner sessions, leases, adoption, idle-stop |
| [0006 Daemon RPC Protocol Version](0006-daemon-rpc-protocol-version.md) | remote daemon HTTP/JSON-RPC compatibility |
| [0007 Remote Device Leases](0007-remote-device-leases.md) | leases, tenancy, provider-owned devices |
| [0008 Command Descriptor Registry](0008-command-descriptor-registry.md) | adding/changing a command, any surface projection (CLI/MCP/client/batch), timeout policy |
| [0008 Command Descriptor Registry](0008-command-descriptor-registry.md) | adding/changing a command, any surface projection (CLI/MCP/ACP/client/batch), timeout policy |
| [0009 Apple Platform Consolidation](0009-apple-platform-consolidation.md) | Apple platform family, apple/appleOs axes, the apple-leak guard |
| [0010 Error system conventions](0010-error-system.md) | error codes, hints, normalizeError, typed error signals |
| [0011 Interaction Guarantee Contract](0011-interaction-guarantee-contract.md) | interaction dispatch paths, fast paths, guards, the guarantee matrix, parity tables |
Expand Down
73 changes: 73 additions & 0 deletions src/acp/__tests__/prompt-lines.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import { tmpdir } from 'node:os';
import { test } from 'vitest';
import { applyStickyFlags, parsePromptCommandLines, type RunnableLine } from '../prompt-lines.ts';

const CWD = tmpdir();

function parseOne(text: string): ReturnType<typeof parsePromptCommandLines>[number] {
const lines = parsePromptCommandLines(text, { cwd: CWD });
assert.equal(lines.length, 1);
return lines[0]!;
}

test('splits prompt text into command lines, skipping blanks and comments', () => {
const lines = parsePromptCommandLines('\n# comment\ndevices\n\nsnapshot -i\n', { cwd: CWD });
assert.deepEqual(
lines.map((line) => (line.kind === 'command' ? line.command : line.error)),
['devices', 'snapshot'],
);
});

test('strips an optional leading agent-device token', () => {
const line = parseOne('agent-device devices');
assert.equal(line.kind, 'command');
assert.equal((line as RunnableLine).command, 'devices');
});

test('accepts ACP slash commands for advertised commands', () => {
const lines = parsePromptCommandLines('/devices\n/snapshot -i', { cwd: CWD });
assert.deepEqual(
lines.map((line) => {
assert.equal(line.kind, 'command');
return (line as RunnableLine).command;
}),
['devices', 'snapshot'],
);
assert.equal((lines[1] as RunnableLine).flags.snapshotInteractiveOnly, true);
});

test('tokenizes quoted arguments with spaces like replay scripts', () => {
const line = parseOne('fill "id:search" "hello world" --platform ios');
assert.equal(line.kind, 'command');
const runnable = line as RunnableLine;
assert.deepEqual(runnable.positionals, ['id:search', 'hello world']);
assert.equal(runnable.flags.platform, 'ios');
});

test('rejects unknown commands with per-line guidance', () => {
const line = parseOne('frobnicate');
assert.equal(line.kind, 'error');
assert.match((line as { error: string }).error, /Unknown command: frobnicate/);
});

test('rejects CLI-only commands that are not automatable through ACP', () => {
for (const command of ['mcp', 'acp', 'connect', 'auth']) {
const line = parseOne(command);
assert.equal(line.kind, 'error', `expected ${command} to be rejected`);
}
});

test('sticky flags fill in target selection and explicit flags win', () => {
const bare = parseOne('snapshot -i') as RunnableLine;
assert.equal(bare.kind, 'command');
assert.deepEqual(bare.providedSticky, {});
const merged = applyStickyFlags(bare, { platform: 'ios', session: 'demo' });
assert.equal(merged.platform, 'ios');
assert.equal(merged.session, 'demo');

const explicit = parseOne('snapshot -i --platform android') as RunnableLine;
assert.equal(explicit.kind, 'command');
assert.deepEqual(explicit.providedSticky, { platform: 'android' });
assert.equal(applyStickyFlags(explicit, { platform: 'ios' }).platform, 'android');
});
Loading
Loading