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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,16 @@ without detaching the device, so agents can keep working. Use `device.close` to
release it; deleting or moving the session also releases it. A failed device switch keeps the
previous attachment.

Refresh retries failed video capture without changing the attached device. If a helper exits,
the plugin restarts capture on the next stream request.

Agent access starts enabled. Use the switch to revoke access for the current
session. Agents can only use a device you have opened.
Installing an app or shutting down a device requires a separate permission prompt.

Cancelling a dispatched action cannot undo device input already sent. Read device state before
retrying an action whose outcome is unknown.

Tools use `device_*` names, or `device.*` in Code Mode. They can inspect the
screen, launch apps, enter text, interact with elements and take screenshots.

Expand Down
36 changes: 18 additions & 18 deletions dist/gui.cjs

Large diffs are not rendered by default.

29 changes: 18 additions & 11 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,12 +359,23 @@ var ControlRpc = Rpc.define({
events: { control: { schema: standard(Control) } }
});

// src/deadlines.ts
var DEVICE_OPERATION_TIMEOUT_MS = 120000;
var DEVICE_REQUEST_TIMEOUT_MS = DEVICE_OPERATION_TIMEOUT_MS + 15000;

// src/server.ts
var unavailable = (call, code, message) => call.error("unavailable", message, { code, message, mayHaveExecuted: false });
var adopt = Effect.fnUntraced(function* (device, operation) {
if (device.handle.platform !== operation.platform || device.handle.deviceId !== operation.deviceId)
return yield* new Tool.Error({
message: "[device.unavailable] A different device is attached to this session. The agent can only adopt the device already open in the desktop app. Call the state tool to read the attached device."
});
return { type: "attachment", attachment: device };
});
var make = Effect.fn("DeviceServer.make")(function* (ctx) {
const attachments = new Map;
let active = true;
const close = (sessionID2, reason = "closed") => Effect.gen(function* () {
const close = Effect.fnUntraced(function* (sessionID2, reason = "closed") {
const attachment = attachments.get(sessionID2);
if (!attachment)
return;
Expand Down Expand Up @@ -424,7 +435,8 @@ var make = Effect.fn("DeviceServer.make")(function* (ctx) {
return;
if (input.outcome.type === "failure")
return yield* Deferred.fail(pending.result, new Tool.Error({
message: `[device.${input.outcome.error.code}] ${input.outcome.error.message}`
message: `[device.${input.outcome.error.code}] ${input.outcome.error.message}${input.outcome.error.mayHaveExecuted ? " The action may already have run. Re-read device state before retrying a mutating action." : ""}`,
error: input.outcome.error
})).pipe(Effect.asVoid);
yield* Deferred.succeed(pending.result, input.outcome.value);
}).pipe(Effect.asVoid)
Expand All @@ -444,9 +456,9 @@ var make = Effect.fn("DeviceServer.make")(function* (ctx) {
connectionID: attachment.connectionID,
requestID
})), Effect.ignore)), Effect.timeoutOrElse({
duration: "60 seconds",
duration: DEVICE_REQUEST_TIMEOUT_MS,
orElse: () => new Tool.Error({
message: `[device.timeout] device.${operation.type} did not finish within 60 seconds; its outcome is unknown. Check the desktop connection and re-read device state before repeating a mutating action.`
message: `[device.timeout] device.${operation.type} did not finish within ${DEVICE_REQUEST_TIMEOUT_MS / 1000} seconds; its outcome is unknown. Check the desktop connection and re-read device state before repeating a mutating action.`
})
}), Effect.ensuring(Effect.sync(() => void attachment.pending.delete(requestID))));
});
Expand All @@ -467,13 +479,8 @@ var make = Effect.fn("DeviceServer.make")(function* (ctx) {
message: "[device.denied] This session does not have device access. Grant device access to this session in the desktop app, then retry."
});
const device = attachment.state;
if (operation.type === "open") {
if (device.handle.platform !== operation.platform || device.handle.deviceId !== operation.deviceId)
return yield* new Tool.Error({
message: "[device.unavailable] A different device is attached to this session. The agent can only adopt the device already open in the desktop app. Call the state tool to read the attached device."
});
return { type: "attachment", attachment: device };
}
if (operation.type === "open")
return yield* adopt(device, operation);
if (!exports_rpc.sameHandle(device.handle, operation.handle))
return yield* new Tool.Error({
message: "[device.detached] This device handle no longer matches the attached device. Call the state tool to read the current handle, then retry with it."
Expand Down
16 changes: 16 additions & 0 deletions src/cancellation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Waiting callers may cancel without cancelling a shared task owned by another lifetime.
export function waitWithSignal<Value>(
pending: Promise<Value>,
signal?: AbortSignal,
): Promise<Value> {
if (!signal) return pending;
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const abort = () => {
signal.removeEventListener("abort", abort);
reject(signal.reason);
};
signal.addEventListener("abort", abort, { once: true });
void pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
124 changes: 116 additions & 8 deletions src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,21 +153,129 @@ test.each([false, true])(
},
);

test("failed shutdown rejects close and preserves the session for retry", async () => {
test.each(["close", "reopen"])(
"failed cleanup quarantines the handle and retains shutdown intent through %s",
async (recovery) => {
const item = fixture();
const first = await item.controller.open(caller, selection, "manual");
const close = item.runtime.close;
item.runtime.close = async () => {
throw deviceError("timeout", "cleanup incomplete", true);
};
await expect(
item.controller.close(caller, { handle: first.handle, shutdown: true }, "manual"),
).rejects.toMatchObject({ data: { code: "timeout", mayHaveExecuted: true } });
expect(item.controller.state(caller)?.handle).toEqual(first.handle);
await expect(
item.controller.run(caller, { type: "apps", handle: first.handle }, "agent"),
).rejects.toMatchObject({ code: "detached" });
await expect(
item.controller.open({ ...caller, sessionID: "other" }, selection, "manual"),
).rejects.toMatchObject({ code: "device-busy" });
item.runtime.close = close;
if (recovery === "close") {
await item.controller.close(caller, { handle: first.handle }, "manual");
expect(item.controller.state(caller)).toBeNull();
expect(item.calls).toEqual(["open", "close"]);
} else {
const next = await item.controller.open(caller, selection, "manual");
expect(next.handle.token).not.toBe(first.handle.token);
expect(item.calls).toEqual(["open", "close", "open"]);
}
expect(item.closes[0]?.shutdown).toBe(true);
await item.controller.dispose();
},
);

test("a failed physical-alias rollback keeps both handles isolated and remains reserved", async () => {
const item = fixture();
const opened = await item.controller.open(caller, selection, "manual");
const open = item.runtime.open;
item.runtime.open = (input) => open({ ...input, deviceId: "serial" });
const first = await item.controller.open(caller, selection, "manual");
const close = item.runtime.close;
const other = { ...caller, sessionID: "other" };
const alias = { ...selection, deviceId: "alias" };
item.runtime.close = async () => {
throw deviceError("timeout", "Android shutdown timed out.", true);
throw deviceError("timeout", "cleanup incomplete");
};
await expect(item.controller.open(other, alias, "manual")).rejects.toMatchObject({
code: "timeout",
});
await expect(
item.controller.close(caller, { handle: opened.handle, shutdown: true }, "manual"),
).rejects.toMatchObject({ data: { code: "timeout", mayHaveExecuted: true } });
expect(item.controller.state(caller)?.handle).toEqual(opened.handle);
item.controller.open({ ...caller, sessionID: "third" }, alias, "manual"),
).rejects.toMatchObject({ code: "device-busy" });
expect(item.calls).toEqual(["open", "open"]);
expect(item.controller.state(caller)?.handle).toEqual(first.handle);
item.runtime.close = close;
await expect(item.controller.open(other, alias, "manual")).rejects.toMatchObject({
code: "device-busy",
});
expect(
item.closes.every((input) => input.handle.token !== first.handle.token && !input.shutdown),
).toBe(true);
await item.controller.dispose();
});

test("failed retirement remains retryable after disposal reports its error", async () => {
const item = fixture();
const first = await item.controller.open(caller, selection, "manual");
const close = item.runtime.close;
item.runtime.close = async () => {
throw deviceError("timeout", "cleanup incomplete");
};
await item.controller.open(caller, { ...selection, deviceId: "other" }, "manual");
await expect(item.controller.dispose()).rejects.toMatchObject({ code: "timeout" });
expect(item.calls).toContain("dispose");
item.runtime.close = close;
await item.controller.dispose();
expect(item.closes.map((input) => input.handle)).toEqual([first.handle]);
expect(item.calls.filter((call) => call === "dispose")).toHaveLength(2);
});

test("cancelling a close before dispatch restores the attached state", async () => {
const item = fixture();
const { handle } = await item.controller.open(caller, selection, "manual");
const action = item.controller.run(caller, { type: "key", handle, key: "home" }, "agent");
await item.started.promise;
const abort = new AbortController();
const closing = item.controller.close(caller, { handle }, "manual", abort.signal);
await Promise.resolve();
await Promise.resolve();
expect(() => item.controller.access(caller, false)).toThrow(
"This session is changing its device.",
);
abort.abort();
item.release.resolve();
await action;
await expect(closing).rejects.toMatchObject({ name: "AbortError" });
await expect(
item.controller.run(caller, { type: "apps", handle }, "agent"),
).resolves.toMatchObject({ type: "apps" });
expect(item.closes).toEqual([]);
await item.controller.dispose();
});

test("failed rollback cleanup does not skip runtime disposal or prevent a later cleanup retry", async () => {
const item = fixture();
const open = item.runtime.open;
const close = item.runtime.close;
const abort = new AbortController();
item.runtime.open = async (input) => {
const result = await open(input);
abort.abort();
return result;
};
item.runtime.close = async () => {
throw deviceError("timeout", "cleanup failed");
};
await expect(
item.controller.open(caller, selection, "manual", abort.signal),
).rejects.toMatchObject({ code: "timeout" });
await expect(item.controller.dispose()).rejects.toMatchObject({ code: "timeout" });
expect(item.calls).toContain("dispose");
item.runtime.close = close;
await item.controller.close(caller, { handle: opened.handle, shutdown: true }, "manual");
expect(item.controller.state(caller)).toBeNull();
await item.controller.dispose();
expect(item.calls).toEqual(["open", "dispose", "close", "dispose"]);
});

test("agent access defaults on, can be revoked, and never exposes the stream token in attachment state", async () => {
Expand Down
Loading
Loading