Automating DeviceTerm

August 10, 2026 · View on GitHub

Use the deviceterm CLI to control DeviceTerm itself: open and arrange tabs, panes, and windows, inspect workspace state, drive other tabs from an orchestrator tab, and wait on events. The caller can be a person at a prompt, a script, an agent, or a program coordinating several agents; the commands and the rules are the same.

Run these commands from a shell inside a DeviceTerm tab. The tab provides the session identity that authorizes them.

Driving the device inside a pane (touch, keys, buttons, accessibility) is covered in USAGE.md. The JSON shapes, exit codes, and stability promises behind every command here are defined in INTEGRATION.md.

Contents

Understand Tabs, Sessions, and Authority

Authority has three tiers. Device panes are contained to the session that currently owns them. Workspace commands reach any public tab the caller can see. Reading another tab's contents or typing into it requires a live orchestration grant that only the GUI can issue.

The grant sits where it does because capture and input reach another session's contents: what its terminal shows and what runs in it. Workspace commands touch arrangement and lifecycle, never contents, and the structure of public tabs is deliberately shared. A tab that should be untouchable opts out of every tier with tab set-private.

Know Your Session

DeviceTerm injects a session identity into every terminal pane's shell: DEVICETERM_SESSION holds the session id, DEVICETERM_SESSION_CAP holds the session credential, and companion variables locate the daemon socket and the per-session shim directory. The CLI reads them transparently; no command takes a credential flag or operand.

A terminal split is its own session. The GUI treats the tab as one workspace, but each terminal pane carries a separate CLI identity with its own credential.

Device panes are linked to an owning session, and every pane-targeted command is authorized against the current ownership. Your session drives only its own panes; a pane owned by a sibling terminal pane is refused with the same error as an unknown pane, even inside your own tab.

The session's role is readable without a daemon round-trip:

echo "$DEVICETERM_SESSION_ROLE"

Trust the Terminal, Not the Token

The capability in DEVICETERM_SESSION_CAP is one authentication factor, not proof of origin. It is inherited environment, readable by any process running under your uid, so possession alone establishes nothing. On every scoped request the daemon also checks the caller's kernel provenance: its POSIX session, controlling terminal, and session-leader start time must match the terminal the session is bound to.

What earns trust is running in the session's terminal, not descending from its shell. Everything you start in the tab shares that terminal and may control the session, which is why the cap is deliberately visible to child processes; don't strip it from a subprocess environment. A helper that detaches from the terminal (setsid, a daemonized process) stops authenticating even though the shell is its ancestor, and a process elsewhere that copied the cap is refused.

Escalate Only Through the GUI

Cross-tab input and capture require a live orchestration grant, and only the GUI issues one, when a person opens an orchestrator tab. There is no CLI verb for escalation, and constructing the raw request by hand does not work: the daemon refuses it from anything but the validated GUI.

A role string such as "orchestrator" is descriptive metadata. Without a live grant, cross-tab input and capture fail with error.role_violation even when DEVICETERM_SESSION_ROLE says orchestrator.

Open an Orchestrator Tab covers the grant lifecycle.

Control the Workspace

Open Tabs, Panes, and Windows

Create workspace surfaces from a script or agent:

deviceterm tab open --cwd "$PWD" --cmd 'make test'
deviceterm pane open --terminal --cwd "$PWD"
deviceterm window open

tab open mints a fresh tab, pane open --terminal splits the current tab with another terminal pane, and window open mints a new window holding one fresh tab. --cmd is typed into the new shell after attach, so the command runs once and the shell stays interactive.

A success receipt means the GUI accepted the mutation for asynchronous processing; it does not prove the change completed, and it does not return the new session id. Confirm the outcome with the list commands; the event stream cannot confirm it, because another session's lifecycle events are not delivered to yours. Receipt shapes are defined in workspace receipts.

Arrange, Select, and Close Surfaces

Reorder, retitle, focus, and close surfaces by reference:

deviceterm tab move --tab abc123 --to 0
deviceterm tab rename "auth-feature"
deviceterm tab select --tab abc123
deviceterm window focus --window 2
deviceterm tab close --mode shutdown

tab move also accepts --to-window <ref> to move the tab to another window. tab close and window close take --mode <detach|shutdown> to decide what happens to owned Simulators, the same decision the GUI close prompt offers.

These verbs reach any public tab visible to the caller, not only your own. A tab close --tab <ref> naming another session's public tab closes it, ending whatever was running there; mark a tab private when other sessions should not be able to touch it.

pane close and pane info resolve Simulator panes only. Close a physical-device pane in the GUI.

pane rename and pane move are not implemented; see unsupported workspace verbs.

Discover State

List Tabs, Panes, Windows, and Devices

deviceterm tabs list
deviceterm panes list
deviceterm windows list
deviceterm devices list

tabs list returns one row per live terminal session, so a split tab produces several rows. It shows public sessions plus your own private ones. tabs current prints only the caller's row.

panes list returns the device panes owned by the calling session. windows list returns the caller's own window; add --all for every window visible to the caller.

devices list reports DeviceTerm-owned booted Simulators and connected physical devices. An externally booted Simulator stays absent until you attach it; see device roster rows.

Pass --json to any list for the machine-readable row shapes defined in Discovery and State.

Check Health With doctor

deviceterm doctor

doctor checks the session environment, the xcrun shim, the daemon socket and handshake, session authentication, linked device panes, and the methods the daemon admits for this session. Use --json in a script and branch on the exit status. The report shape and check names are defined in the doctor report.

Diagnose Version Skew

After an upgrade, confirm the live daemon and the bundled CLI agree:

deviceterm version --json

Compare the daemon and rpcWire fields. A missing daemon field means the version probe did not complete; it does not prove that no daemon is reachable. Field semantics and a ready-made check are in the version report.

Orchestrate Other Tabs

Open an Orchestrator Tab

Open the tab with Shell ▸ Open Orchestrator Tab or ⇧⌘T.

The GUI issues that tab's terminal session a live orchestration grant. The grant lives in daemon memory and is checked on every cross-tab request; it is revoked when the tab closes, when the issuing GUI connection is lost, or when the session ends. An ordinary tab receives error.role_violation for cross-tab capture and input, and the CLI cannot grant authority to itself.

Send Input to Another Tab

List the visible sessions and target one by short id:

deviceterm tabs list
TARGET_TAB="abc123"
deviceterm tab send-input --tab "$TARGET_TAB" 'make test\n'

Replace abc123 with a short id printed by tabs list.

Instant input is dispatched before the command returns. With --type-delay <ms>, typing is animated one character at a time and the command returns as soon as the typing is enqueued, so it may still be running. Neither result confirms that the target shell executed anything. Receipt fields and pacing limits are defined in send input.

Capture Another Tab

deviceterm tab capture --tab "$TARGET_TAB"

The capture is the target's currently visible terminal viewport; scrollback is not included. Human output is the raw text, so a redirect saves the screen; --json wraps it as {text}. See capture a viewport.

Make a Tab Private

Mark the current tab private when other sessions should not see or control it:

deviceterm tab set-private true

Every terminal session in the tab changes together. Other sessions cannot list the private tab or its panes, resolve its references, capture it, or send input to it; your own sessions keep access. Orchestration grants do not bypass privacy, so an orchestrator tab cannot capture or type into a tab after that target becomes private.

Only a tab the caller owns a terminal in can be flipped. The receipt's committed field distinguishes a confirmed change from one the GUI is still converging on; see set privacy.

Wait on Events

Choose Polling or Events

deviceterm events streams the current session's pane transitions and session close, plus global Simulator boot and shutdown transitions, one JSON object per line:

deviceterm events

Treat the list commands as the source of current truth and the stream as a low-latency signal to refresh that truth. Event shapes, ordering, and loss behavior are defined in Events.

An external Simulator can emit boot and shutdown events while staying absent from devices list; use xcrun simctl when you need its metadata.

Subscribe Before Triggering Work

The stream has no replay or durable journal. Events published before the subscription is established are not delivered later.

This sequence can miss the rendering transition and wait forever:

xcrun simctl boot "$UDID"
deviceterm events \
  | jq --unbuffered \
      'select(.type == "pane.stateChanged" and .state == "rendering")'

Starting the subscriber first reduces the race, but the CLI does not emit a public readiness record. A fixed delay cannot prove that the subscription is active.

The following recipes run simctl boot synchronously. Their deadline begins after that command returns, so it bounds the rendering wait but does not bound a stalled boot command. Apply a command timeout appropriate to your automation environment if that failure mode must also be bounded.

Poll for the Final State

Use current-state polling when you only need the final state:

UDID="<simulator-udid>"

if ! xcrun simctl boot "$UDID"; then
  printf 'failed to boot Simulator %s\n' "$UDID" >&2
  exit 1
fi

deadline=$(( $(date +%s) + 30 ))
while ! deviceterm panes list --json \
    | jq -e --arg udid "$UDID" \
        'any(.[];
          (.udid | ascii_downcase) == ($udid | ascii_downcase)
          and .state == "rendering"
        )' \
    >/dev/null; do
  if [ "$(date +%s)" -ge "$deadline" ]; then
    printf 'timed out waiting for Simulator %s\n' "$UDID" >&2
    exit 1
  fi
  sleep 0.2
done

Combine Events With Current State

For lower latency without an indefinite rendering wait, start the subscriber first and poll the same target as a fallback:

UDID="<simulator-udid>"
EVENT_FILE="$(mktemp -t deviceterm-events)"

deviceterm events > "$EVENT_FILE" &
EVENTS_PID=$!

cleanup_events() {
  trap - EXIT HUP INT TERM
  kill "$EVENTS_PID" 2>/dev/null || true
  wait "$EVENTS_PID" 2>/dev/null || true
  rm -f "$EVENT_FILE"
}
trap cleanup_events EXIT
trap 'exit 1' HUP INT TERM

if ! xcrun simctl boot "$UDID"; then
  printf 'failed to boot Simulator %s\n' "$UDID" >&2
  exit 1
fi

DEADLINE=$(( $(date +%s) + 30 ))
while ! jq -e -s --arg udid "$UDID" \
    'any(.[];
      .type == "pane.stateChanged"
      and (((.udid? // "") | ascii_downcase) == ($udid | ascii_downcase))
      and .state == "rendering"
    )' \
    "$EVENT_FILE" >/dev/null 2>&1; do
  deviceterm panes list --json \
    | jq -e --arg udid "$UDID" \
        'any(.[];
          (.udid | ascii_downcase) == ($udid | ascii_downcase)
          and .state == "rendering"
        )' \
    >/dev/null && break

  if [ "$(date +%s)" -ge "$DEADLINE" ]; then
    printf 'timed out waiting for Simulator %s to render\n' "$UDID" >&2
    exit 1
  fi

  sleep 0.2
done

cleanup_events

The state query covers an event that fired before subscription readiness. It also provides the recovery path if the daemon restarts and closes the stream.

The cleanup function stops the whole subscriber process and waits for it. The deadline bounds the rendering wait after simctl boot returns.