@saadnvd1/agent-os 0.3.521 → 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.
Files changed (39) hide show
  1. package/README.md +57 -0
  2. package/app/api/sessions/[id]/simulator/route.ts +32 -0
  3. package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
  4. package/components/Chat/BackgroundTasks.tsx +76 -10
  5. package/components/Chat/ChatPanel.tsx +90 -34
  6. package/components/Chat/Subagent.tsx +25 -20
  7. package/components/Chat/Tools.tsx +70 -39
  8. package/components/Chat/background.tsx +114 -0
  9. package/data/chat/useChat.ts +40 -0
  10. package/lib/chat/activity.ts +4 -2
  11. package/lib/chat/background.ts +77 -0
  12. package/lib/chat/driver.ts +6 -0
  13. package/lib/chat/drivers/claude-mapper.ts +44 -6
  14. package/lib/chat/drivers/claude.ts +4 -0
  15. package/lib/chat/events.ts +14 -0
  16. package/lib/chat/registry.ts +2 -0
  17. package/lib/chat/runner.ts +20 -0
  18. package/lib/chat/settings.ts +1 -0
  19. package/lib/chat/socket.ts +9 -0
  20. package/lib/chat/worker/client.ts +33 -0
  21. package/lib/chat/worker/host.ts +13 -0
  22. package/lib/chat/worker/main.ts +1 -1
  23. package/lib/chat/worker/protocol.ts +2 -0
  24. package/lib/db/migrations.ts +19 -0
  25. package/lib/project-config/agentos.schema.json +19 -0
  26. package/lib/project-config/brief.ts +3 -0
  27. package/lib/project-config/schema.ts +10 -0
  28. package/lib/security/auth.ts +11 -1
  29. package/lib/sessions/end.ts +3 -0
  30. package/lib/sessions/stop.ts +3 -0
  31. package/lib/simulator/agent-device.ts +258 -0
  32. package/lib/simulator/available.ts +5 -0
  33. package/lib/simulator/brief.ts +18 -0
  34. package/lib/simulator/http.ts +16 -0
  35. package/lib/simulator/index.ts +458 -0
  36. package/lib/simulator/lease.ts +169 -0
  37. package/lib/simulator/simctl.ts +167 -0
  38. package/package.json +1 -1
  39. package/server.ts +16 -0
@@ -75,6 +75,7 @@ export function emitCapabilities(session: Session, listener?: Listener): void {
75
75
  model: chatModel(session),
76
76
  access: session.chat_access,
77
77
  plan: canPlan(session) ? !!session.chat_plan : null,
78
+ background: chatDriverFor(session.agent_type)?.backgroundTasks === true,
78
79
  };
79
80
  if (listener) listener(m);
80
81
  else emit(session.id, m);
@@ -12,6 +12,7 @@ import {
12
12
  carryOutPlan,
13
13
  chatFileSuggestions,
14
14
  chatHistory,
15
+ backgroundChatTask,
15
16
  chatTaskOutput,
16
17
  chatToolBody,
17
18
  deleteQueuedChat,
@@ -183,6 +184,14 @@ function onChatMessage(
183
184
  run(carryOutPlan(sessionId, String(msg.id)));
184
185
  else if (msg.type === "respond") respondChat(sessionId, msg.id, msg);
185
186
  else if (msg.type === "stop_task") stopChatTask(sessionId, msg.taskId);
187
+ else if (msg.type === "background_task")
188
+ run(
189
+ backgroundChatTask(
190
+ sessionId,
191
+ typeof msg.toolUseId === "string" ? msg.toolUseId : undefined,
192
+ reply
193
+ )
194
+ );
186
195
  else if (msg.type === "task_output")
187
196
  reply({
188
197
  type: "task_output",
@@ -41,6 +41,11 @@ export class WorkerClient {
41
41
 
42
42
  private files = new Map<string, (r: FileSuggestion[] | null) => void>();
43
43
 
44
+ private backgrounds = new Map<
45
+ string,
46
+ (r: { ok?: boolean; error?: string }) => void
47
+ >();
48
+
44
49
  private constructor(
45
50
  private socket: net.Socket,
46
51
  private handlers: WorkerHandlers
@@ -55,6 +60,9 @@ export class WorkerClient {
55
60
  this.undos.clear();
56
61
  for (const f of this.files.values()) f(null);
57
62
  this.files.clear();
63
+ for (const b of this.backgrounds.values())
64
+ b({ error: "The chat worker went away" });
65
+ this.backgrounds.clear();
58
66
  handlers.onClose(this.detached);
59
67
  });
60
68
  socket.on("error", () => socket.destroy());
@@ -101,6 +109,11 @@ export class WorkerClient {
101
109
  else u?.reject(new Error(e.error ?? "Undo failed"));
102
110
  return;
103
111
  }
112
+ if (e.type === "background_result") {
113
+ this.backgrounds.get(e.reqId)?.({ ok: e.ok, error: e.error });
114
+ this.backgrounds.delete(e.reqId);
115
+ return;
116
+ }
104
117
  if (e.type === "files_result") {
105
118
  this.files.get(e.reqId)?.(e.files ?? null);
106
119
  this.files.delete(e.reqId);
@@ -121,6 +134,26 @@ export class WorkerClient {
121
134
  });
122
135
  }
123
136
 
137
+ // Moves a blocking call (or all of them) to the background: whether
138
+ // anything moved, or why not (an error, or no answer in time).
139
+ background(
140
+ toolUseId?: string,
141
+ timeoutMs = 5000
142
+ ): Promise<{ ok?: boolean; error?: string }> {
143
+ const reqId = randomUUID();
144
+ return new Promise((resolve) => {
145
+ const timer = setTimeout(() => {
146
+ this.backgrounds.delete(reqId);
147
+ resolve({ error: "timeout" });
148
+ }, timeoutMs);
149
+ this.backgrounds.set(reqId, (r) => {
150
+ clearTimeout(timer);
151
+ resolve(r);
152
+ });
153
+ this.command({ type: "background_task", reqId, toolUseId });
154
+ });
155
+ }
156
+
124
157
  // The agent's own matches for an @mention; null when it can't say (an
125
158
  // older worker, an error, no answer in time).
126
159
  fileSuggestions(
@@ -712,6 +712,19 @@ export class ChatHost {
712
712
  return this.conversation.respond(cmd.id, cmd);
713
713
  case "stop_task":
714
714
  return this.conversation.stopTask(cmd.taskId);
715
+ case "background_task":
716
+ try {
717
+ if (!this.conversation.backgroundTask) throw new Error("unsupported");
718
+ const ok = await this.conversation.backgroundTask(cmd.toolUseId);
719
+ this.emit({ type: "background_result", reqId: cmd.reqId, ok });
720
+ } catch (error) {
721
+ this.emit({
722
+ type: "background_result",
723
+ reqId: cmd.reqId,
724
+ error: error instanceof Error ? error.message : String(error),
725
+ });
726
+ }
727
+ return;
715
728
  case "undo":
716
729
  try {
717
730
  const result = await this.conversation.undo(
@@ -76,7 +76,7 @@ async function main(sessionId: string) {
76
76
  state: host.state,
77
77
  compacting: host.compactingNow,
78
78
  streaming: [...host.streaming.values()],
79
- caps: ["plan", "queue", "retire", "fresh"],
79
+ caps: ["plan", "queue", "retire", "fresh", "background"],
80
80
  } satisfies WorkerEvent)}\n`
81
81
  );
82
82
  client.on(
@@ -55,6 +55,7 @@ export type WorkerCommand =
55
55
  | ({ type: "respond"; id: string } & ApprovalDecision)
56
56
  | { type: "undo"; reqId: string; checkpoint: string; dryRun: boolean }
57
57
  | { type: "stop_task"; taskId: string }
58
+ | { type: "background_task"; reqId: string; toolUseId?: string }
58
59
  // It runs an older build: it takes no new message into its agent (they
59
60
  // wait in the queue) and closes at its next turn boundary, so a current
60
61
  // worker resumes the conversation with today's tools.
@@ -99,6 +100,7 @@ export type WorkerEvent =
99
100
  // The agent's guess at the next message, or null once it's stale.
100
101
  | { type: "suggestion"; text: string | null }
101
102
  | { type: "undo_result"; reqId: string; result?: UndoResult; error?: string }
103
+ | { type: "background_result"; reqId: string; ok?: boolean; error?: string }
102
104
  | {
103
105
  type: "files_result";
104
106
  reqId: string;
@@ -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(),
@@ -162,8 +162,18 @@ export function authorize(req: AuthRequest, policy: AuthPolicy): AuthResult {
162
162
 
163
163
  function decide(req: AuthRequest, policy: AuthPolicy): AuthResult {
164
164
  const trusted = req.tunnel ? null : networkTrust(req, policy);
165
- if (trusted) return { ok: true, via: trusted };
166
165
  const token = readToken(req.headers);
166
+ if (trusted) {
167
+ // Identity only: network trust already decided access, so a token that
168
+ // is unknown, revoked or fails to resolve never turns this into a refusal.
169
+ let deviceId: string | undefined;
170
+ try {
171
+ deviceId = (token ? policy.lookup(token) : null)?.id;
172
+ } catch {}
173
+ return deviceId
174
+ ? { ok: true, via: trusted, deviceId }
175
+ : { ok: true, via: trusted };
176
+ }
167
177
  const device = token ? policy.lookup(token) : null;
168
178
  if (device) return { ok: true, via: "device", deviceId: device.id };
169
179
  // AGENTOS_AUTH=off is for a login proxy on this side of the network.
@@ -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
+ }