@saadnvd1/agent-os 0.3.523 → 0.3.524
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/app/api/sessions/[id]/simulator/route.ts +32 -0
- package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
- package/lib/db/migrations.ts +19 -0
- package/lib/project-config/agentos.schema.json +19 -0
- package/lib/project-config/brief.ts +3 -0
- package/lib/project-config/schema.ts +10 -0
- package/lib/sessions/end.ts +3 -0
- package/lib/sessions/stop.ts +3 -0
- package/lib/simulator/agent-device.ts +258 -0
- package/lib/simulator/available.ts +5 -0
- package/lib/simulator/brief.ts +18 -0
- package/lib/simulator/http.ts +16 -0
- package/lib/simulator/index.ts +458 -0
- package/lib/simulator/lease.ts +169 -0
- package/lib/simulator/simctl.ts +167 -0
- package/package.json +1 -1
- package/server.ts +16 -0
package/README.md
CHANGED
|
@@ -801,6 +801,63 @@ schema, including the fields later releases implement (MCP servers, apps,
|
|
|
801
801
|
recipes), is in
|
|
802
802
|
[docs/decisions/2026-10-10-agentos-json.md](docs/decisions/2026-10-10-agentos-json.md).
|
|
803
803
|
|
|
804
|
+
### iOS simulator for tasks
|
|
805
|
+
|
|
806
|
+
On a Mac, a task on an iOS app can boot a simulator of its own, put a debug
|
|
807
|
+
build of its worktree on it, tap through screens, read the accessibility tree
|
|
808
|
+
and take screenshots, to check a UI fix before anyone installs a release
|
|
809
|
+
build. A project opts in with a `simulator` block in `agentos.json`:
|
|
810
|
+
|
|
811
|
+
```json
|
|
812
|
+
{
|
|
813
|
+
"ports": { "METRO_PORT": 8100 },
|
|
814
|
+
"simulator": {
|
|
815
|
+
"start": "npm run sim",
|
|
816
|
+
"app": "com.example.app",
|
|
817
|
+
"device": "iPhone 17"
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
`start` runs in the task's worktree with `SIMULATOR_UDID` (and the task's
|
|
823
|
+
ports) in its environment. It builds if needed, installs the app on that
|
|
824
|
+
device and exits 0 once the app is up; AgentOS builds nothing itself, because
|
|
825
|
+
the project knows its native fingerprint and what to cache. `app` is the
|
|
826
|
+
bundle id, and `device` the model (default `iPhone 17`).
|
|
827
|
+
|
|
828
|
+
A project without the block gets nothing: no brief lines and no tools. A task
|
|
829
|
+
declares nothing and chooses nothing; it runs:
|
|
830
|
+
|
|
831
|
+
```bash
|
|
832
|
+
aos sim open [--no-wait] # make its simulator, run start; waits until it is up
|
|
833
|
+
aos sim status | close
|
|
834
|
+
aos sim snapshot -i # accessibility tree with @e1, @e2... refs
|
|
835
|
+
aos sim click @e3 | fill @e5 "text" | scroll | swipe | back | home | find | wait
|
|
836
|
+
aos sim screenshot [path] # prints a PNG path under ~/.agent-os/simulators/<task>/shots
|
|
837
|
+
aos sim logs --last 2m # the app's recent log
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
- **Driving** is [agent-device](https://github.com/callstack/agent-device) (MIT),
|
|
841
|
+
pinned and installed on first use into `~/.agent-os/tools`, with its own
|
|
842
|
+
state dir. Only the commands above are accepted; the device and session are
|
|
843
|
+
filled in by the server, so a task can only reach its own simulator.
|
|
844
|
+
- **One simulator per task**, cloned from a base device AgentOS makes once on
|
|
845
|
+
the newest iOS runtime. At most two are booted at once across all tasks
|
|
846
|
+
(`AGENTOS_SIMULATOR_MAX`); one idle for 15 minutes is shut down and boots
|
|
847
|
+
again on its next command. The XCTest runner builds the first time per Xcode
|
|
848
|
+
version (minutes), then is cached.
|
|
849
|
+
- **Cleanup**: a task's simulator is deleted when it is done, signed off,
|
|
850
|
+
dropped, deleted or moved away, and shut down when stopped. A sweep at start
|
|
851
|
+
and every 5 minutes also removes those of tasks that ended or crashed.
|
|
852
|
+
Devices are named with a per-database prefix and only those are ever
|
|
853
|
+
touched, so a dev instance and the real one never reap each other's.
|
|
854
|
+
- **Mac only.** On any other machine, including a linked one a task was moved
|
|
855
|
+
to, the brief says the simulator is unavailable and `aos sim` says the same;
|
|
856
|
+
nothing is forwarded between machines.
|
|
857
|
+
- **Optional.** No merge, review or release check reads simulator state.
|
|
858
|
+
Screenshots are written outside the worktree, and the brief tells the task
|
|
859
|
+
not to put them in commits or PR bodies unless they belong there.
|
|
860
|
+
|
|
804
861
|
### Stacks
|
|
805
862
|
|
|
806
863
|
A project whose board is linked to LumifyHub can run the whole board as
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
openSimulator,
|
|
4
|
+
releaseSimulator,
|
|
5
|
+
simulatorStatus,
|
|
6
|
+
} from "@/lib/simulator";
|
|
7
|
+
import { simulatorFailure } from "@/lib/simulator/http";
|
|
8
|
+
|
|
9
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
10
|
+
|
|
11
|
+
// GET /api/sessions/[id]/simulator - the task's simulator: state and latest shots
|
|
12
|
+
export async function GET(_request: NextRequest, { params }: Ctx) {
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
return NextResponse.json(simulatorStatus(id));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// POST - start it in the background (`aos sim open`); poll GET
|
|
18
|
+
export async function POST(_request: NextRequest, { params }: Ctx) {
|
|
19
|
+
try {
|
|
20
|
+
const { id } = await params;
|
|
21
|
+
return NextResponse.json(openSimulator(id), { status: 202 });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return simulatorFailure(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// DELETE - end it (`aos sim close`): shut down and removed
|
|
28
|
+
export async function DELETE(_request: NextRequest, { params }: Ctx) {
|
|
29
|
+
const { id } = await params;
|
|
30
|
+
await releaseSimulator(id, { remove: true });
|
|
31
|
+
return NextResponse.json(simulatorStatus(id));
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { runSimulator, simulatorLogs } from "@/lib/simulator";
|
|
3
|
+
import { simulatorFailure } from "@/lib/simulator/http";
|
|
4
|
+
|
|
5
|
+
// POST /api/sessions/[id]/simulator/run - one allow-listed agent-device call
|
|
6
|
+
// on the task's own simulator, or its recent app log: { args } | { logs }
|
|
7
|
+
export async function POST(
|
|
8
|
+
request: NextRequest,
|
|
9
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
try {
|
|
12
|
+
const { id } = await params;
|
|
13
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
14
|
+
args?: unknown;
|
|
15
|
+
logs?: unknown;
|
|
16
|
+
};
|
|
17
|
+
if (typeof body.logs === "string")
|
|
18
|
+
return NextResponse.json({
|
|
19
|
+
stdout: await simulatorLogs(id, body.logs),
|
|
20
|
+
stderr: "",
|
|
21
|
+
code: 0,
|
|
22
|
+
screenshot: null,
|
|
23
|
+
});
|
|
24
|
+
if (
|
|
25
|
+
!Array.isArray(body.args) ||
|
|
26
|
+
!body.args.length ||
|
|
27
|
+
!body.args.every((a) => typeof a === "string")
|
|
28
|
+
)
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: "args must be a list of strings" },
|
|
31
|
+
{ status: 400 }
|
|
32
|
+
);
|
|
33
|
+
return NextResponse.json(await runSimulator(id, body.args as string[]));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return simulatorFailure(error);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1768,6 +1768,25 @@ const migrations: Migration[] = [
|
|
|
1768
1768
|
);
|
|
1769
1769
|
},
|
|
1770
1770
|
},
|
|
1771
|
+
{
|
|
1772
|
+
id: 77,
|
|
1773
|
+
name: "simulator_leases",
|
|
1774
|
+
up: (db) => {
|
|
1775
|
+
// One iOS simulator a task holds (lib/simulator): the device AgentOS
|
|
1776
|
+
// made for it, and where it is in coming up.
|
|
1777
|
+
db.exec(
|
|
1778
|
+
`CREATE TABLE IF NOT EXISTS simulator_leases (
|
|
1779
|
+
session_id TEXT PRIMARY KEY,
|
|
1780
|
+
udid TEXT,
|
|
1781
|
+
name TEXT NOT NULL,
|
|
1782
|
+
state TEXT NOT NULL,
|
|
1783
|
+
error TEXT,
|
|
1784
|
+
created_at INTEGER NOT NULL,
|
|
1785
|
+
last_used_at INTEGER NOT NULL
|
|
1786
|
+
)`
|
|
1787
|
+
);
|
|
1788
|
+
},
|
|
1789
|
+
},
|
|
1771
1790
|
];
|
|
1772
1791
|
|
|
1773
1792
|
// `upTo`: stop after this id (tests that start from an older database).
|
|
@@ -132,6 +132,25 @@
|
|
|
132
132
|
},
|
|
133
133
|
"additionalProperties": false
|
|
134
134
|
},
|
|
135
|
+
"simulator": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"start": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"minLength": 1
|
|
141
|
+
},
|
|
142
|
+
"app": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"minLength": 1
|
|
145
|
+
},
|
|
146
|
+
"device": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"minLength": 1
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"required": ["start"],
|
|
152
|
+
"additionalProperties": false
|
|
153
|
+
},
|
|
135
154
|
"database": {
|
|
136
155
|
"type": "object",
|
|
137
156
|
"properties": {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ProjectConfig,
|
|
14
14
|
} from "./index";
|
|
15
15
|
import { databaseBrief, type SessionDatabase } from "./database";
|
|
16
|
+
import { simulatorBrief } from "../simulator/brief";
|
|
16
17
|
|
|
17
18
|
export function runningBrief(
|
|
18
19
|
config: ProjectConfig,
|
|
@@ -49,6 +50,8 @@ export function runningBrief(
|
|
|
49
50
|
lines.push(
|
|
50
51
|
`- \`${browse.map}\` maps every page to its URL: navigate by URL, don't explore.`
|
|
51
52
|
);
|
|
53
|
+
const sim = simulatorBrief(config.simulator);
|
|
54
|
+
if (sim) lines.push(sim);
|
|
52
55
|
for (const note of notesOf(config.notes))
|
|
53
56
|
lines.push(`- ${withPorts(note, ports)}`);
|
|
54
57
|
return lines.length ? `## Running this project\n\n${lines.join("\n")}` : "";
|
|
@@ -60,6 +60,15 @@ const browse = z.strictObject({
|
|
|
60
60
|
port: envName.optional(),
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// An iOS simulator tasks may drive on a Mac (lib/simulator). `start` runs in
|
|
64
|
+
// the task's worktree with SIMULATOR_UDID set, builds and installs the app
|
|
65
|
+
// on that device and exits 0 once it is up.
|
|
66
|
+
const simulator = z.strictObject({
|
|
67
|
+
start: z.string().min(1),
|
|
68
|
+
app: z.string().min(1).optional(),
|
|
69
|
+
device: z.string().min(1).optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
63
72
|
const database = z.strictObject({
|
|
64
73
|
from: z.string().min(1),
|
|
65
74
|
env: projectEnvName.optional(),
|
|
@@ -109,6 +118,7 @@ const shape = {
|
|
|
109
118
|
test: z.string().min(1).optional(),
|
|
110
119
|
notes: notes.optional(),
|
|
111
120
|
browse: browse.optional(),
|
|
121
|
+
simulator: simulator.optional(),
|
|
112
122
|
database: database.optional(),
|
|
113
123
|
mcp: z.array(z.string().min(1)).optional(),
|
|
114
124
|
model: z.string().min(1).optional(),
|
package/lib/sessions/end.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Session } from "../db";
|
|
|
2
2
|
import { stopChat } from "../chat/runner";
|
|
3
3
|
import { waitForExit } from "../chat/worker/client";
|
|
4
4
|
import { endTmuxSession } from "../tmux/end";
|
|
5
|
+
import { releaseOnEnd } from "../simulator";
|
|
5
6
|
|
|
6
7
|
type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
7
8
|
|
|
@@ -10,6 +11,8 @@ type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
|
10
11
|
// finish share.
|
|
11
12
|
export async function endSessionAgent(s: Agent): Promise<void> {
|
|
12
13
|
stopChat(s.id);
|
|
14
|
+
// The task's simulator goes with it; no row, no work.
|
|
15
|
+
releaseOnEnd(s.id, { remove: true });
|
|
13
16
|
void waitForExit(s.id).catch(() => {});
|
|
14
17
|
await endTmuxSession(s.host_id, s.tmux_name);
|
|
15
18
|
}
|
package/lib/sessions/stop.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { hostLink } from "../hosts/remote-api";
|
|
|
3
3
|
import { stopChatTurn } from "../chat/stop";
|
|
4
4
|
import type { Session } from "../db";
|
|
5
5
|
import { stopHeldTask } from "../tasks/held-remote";
|
|
6
|
+
import { releaseOnEnd } from "../simulator";
|
|
6
7
|
|
|
7
8
|
// Stops a session's agent and keeps everything else, the same for every
|
|
8
9
|
// caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
|
|
@@ -33,10 +34,12 @@ export async function stopSession(
|
|
|
33
34
|
const out = await stopChatTurn(s.id);
|
|
34
35
|
if (!out.stopped)
|
|
35
36
|
throw new Error(`Couldn't stop ${s.name}: ${out.reason}.`);
|
|
37
|
+
releaseOnEnd(s.id, { remove: false });
|
|
36
38
|
return out.killed
|
|
37
39
|
? `Stopped ${s.name}: its agent didn't answer the interrupt, so it was ended. Its conversation and worktree are kept; your next message runs as its next turn.`
|
|
38
40
|
: `Stopped ${s.name}'s turn. Your next message runs as its next turn.`;
|
|
39
41
|
}
|
|
40
42
|
await endTmuxSession(s.host_id, s.tmux_name);
|
|
43
|
+
releaseOnEnd(s.id, { remove: false });
|
|
41
44
|
return `Stopped ${s.name}: its agent is ended, its worktree and branch are kept.`;
|
|
42
45
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driving a simulator: the `agent-device` CLI (an XCTest runner behind an
|
|
3
|
+
* accessibility-tree snapshot with element refs), pinned and installed on
|
|
4
|
+
* first use. The task never names a device or session: they are injected
|
|
5
|
+
* here, so it can only drive its own simulator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from "child_process";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { promisify } from "util";
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
export const AGENT_DEVICE_VERSION = "0.21.27";
|
|
17
|
+
|
|
18
|
+
const root = () => path.join(os.homedir(), ".agent-os");
|
|
19
|
+
export const toolDir = () =>
|
|
20
|
+
path.join(root(), "tools", `agent-device-${AGENT_DEVICE_VERSION}`);
|
|
21
|
+
export const toolBin = () =>
|
|
22
|
+
path.join(toolDir(), "node_modules/.bin/agent-device");
|
|
23
|
+
// Its daemon, device claims and XCTest runner cache stay apart from any other
|
|
24
|
+
// copy of the tool on this Mac.
|
|
25
|
+
export const stateDir = () => path.join(root(), "agent-device");
|
|
26
|
+
export const simulatorDir = (sessionId: string) =>
|
|
27
|
+
path.join(root(), "simulators", sessionId);
|
|
28
|
+
export const shotsDir = (sessionId: string) =>
|
|
29
|
+
path.join(simulatorDir(sessionId), "shots");
|
|
30
|
+
|
|
31
|
+
export const ALLOWED = new Set([
|
|
32
|
+
"snapshot",
|
|
33
|
+
"click",
|
|
34
|
+
"press",
|
|
35
|
+
"longpress",
|
|
36
|
+
"fill",
|
|
37
|
+
"type",
|
|
38
|
+
"scroll",
|
|
39
|
+
"swipe",
|
|
40
|
+
"back",
|
|
41
|
+
"home",
|
|
42
|
+
"find",
|
|
43
|
+
"get",
|
|
44
|
+
"wait",
|
|
45
|
+
"screenshot",
|
|
46
|
+
"apps",
|
|
47
|
+
"relaunch",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Flags that pick a device, a session or where the tool keeps its state.
|
|
51
|
+
const REFUSED = new Set([
|
|
52
|
+
"--udid",
|
|
53
|
+
"--device",
|
|
54
|
+
"--serial",
|
|
55
|
+
"--session",
|
|
56
|
+
"--state-dir",
|
|
57
|
+
"--platform",
|
|
58
|
+
"--ios-simulator-device-set",
|
|
59
|
+
"--ios-xctest-derived-data-path",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
export interface Target {
|
|
63
|
+
udid: string;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
app?: string | null;
|
|
66
|
+
// The task's worktree: with the shots dir, the only place a screenshot may
|
|
67
|
+
// be written. Relative paths are taken from here.
|
|
68
|
+
cwd?: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The tool's session on the task's device, attached to the app.
|
|
72
|
+
export function openArgs(target: Target): string[] {
|
|
73
|
+
return [
|
|
74
|
+
"open",
|
|
75
|
+
target.app ?? "",
|
|
76
|
+
"--platform",
|
|
77
|
+
"ios",
|
|
78
|
+
"--udid",
|
|
79
|
+
target.udid,
|
|
80
|
+
"--session",
|
|
81
|
+
agentSession(target.sessionId),
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const agentSession = (sessionId: string) =>
|
|
86
|
+
`aos-${sessionId.replace(/-/g, "").slice(0, 8)}`;
|
|
87
|
+
|
|
88
|
+
// The argv for one call, or why it is refused.
|
|
89
|
+
export function buildArgs(
|
|
90
|
+
args: string[],
|
|
91
|
+
target: Target,
|
|
92
|
+
now = () => Date.now()
|
|
93
|
+
): string[] {
|
|
94
|
+
const [sub, ...rest] = args;
|
|
95
|
+
if (!sub || !ALLOWED.has(sub))
|
|
96
|
+
throw new Error(
|
|
97
|
+
`aos sim ${sub ?? ""}: not allowed. Use: ${[...ALLOWED].join(", ")}`
|
|
98
|
+
);
|
|
99
|
+
for (const a of rest) {
|
|
100
|
+
const flag = a.split("=")[0];
|
|
101
|
+
if (REFUSED.has(flag))
|
|
102
|
+
throw new Error(
|
|
103
|
+
`${flag} is set for you: the simulator is this task's own`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// `aos sim open` is AgentOS's own; the tool's `open` is how a session is
|
|
107
|
+
// attached to the app, which bring-up does and `relaunch` repeats.
|
|
108
|
+
const out =
|
|
109
|
+
sub === "relaunch"
|
|
110
|
+
? ["open", ...(target.app ? [target.app] : []), "--relaunch", ...rest]
|
|
111
|
+
: [sub, ...rest];
|
|
112
|
+
if (sub === "screenshot") {
|
|
113
|
+
const at = shotArg(out);
|
|
114
|
+
if (!at) out.push(path.join(shotsDir(target.sessionId), `${now()}.png`));
|
|
115
|
+
else
|
|
116
|
+
out[at.index] =
|
|
117
|
+
at.prefix + safeShotPath(out[at.index].slice(at.prefix.length), target);
|
|
118
|
+
}
|
|
119
|
+
return [
|
|
120
|
+
...out,
|
|
121
|
+
"--platform",
|
|
122
|
+
"ios",
|
|
123
|
+
"--udid",
|
|
124
|
+
target.udid,
|
|
125
|
+
"--session",
|
|
126
|
+
agentSession(target.sessionId),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const within = (dir: string, file: string) => {
|
|
131
|
+
const rel = path.relative(dir, file);
|
|
132
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Resolves through symlinks as far as the path exists, so a link inside the
|
|
136
|
+
// worktree can't lead the write elsewhere.
|
|
137
|
+
function realish(file: string): string {
|
|
138
|
+
const rest: string[] = [];
|
|
139
|
+
let dir = file;
|
|
140
|
+
for (;;) {
|
|
141
|
+
try {
|
|
142
|
+
return path.join(fs.realpathSync(dir), ...rest.reverse());
|
|
143
|
+
} catch {
|
|
144
|
+
const up = path.dirname(dir);
|
|
145
|
+
if (up === dir) return file;
|
|
146
|
+
rest.push(path.basename(dir));
|
|
147
|
+
dir = up;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A screenshot goes under the session's shots dir or the task's worktree, as
|
|
153
|
+
// a .png; anything else is refused.
|
|
154
|
+
function safeShotPath(given: string, target: Target): string {
|
|
155
|
+
const base = target.cwd ?? shotsDir(target.sessionId);
|
|
156
|
+
const full = realish(path.resolve(base, given));
|
|
157
|
+
const roots = [shotsDir(target.sessionId), target.cwd].flatMap((r) =>
|
|
158
|
+
r ? [realish(r)] : []
|
|
159
|
+
);
|
|
160
|
+
if (!full.endsWith(".png") || !roots.some((r) => within(r, full)))
|
|
161
|
+
throw new Error(
|
|
162
|
+
"screenshot: the path must be a .png under this task's worktree or its simulator shots folder"
|
|
163
|
+
);
|
|
164
|
+
return full;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const VALUE_FLAGS = new Set(["--crop-on", "--pixel-density", "--scale"]);
|
|
168
|
+
|
|
169
|
+
// Where a screenshot argv names its file: a positional path or --out.
|
|
170
|
+
function shotArg(argv: string[]): { index: number; prefix: string } | null {
|
|
171
|
+
for (let i = 1; i < argv.length; i++) {
|
|
172
|
+
const a = argv[i];
|
|
173
|
+
if (a === "--out")
|
|
174
|
+
return argv[i + 1] === undefined ? null : { index: i + 1, prefix: "" };
|
|
175
|
+
if (a.startsWith("--out=")) return { index: i, prefix: "--out=" };
|
|
176
|
+
if (VALUE_FLAGS.has(a)) i++;
|
|
177
|
+
else if (!a.startsWith("-")) return { index: i, prefix: "" };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The screenshot path a built argv will write, if it is one.
|
|
183
|
+
export const screenshotPath = (argv: string[]): string | null => {
|
|
184
|
+
if (argv[0] !== "screenshot") return null;
|
|
185
|
+
const at = shotArg(argv);
|
|
186
|
+
return at ? argv[at.index].slice(at.prefix.length) : null;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// What the tool and its install get: no tokens or project secrets from the
|
|
190
|
+
// server's environment, only what it needs to find Xcode and a home.
|
|
191
|
+
const ENV_KEYS = ["PATH", "HOME", "TMPDIR", "LANG", "DEVELOPER_DIR"];
|
|
192
|
+
export const toolEnv = (): NodeJS.ProcessEnv =>
|
|
193
|
+
({
|
|
194
|
+
...Object.fromEntries(
|
|
195
|
+
ENV_KEYS.filter((k) => process.env[k]).map((k) => [k, process.env[k]])
|
|
196
|
+
),
|
|
197
|
+
AGENT_DEVICE_STATE_DIR: stateDir(),
|
|
198
|
+
}) as unknown as NodeJS.ProcessEnv;
|
|
199
|
+
|
|
200
|
+
let installing: Promise<void> | null = null;
|
|
201
|
+
|
|
202
|
+
export function ensureInstalled(): Promise<void> {
|
|
203
|
+
if (fs.existsSync(toolBin())) return Promise.resolve();
|
|
204
|
+
installing ??= (async () => {
|
|
205
|
+
fs.mkdirSync(toolDir(), { recursive: true });
|
|
206
|
+
await execFileAsync(
|
|
207
|
+
"npm",
|
|
208
|
+
[
|
|
209
|
+
"install",
|
|
210
|
+
"--prefix",
|
|
211
|
+
toolDir(),
|
|
212
|
+
"--no-audit",
|
|
213
|
+
"--no-fund",
|
|
214
|
+
// agent-device has no install scripts; none are run for it or its deps.
|
|
215
|
+
"--ignore-scripts",
|
|
216
|
+
`agent-device@${AGENT_DEVICE_VERSION}`,
|
|
217
|
+
],
|
|
218
|
+
{ timeout: 300_000, env: toolEnv() }
|
|
219
|
+
);
|
|
220
|
+
})().finally(() => {
|
|
221
|
+
installing = null;
|
|
222
|
+
});
|
|
223
|
+
return installing;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CallResult {
|
|
227
|
+
stdout: string;
|
|
228
|
+
stderr: string;
|
|
229
|
+
code: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function callTool(
|
|
233
|
+
argv: string[],
|
|
234
|
+
timeoutMs = 120_000
|
|
235
|
+
): Promise<CallResult> {
|
|
236
|
+
await ensureInstalled();
|
|
237
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
238
|
+
try {
|
|
239
|
+
const { stdout, stderr } = await execFileAsync(toolBin(), argv, {
|
|
240
|
+
env: toolEnv(),
|
|
241
|
+
timeout: timeoutMs,
|
|
242
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
243
|
+
});
|
|
244
|
+
return { stdout, stderr, code: 0 };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const e = error as {
|
|
247
|
+
stdout?: string;
|
|
248
|
+
stderr?: string;
|
|
249
|
+
code?: number | string;
|
|
250
|
+
message: string;
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
stdout: e.stdout ?? "",
|
|
254
|
+
stderr: e.stderr || e.message,
|
|
255
|
+
code: typeof e.code === "number" ? e.code : 1,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ProjectConfig } from "../project-config/schema";
|
|
2
|
+
import { simulatorAvailable } from "./available";
|
|
3
|
+
|
|
4
|
+
// What a task is told about the simulator: nothing when the project declares
|
|
5
|
+
// none, one line when this machine can't run one.
|
|
6
|
+
export function simulatorBrief(
|
|
7
|
+
sim: ProjectConfig["simulator"],
|
|
8
|
+
available = simulatorAvailable()
|
|
9
|
+
): string {
|
|
10
|
+
if (!sim) return "";
|
|
11
|
+
if (!available)
|
|
12
|
+
return "- The iOS simulator tools are not available on this machine; skip simulator checks and say so in the PR.";
|
|
13
|
+
return [
|
|
14
|
+
"- iOS simulator (optional; it never gates a PR or a build, and a failure is reported, not a blocker): `aos sim open` makes your own simulator and installs this worktree's build (run it with run_in_background, a cold build takes minutes; `aos sim status` shows where it is).",
|
|
15
|
+
' Then `aos sim snapshot -i` (accessibility tree with @refs), `aos sim click @e3`, `aos sim fill @e5 "text"`, `aos sim screenshot` (prints a PNG path; read it), `aos sim logs --last 2m`. Refs are only valid from the latest snapshot.',
|
|
16
|
+
" Screenshots are written outside the worktree: don't put them in commits or the PR body unless they belong there. `aos sim close` when you're done; it is closed for you when the task ends.",
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { SimulatorError } from "./index";
|
|
3
|
+
|
|
4
|
+
// A simulator call's failure as a response: its own errors carry their status.
|
|
5
|
+
export function simulatorFailure(error: unknown) {
|
|
6
|
+
if (error instanceof SimulatorError)
|
|
7
|
+
return NextResponse.json(
|
|
8
|
+
{ error: error.message },
|
|
9
|
+
{ status: error.status }
|
|
10
|
+
);
|
|
11
|
+
console.error("[simulator]", error);
|
|
12
|
+
return NextResponse.json(
|
|
13
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
14
|
+
{ status: 500 }
|
|
15
|
+
);
|
|
16
|
+
}
|