Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
16dd5eb
fix(tv): element tree after tv-remote, and working auto-describe on A…
latekvo Oct 5, 2026
798c773
docs(skills): TV loop reads focus from the tree tv-remote returns
latekvo Oct 5, 2026
03a628f
chore: tighten uiautomator lock comment
latekvo Oct 5, 2026
1200c3a
docs(tv): point TV tool descriptions and skill at the returned focus …
latekvo Oct 5, 2026
32c5905
fix(describe): log the helper failure before the Android TV dump fall…
latekvo Oct 5, 2026
7e9523c
test(mcp): assert the tv-remote delay from the map, not the env-depen…
latekvo Oct 5, 2026
e114998
fix(tv): point at describe when the returned focus view is missing; p…
latekvo Oct 5, 2026
00672ef
fix(tv): correct TV describe prose, skill and delay docs; pin the emp…
latekvo Oct 7, 2026
1118b17
fix(tv): Android TV focus view drops off-screen nodes, marks one focu…
latekvo Oct 7, 2026
cfe2854
fix(tv): reap the in-simulator Apple TV AX reader on recycle and disp…
latekvo Oct 7, 2026
733c631
fix(vega): serialize toolkit page-source reads per device
latekvo Oct 7, 2026
38723ce
fix(tv): give the Apple TV HID socket a per-server path; pin the reap…
latekvo Oct 7, 2026
015b62c
docs(config): name the actor in the auto-screenshot delay row
latekvo Oct 7, 2026
64ffac0
Merge remote-tracking branch 'origin/main' into fix/tv-auto-describe
latekvo Oct 9, 2026
0141592
Merge remote-tracking branch 'origin/main' into fix/tv-auto-describe
latekvo Oct 9, 2026
6f7d446
Merge remote-tracking branch 'origin/main' into fix/tv-auto-describe
latekvo Oct 9, 2026
fb3eb17
Merge branch 'main' into fix/tv-auto-describe
latekvo Oct 14, 2026
4fd1357
Merge remote-tracking branch 'origin/main' into fix/tv-auto-describe
latekvo Oct 25, 2026
5a9c0ac
fix(await-screen-idle): count focus and selection in the settle signa…
latekvo Oct 25, 2026
2b7e9cd
test(await-screen-idle): pin the focused marker; docs: the settle wai…
latekvo Oct 25, 2026
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
3 changes: 3 additions & 0 deletions packages/argent-mcp/src/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const AUTO_SCREENSHOT_TOOLS = new Set([
"paste",
"rotate",
"fold",
"tv-remote",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every Apple TV screenshot is written to $TMPDIR/argent-tv-screenshot-<udid8>-<hrtime>.png (tools/screenshot/index.ts:139-142), and nothing in the repo deletes it. With tv-remote captured, every remote press on Apple TV now leaves one more file; they were 10-600 KB each at the default scale in my run. Before this PR, only explicit screenshots, launch-app and keyboard wrote them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: Confirmed, and it is older than this PR: every backend that writes its own PNG into os.tmpdir() has it. On this machine $TMPDIR holds 380 vega-screenshot-* and 57 argent-tv-screenshot-* files, and ios-remote writes argent-remote-screenshots/<uuid>.png the same way. Nothing unlinks a registered artifact. The registry store is an in-memory map with no eviction. With tv-remote captured, the per-press rate goes up, so I filed it as #1277 rather than patching only the Apple TV path here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: Still present as of 5a9c0ac. Each tv-remote press on Apple TV or Vega writes one PNG under os.tmpdir() that is never removed. Tracked in #1277, which is still open.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A secret typed into a plain text field on a TV now shows up in the next tv-remote result. The capture of the keyboard {text: "{{secret:X}}"} call itself is skipped. On a TV, though, the next call is almost always a tv-remote to reach the submit button, with the field still on screen. That call is now captured: the field is in the screenshot, and on Android TV its text is in the focus view. The skip note does say the next call is captured, and password fields are masked, so this hits non-password fields typed outside a run-sequence. Before this PR, tv-remote was not captured at all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: Not changing this. The capture after a secret is the same on every platform: on a phone the next gesture-tap to the submit button is captured in the same way. The skip note on the keyboard call says so explicitly ("the next call is captured normally, and shows the secret if the field is still on screen") and gives the fix: put the typing and the submit in one run-sequence. run-sequence accepts tv-remote steps, so that remedy works on a TV as well.

"launch-app",
"restart-app",
"open-url",
Expand All @@ -44,6 +45,7 @@ export const AUTO_SCREENSHOT_DELAY_MS_BY_TOOL: Record<string, number> = {
"gesture-rotate": 1500,
"run-sequence": 15000,
"button": 1500,
"tv-remote": 1500,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: On an Apple TV simulator, every MCP tv-remote call now waits the full 1500 ms before capturing. await-screen-idle there polls describeIos(..., {isTvOs: true}), which always returns emptyTree(), and an empty tree never counts as settled. A scratch run of await-screen-idle with isTvOsSimulator mocked to true returned {settled:false, waitedMs:1503, polls:9}. The PR body acknowledges this and points at #681; noting it here because the delay map's JSDoc ("usually returns well under" the cap) is never true for this entry on tvOS.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: Leaving this to #681, which gives Apple TV a real wait primitive. Until then the cost is latency only (1.5 s per press), and the screenshot and focus view are still correct.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: Still present as of 5a9c0ac. A scratch await-screen-idle with isTvOsSimulator mocked true returned {settled:false, waitedMs:1502, polls:9} for the 1500 ms tv-remote cap. Tracked in #681, which is still open.

"rotate": 1000,
// The fold tool already waits for the panel hand-over; the cap covers the
// repaint after it.
Expand Down Expand Up @@ -80,6 +82,7 @@ export const AUTO_DESCRIBE_TOOLS = new Set([
"paste",
"rotate",
"fold",
"tv-remote",
Comment thread
latekvo marked this conversation as resolved.
"launch-app",
"restart-app",
"open-url",
Expand Down
8 changes: 8 additions & 0 deletions packages/argent-mcp/test/auto-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ describe("autoDescribeEnabled", () => {
});
});

describe("auto-capture after tv-remote", () => {
it("captures the screen and the focus tree after a TV remote press", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks that tv-remote is in the two sets, which the loops further down already cover, and restates the 1500 constant. Nothing tests what the PR is about: that a tv-remote call through mcp-server.ts comes back with the screenshot and the --- Elements after action (describe) --- section.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: Not adding an mcp-server.ts harness in this PR. The test is not redundant with the loops: they iterate the sets, so removing tv-remote from them leaves them green. I removed it from AUTO_DESCRIBE_TOOLS, and only this test failed (1/39). For tv-remote, mcp-server.ts reads only those two sets and the delay map, and that code path is shared by all 17 captured tools. mcp-server.test.ts has no call-level harness for any of them. The end-to-end check (screenshot plus --- Elements after action (describe) --- after tv-remote on all three TV platforms) was run through the real argent-mcp over stdio and is in the PR description.

expect(shouldAutoScreenshot("mcp__argent__tv-remote")).toBe(true);
expect(shouldAutoDescribe("mcp__argent__tv-remote")).toBe(true);
expect(AUTO_SCREENSHOT_DELAY_MS_BY_TOOL["tv-remote"]).toBe(1500);
});
});

describe("shouldAutoDescribe", () => {
it("is true for every listed tool, with or without a client prefix", () => {
for (const tool of AUTO_DESCRIBE_TOOLS) {
Expand Down
12 changes: 6 additions & 6 deletions packages/docs/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,12 @@ An environment variable changes the behavior of the process that reads it. Expor

### MCP server

| Variable | Default | Description |
| ----------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ARGENT_TOOLS_URL` | unset | URL of the tool-server that the MCP server and the CLI use. Takes precedence over `argent link` |
| `ARGENT_MCP_LOG` | `~/.argent/mcp-calls.log` | Path of the log of tool calls that the MCP server writes |
| `ARGENT_AUTO_SCREENSHOT_DELAY_MS` | per tool, `1400` otherwise | Minimum milliseconds between an interaction tool and its automatic screenshot. Raises the per-tool default; it cannot lower it. See the `disable-auto-screenshot` flag |
| `ARGENT_TOOL_SERVER_SHUTDOWN_ON_MCP_EXIT` | unset | Set `1` to stop the tool-server when the editor closes the MCP server |
| Variable | Default | Description |
| ----------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ARGENT_TOOLS_URL` | unset | URL of the tool-server that the MCP server and the CLI use. Takes precedence over `argent link` |
| `ARGENT_MCP_LOG` | `~/.argent/mcp-calls.log` | Path of the log of tool calls that the MCP server writes |
| `ARGENT_AUTO_SCREENSHOT_DELAY_MS` | per tool, `1400` otherwise | Longest wait, in milliseconds, for the screen to settle before an interaction tool's automatic screenshot. The variable raises the per-tool default but cannot lower it. See the `disable-auto-screenshot` flag |
| `ARGENT_TOOL_SERVER_SHUTDOWN_ON_MCP_EXIT` | unset | Set `1` to stop the tool-server when the editor closes the MCP server |

### Telemetry

Expand Down
10 changes: 5 additions & 5 deletions packages/skills/skills/argent-tv-interact/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ description: Control and inspect TV apps via argent — Apple TV (tvOS), Android
## Critical

- A TV is **focus-driven, not touch-driven.** Drive every interaction with `describe` + `tv-remote` + `keyboard`; never use `gesture-*` / coordinate taps — they don't apply on any TV platform.
- **Always `describe` before navigating** to find the live cursor and your target — never guess focus from a screenshot. The cursor is the focused element; on **Vega** the toolkit often leaves `focused` false and marks the highlighted item `[selected]`, so treat `[selected]` as the cursor when nothing reports `[focused]`.
- **Always read the focus view before navigating** (`describe`, or the tree the last action returned) to find the live cursor and your target — never guess focus from a screenshot. The cursor is the focused element; on **Vega** the toolkit often leaves `focused` false and marks the highlighted item `[selected]`, so treat `[selected]` as the cursor when nothing reports `[focused]`.
- Pass the `udid` from `list-devices` — an Apple TV simulator UDID or an Android TV / Vega `serial`. Dispatch is automatic from the id; the same tools drive all three.

## The navigation loop

1. `describe` — find the cursor and your target (returns the focused element + all focusable ones, not a tap tree).
2. `tv-remote` — move focus toward the target. Prefer **one** call with a path ending in `select`, e.g. `{button:["down","right","select"]}`; count rows/columns from the frames to build the path.
3. `describe` again to confirm. On a miss, repeat.
1. `describe` — find the cursor and your target (see `describe` under Tools for what it returns on each platform).
2. `tv-remote` — move focus toward the target. Prefer **one** call with a path ending in `select`, e.g. `{button:["down","right","select"]}`; on Vega, count rows/columns from the frames to build the path. The Apple TV / Android TV focus view is a flat list with no positions, so read the grid's shape from the screenshot.
3. Read where focus landed from the focus view returned under `--- Elements after action (describe) ---`; call `describe` only when that section is missing. On a miss, repeat.

## Tools

- `describe {udid}` — focus view: the focused / `[selected]` element + focusable elements with labels and normalized frames. Call before and after navigating. Empty tree → see the per-platform notes.
- `describe {udid}` — on Apple TV / Android TV, the focus view: the focused element + focusable elements with labels. On Vega, the full element tree with normalized frames, where `[focused]` / `[selected]` mark the cursor. Call it before the first press on a screen; `tv-remote`, `keyboard`, `launch-app`, `restart-app` and `run-sequence` return it after the action. Empty tree → see the per-platform notes.
- `tv-remote {udid, button}` — D-pad / remote. `button` is one key **or a whole path** (run in one call). Keys: `up`/`down`/`left`/`right`, `select`, `back`, `menu`, `home`, `playPause`, plus media keys `rewind`/`fastForward`/`next`/`previous`/`volumeUp`/`volumeDown`/`mute`. Single: `{button:"down"}`; repeat: `{button:"down", repeat:3}`; path: `{button:["up","right","select"]}`.
- `keyboard {udid, text}` — type into the focused field (focus it with `tv-remote` first). One call carries `text` or `key`, never both — to type and then press a key, send two `keyboard` steps in one `run-sequence`. Named `key` presses (e.g. `{key:"enter"}`) work on Vega; on Apple TV / Android TV move focus with `tv-remote` instead.
- `launch-app` / `restart-app` / `reinstall-app {udid, bundleId}` — `bundleId` from the app manifest. Vega `reinstall-app` takes `appPath` = a `.vpkg`.
Expand Down
69 changes: 46 additions & 23 deletions packages/tool-server/src/blueprints/android-tv-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
import { adbExecOutBinary, adbShell, getAndroidRuntimeKind } from "../utils/adb";
import { assertTypeableAndroidText, injectAndroidText } from "../utils/android-input";
import { UnsupportedOperationError } from "../utils/capability";
import { withUiautomatorLock } from "../utils/uiautomator-lock";
import {
parseUiAutomatorXml,
parseUiAutomatorBounds,
attrIsTrue,
labelOf,
} from "../tools/describe/platforms/android/uiautomator-parser";
Expand Down Expand Up @@ -60,7 +62,6 @@ const KEYEVENTS: Record<TvDirection, number> = {
interface TvNode {
label: string;
value: string;
focused: boolean;
selected: boolean;
disabled: boolean;
isButton: boolean;
Expand All @@ -77,13 +78,13 @@ function valueOf(attrs: Record<string, string>): string {
}

/**
* Collect the focused node and every focusable node. `parseUiAutomatorDump`
* Collect the focused nodes and every focusable node. `parseUiAutomatorDump`
* drops the `focused` attribute, so the focus walk needs its own pass.
*/
function collectTvNodes(xml: string): { focused: TvNode | null; focusable: TvNode[] } {
function collectTvNodes(xml: string): { focused: TvNode[]; focusable: TvNode[] } {
const root = parseUiAutomatorXml(xml);
const focusable: TvNode[] = [];
let focused: TvNode | null = null;
const focused: TvNode[] = [];
if (!root) return { focused, focusable };

const stack = [root];
Expand All @@ -96,6 +97,10 @@ function collectTvNodes(xml: string): { focused: TvNode | null; focusable: TvNod
const isFocusable = attrIsTrue(attrs, "focusable");
const isFocused = attrIsTrue(attrs, "focused");
if (!isFocusable && !isFocused) continue;
// A view outside the display gets its far edge clamped to the screen, so
// its bounds come back empty or inverted; `uiautomator dump` omits it.
const bounds = parseUiAutomatorBounds(attrs.bounds ?? "");
if (bounds && (bounds.w === 0 || bounds.h === 0)) continue;

const label = labelOf(attrs);
// An unlabelled focusable is a layout focus-trap: kept out of `focusable`,
Expand All @@ -104,14 +109,13 @@ function collectTvNodes(xml: string): { focused: TvNode | null; focusable: TvNod
const tvNode: TvNode = {
label,
value: valueOf(attrs),
focused: isFocused,
selected: attrIsTrue(attrs, "selected"),
disabled: attrs.enabled === "false",
isButton: /Button/.test(className),
isEditable: /EditText/.test(className),
pkg: attrs.package ?? "",
};
if (isFocused && !focused) focused = tvNode;
if (isFocused) focused.push(tvNode);
if (isFocusable && label) focusable.push(tvNode);
}
return { focused, focusable };
Expand All @@ -126,15 +130,42 @@ function traitsOf(n: TvNode): string[] {
return traits;
}

function toTvElement(n: TvNode): TvElement {
function toTvElement(n: TvNode, focused: TvNode | undefined): TvElement {
return {
label: n.label || undefined,
traits: traitsOf(n),
value: n.value || undefined,
isFocused: n.focused,
isFocused: n === focused,
};
}

/**
* Focus view of a uiautomator-schema hierarchy (a `uiautomator dump` or the
* android-devtools helper). The helper emits one root per window, topmost
* first; with the on-screen keyboard up, both the key under the D-pad cursor
* and the app's text field are `focused`. The first is where the D-pad acts;
* `imePackage` keeps the keyboard out of `bundleId`.
*/
export function tvFocusViewFromXml(xml: string, imePackage?: string): TvDescribeResponse {
const { focused, focusable } = collectTvNodes(xml);
Comment thread
latekvo marked this conversation as resolved.
Outdated
const top = focused[0];
const pkg = [...focused, ...focusable].find((n) => n.pkg && n.pkg !== imePackage)?.pkg;
return {
bundleId: pkg || top?.pkg || undefined,
focused: top ? toTvElement(top, top) : null,
focusable: focusable.map((n) => toTvElement(n, top)),
};
}

/** Package of the device's current input method, or undefined when unset. */
export async function readImePackage(serial: string): Promise<string | undefined> {
const out = (
await adbShell(serial, "settings get secure default_input_method", { timeoutMs: 5_000 })
).trim();
const pkg = out.split("/")[0];
return pkg && pkg !== "null" ? pkg : undefined;
}

export const androidTvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInfo> = {
namespace: ANDROID_TV_CONTROL_NAMESPACE,

Expand Down Expand Up @@ -187,10 +218,12 @@ export const androidTvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInf
const suffix = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
const dumpPath = `/data/local/tmp/argent-tv-dump-${suffix}.xml`;
const raw = (
await adbExecOutBinary(
serial,
`uiautomator dump --compressed ${dumpPath} >/dev/null && cat ${dumpPath}; rm -f ${dumpPath}`,
{ timeoutMs: 20_000 }
await withUiautomatorLock(serial, () =>
adbExecOutBinary(
serial,
`uiautomator dump --compressed ${dumpPath} >/dev/null && cat ${dumpPath}; rm -f ${dumpPath}`,
{ timeoutMs: 20_000 }
)
)
).toString("utf-8");
if (!raw.includes("<hierarchy")) {
Expand All @@ -202,23 +235,13 @@ export const androidTvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInf
return raw;
}

async function read(): Promise<{ focused: TvNode | null; focusable: TvNode[] }> {
return collectTvNodes(await dumpHierarchy());
}

async function pressKey(direction: TvDirection): Promise<void> {
await adbShell(serial, `input keyevent ${KEYEVENTS[direction]}`, { timeoutMs: 10_000 });
}

const api: TvControlApi = {
async describe(): Promise<TvDescribeResponse> {
const { focused, focusable } = await read();
const pkg = focused?.pkg || focusable.find((n) => n.pkg)?.pkg;
return {
bundleId: pkg || undefined,
focused: focused ? toTvElement(focused) : null,
focusable: focusable.map(toTvElement),
};
return tvFocusViewFromXml(await dumpHierarchy());
},

async navigate(direction: TvDirection): Promise<void> {
Expand Down
29 changes: 21 additions & 8 deletions packages/tool-server/src/blueprints/tv-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ export function tvControlRef(device: DeviceInfo): {
};
}

// Per tool-server process, so neither a reap nor a dispose touches another
// server's daemon, and a daemon left by an earlier process can't unlink ours.
function axSocketPath(udid: string): string {
return `/tmp/argent-tv-ax-${udid.slice(0, 8)}.sock`;
return `/tmp/argent-tv-ax-${udid.slice(0, 8)}-${process.pid}.sock`;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: The factory (spawnAxDaemon / spawnHidDaemon at lines 268-269) spawns straight onto these per-pid paths, with no reap or unlink first, so a stale daemon is ruled out only while pids are not reused. Suppose a tool-server is SIGKILLed or crashes, so dispose never runs: its reader and HID daemon keep their -<pid>.sock paths for up to their 1 h --timeout. If a new tool-server gets the same pid within that hour and opens the same Apple TV, waitForSocket accepts the old daemon's socket as ready. The new reader then unlinks and rebinds the path. When the orphan later idles out, its cleanup() unlinks the live socket, and every later call fails with ENOENT, the outage this PR fixes. The comment above ("a daemon left by an earlier process can't unlink ours") holds only while pids are unique. Found by reading the code; reproducing it needs pid reuse within the hour.

}

function hidSocketPath(udid: string): string {
return `/tmp/argent-tv-hid-${udid.slice(0, 8)}.sock`;
return `/tmp/argent-tv-hid-${udid.slice(0, 8)}-${process.pid}.sock`;
}

/**
Expand Down Expand Up @@ -129,6 +131,16 @@ function spawnAxDaemon(udid: string, socketPath: string): ChildProcess {
return proc;
}

// Kills every in-sim reader bound to `socketPath`. SIGKILLing `simctl spawn`
// does not reach its reader, and a reader left running unlinks the path when
// its idle `--timeout` fires (or on SIGTERM), even after a newer reader has
// bound it. SIGKILL, so the reader never runs that unlink.
function reapAxReaders(socketPath: string): Promise<void> {
const pattern = `tvos-ax-service --socket ${socketPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`;
// pkill exits 1 when nothing matched, the common case.
return new Promise((resolve) => execFile("pkill", ["-KILL", "-f", pattern], () => resolve()));
}

// Run the HID injector on the host. It loads SimulatorKit and holds one
// SimDeviceLegacyClient open against the UDID for its lifetime.
function spawnHidDaemon(udid: string, socketPath: string): ChildProcess {
Expand Down Expand Up @@ -302,11 +314,11 @@ export const tvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInfo> = {
axProc.removeListener("error", onAxError);
hidProc.removeListener("exit", onHidExit);
hidProc.removeListener("error", onHidError);
// SIGKILL the ax daemon: under `simctl spawn` SIGTERM doesn't reliably
// reach the in-sim process, which would orphan it. The hid daemon is a
// direct host process, so SIGTERM reaps it.
// The ax reader is reaped by `reapAxReaders`. The hid daemon is a direct
// host process, so SIGTERM reaps it.
if (!axProc.killed) axProc.kill("SIGKILL");
if (!hidProc.killed) hidProc.kill("SIGTERM");
await reapAxReaders(axSock);
// Drop any socket a daemon already bound, so a stale file can't make the
// next factory's accept-probe read as "ready" against a dead socket.
for (const p of [axSock, hidSock]) {
Expand Down Expand Up @@ -334,6 +346,7 @@ export const tvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInfo> = {
} catch {
/* no stale socket to remove */
}
await reapAxReaders(axSock);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: This reap now SIGKILLs the old in-simulator reader even while it is answering another caller's request. Before, killing simctl spawn never reached the reader, so a request already in flight still finished. The cut-off caller's sendLine gets end/close with no data and resolves "". sendJson turns that into {}, and api.describe() returns {focused: null, focusable: []} with no error. sendAx retries only on a throw, so this reads as a real empty screen. If it was that caller's last read, describe reports "No focusable elements" with EMPTY_HINT on a screen that has focus. That empty result also starts a recycle, which can cut the other caller's read in turn. The trigger is two overlapping describes on one Apple TV while one is in the empty-focus recycle: for example, argent-mcp re-sending a call after its 30 s per-attempt cap, or two sessions on one tool-server. A scratch unix-socket server that is SIGKILLed after it reads the request line gave the client end with an empty payload.

axProc = spawnAxDaemon(udid, axSock);
axProc.on("exit", onAxExit);
axProc.on("error", onAxError);
Expand Down Expand Up @@ -465,11 +478,11 @@ export const tvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInfo> = {
/* respawn failed — nothing extra to kill beyond the current axProc */
}
}
// SIGKILL for the same reason as spawnFreshAx: under `simctl spawn`
// SIGTERM doesn't reliably reach the in-sim process, which would leave
// it orphaned. The hid daemon is a direct host process, so SIGTERM does.
// The ax reader is reaped by `reapAxReaders`. The hid daemon is a
// direct host process, so SIGTERM reaps it.
if (!axProc.killed) axProc.kill("SIGKILL");
if (!hidProc.killed) hidProc.kill("SIGTERM");
await reapAxReaders(axSock);
for (const p of [axSock, hidSock]) {
try {
fs.unlinkSync(p);
Expand Down
10 changes: 9 additions & 1 deletion packages/tool-server/src/tools/await-screen-idle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { describeIos, iosRequires } from "../describe/platforms/ios";
import { describeIosDevice } from "../describe/platforms/ios-device";
import { describeAndroid, androidRequires } from "../describe/platforms/android";
import { describeChromium } from "../describe/platforms/chromium";
import { describeVega, vegaRequires } from "../describe/platforms/vega";

const AWAIT_SCREEN_IDLE_TOOL_ID = "await-screen-idle";

Expand All @@ -30,7 +31,9 @@ const zodSchema = z.object({
udid: z
.string()
.min(1)
.describe("Target device id from `list-devices` (iOS UDID, Android serial, or Chromium id)."),
.describe(
"Target device id from `list-devices` (iOS UDID, Android serial, Vega serial, or Chromium id)."
),
timeoutMs: z
.number()
.int()
Expand Down Expand Up @@ -72,6 +75,7 @@ const capability: ToolCapability = {
appleRemote: { simulator: true },
android: { emulator: true, device: true, unknown: true },
chromium: { app: true },
vega: { vvd: true },
Comment thread
latekvo marked this conversation as resolved.
};

// Frames are normalized 0..1, so rounding to 0.01 tolerates sub-pixel jitter
Expand Down Expand Up @@ -112,6 +116,9 @@ export function createAwaitScreenIdleTool(registry: Registry): ToolDefinition<Pa
if (device.platform === "android") {
return describeAndroid(registry, device.id, undefined, androidIsTv);
}
if (device.platform === "vega") {
Comment thread
latekvo marked this conversation as resolved.
return describeVega(device.id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return describeChromium(services.chromium as ChromiumCdpApi);
}

Expand Down Expand Up @@ -148,6 +155,7 @@ still before the timeout. Use after a launch/navigation to wait for the UI to re
if (device.platform === "ios") await ensureDeps(iosRequires);
else if (device.platform === "ios-remote") await ensureDeps(["sim-remote"]);
else if (device.platform === "android") await ensureDeps(androidRequires);
else if (device.platform === "vega") await ensureDeps(vegaRequires);
Comment thread
latekvo marked this conversation as resolved.

// Resolve tvOS / Android-TV once. Physical devices skip the tvOS probe. They are never tvOS simulators.
const isTvOs =
Expand Down
Loading
Loading