@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 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
+ }
@@ -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(),
@@ -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
  }
@@ -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,5 @@
1
+ import fs from "fs";
2
+
3
+ // xcrun only exists on a Mac with Xcode.
4
+ export const simulatorAvailable = (): boolean =>
5
+ process.platform === "darwin" && fs.existsSync("/usr/bin/xcrun");
@@ -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
+ }