@saadnvd1/agent-os 0.3.523 → 0.3.527

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 (69) hide show
  1. package/README.md +82 -3
  2. package/app/api/doctor/route.ts +8 -0
  3. package/app/api/sessions/[id]/simulator/route.ts +32 -0
  4. package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
  5. package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
  6. package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
  7. package/app/page.tsx +8 -3
  8. package/components/Chat/ChatPanel.tsx +96 -8
  9. package/components/Chat/EventRow.tsx +2 -2
  10. package/components/Chat/EventTurns.test.ts +56 -0
  11. package/components/Chat/EventTurns.tsx +141 -0
  12. package/components/Chat/useFind.ts +5 -3
  13. package/components/Chat/useGoToItem.ts +71 -0
  14. package/components/Orchestrator/AsksList.tsx +16 -11
  15. package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
  16. package/components/Orchestrator/OrchestratorBar.tsx +27 -2
  17. package/components/Settings/SettingsDialog.tsx +2 -2
  18. package/components/Settings/SetupSection.tsx +6 -1
  19. package/components/Simulator/ScreenshotStrip.test.ts +91 -0
  20. package/components/Simulator/ScreenshotStrip.tsx +102 -0
  21. package/components/Simulator/index.ts +1 -0
  22. package/components/Tasks/TaskRow.tsx +2 -0
  23. package/components/Welcome/DevicesStep.tsx +90 -0
  24. package/components/Welcome/HealthList.tsx +69 -0
  25. package/components/Welcome/ProjectStep.tsx +2 -8
  26. package/components/Welcome/WelcomeDialog.tsx +31 -7
  27. package/data/doctor/index.ts +29 -0
  28. package/data/orchestrators/index.ts +59 -0
  29. package/data/push/topics.ts +2 -0
  30. package/data/simulator/index.test.ts +14 -0
  31. package/data/simulator/index.ts +33 -0
  32. package/data/simulator/keys.ts +5 -0
  33. package/hooks/useNeedsYou.ts +53 -0
  34. package/lib/chat/events.ts +2 -0
  35. package/lib/chat/find.ts +25 -4
  36. package/lib/chat/group.ts +50 -2
  37. package/lib/chat/turns.ts +111 -0
  38. package/lib/db/changes.ts +6 -0
  39. package/lib/db/migrations.ts +51 -0
  40. package/lib/doctor/checks.ts +222 -0
  41. package/lib/orchestrator/ask-view.ts +13 -0
  42. package/lib/orchestrator/brief.ts +5 -1
  43. package/lib/orchestrator/deliver.ts +3 -0
  44. package/lib/orchestrator/events.ts +19 -0
  45. package/lib/orchestrator/needs-you-items.ts +104 -0
  46. package/lib/orchestrator/overview.ts +3 -0
  47. package/lib/orchestrator/questions.ts +120 -0
  48. package/lib/orchestrator/serve.ts +11 -0
  49. package/lib/orchestrator/tool-names.ts +2 -0
  50. package/lib/orchestrator/tool-schemas.ts +12 -0
  51. package/lib/orchestrator/tools.ts +4 -0
  52. package/lib/project-config/agentos.schema.json +19 -0
  53. package/lib/project-config/brief.ts +3 -0
  54. package/lib/project-config/schema.ts +10 -0
  55. package/lib/sessions/end.ts +3 -0
  56. package/lib/sessions/stop.ts +3 -0
  57. package/lib/simulator/agent-device.ts +258 -0
  58. package/lib/simulator/available.ts +5 -0
  59. package/lib/simulator/brief.ts +18 -0
  60. package/lib/simulator/http.ts +16 -0
  61. package/lib/simulator/index.ts +458 -0
  62. package/lib/simulator/lease.ts +169 -0
  63. package/lib/simulator/simctl.ts +167 -0
  64. package/lib/welcome-url.ts +6 -1
  65. package/package.json +1 -1
  66. package/server.ts +16 -0
  67. package/stores/needsYouFocus.ts +13 -0
  68. package/stores/welcomeUi.test.ts +15 -0
  69. package/stores/welcomeUi.ts +6 -1
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Questions the orchestrator puts to Saad in chat. Marking one open pins it
3
+ * on his Needs-you strip until he answers it (the strip's Reply) or the
4
+ * orchestrator clears it. Unlike an ask it has no Approve or Decline: it is
5
+ * just words that need an answer. Dismissing an item from the strip only
6
+ * hides it, for asks and questions alike.
7
+ */
8
+
9
+ import { db } from "../db";
10
+ import type { QuestionView } from "./ask-view";
11
+ import { workspaceTask } from "./targets";
12
+
13
+ export const MAX_OPEN_QUESTIONS = 10;
14
+
15
+ interface QuestionRow {
16
+ id: number;
17
+ text: string;
18
+ task: string | null;
19
+ task_name: string | null;
20
+ created_at: string;
21
+ }
22
+
23
+ export function openQuestions(workspaceId: string): QuestionView[] {
24
+ settleQuestions(workspaceId);
25
+ return (
26
+ db
27
+ .prepare(
28
+ `SELECT q.id, q.text, q.task, s.name AS task_name, q.created_at
29
+ FROM orchestrator_questions q LEFT JOIN sessions s ON s.id = q.task
30
+ WHERE q.workspace_id = ? AND q.status = 'open'
31
+ ORDER BY q.id DESC`
32
+ )
33
+ .all(workspaceId) as QuestionRow[]
34
+ ).map((r) => ({
35
+ id: r.id,
36
+ text: r.text,
37
+ task: r.task,
38
+ taskName: r.task_name,
39
+ createdAt: r.created_at,
40
+ }));
41
+ }
42
+
43
+ // A question about work that has finished no longer needs an answer.
44
+ function settleQuestions(workspaceId: string): void {
45
+ db.prepare(
46
+ `UPDATE orchestrator_questions
47
+ SET status = 'cleared', resolved_by = 'orchestrator', resolved_at = datetime('now')
48
+ WHERE workspace_id = ? AND status = 'open' AND task IN (
49
+ SELECT id FROM sessions WHERE task_status IN ('merged', 'dropped', 'done'))`
50
+ ).run(workspaceId);
51
+ }
52
+
53
+ // The same words while open are the same question.
54
+ export function askQuestion(
55
+ workspaceId: string,
56
+ text: string,
57
+ taskRef?: string
58
+ ): string {
59
+ const task = taskRef ? workspaceTask(workspaceId, taskRef).id : null;
60
+ const open = db
61
+ .prepare(
62
+ `SELECT id FROM orchestrator_questions
63
+ WHERE workspace_id = ? AND status = 'open' AND text = ?`
64
+ )
65
+ .get(workspaceId, text) as { id: number } | undefined;
66
+ if (open) return `Question ${open.id} is already open; left as it is.`;
67
+ const count = (
68
+ db
69
+ .prepare(
70
+ `SELECT COUNT(*) AS n FROM orchestrator_questions
71
+ WHERE workspace_id = ? AND status = 'open'`
72
+ )
73
+ .get(workspaceId) as { n: number }
74
+ ).n;
75
+ if (count >= MAX_OPEN_QUESTIONS)
76
+ return `Not opened: ${count} questions are already open. Clear the ones that no longer matter with resolve_question first.`;
77
+ const { lastInsertRowid } = db
78
+ .prepare(
79
+ `INSERT INTO orchestrator_questions (workspace_id, text, task) VALUES (?, ?, ?)`
80
+ )
81
+ .run(workspaceId, text, task);
82
+ return `Question ${lastInsertRowid} is open on Saad's Needs-you strip. Call resolve_question ${lastInsertRowid} once he has answered or it no longer matters.`;
83
+ }
84
+
85
+ export function resolveQuestion(
86
+ workspaceId: string,
87
+ id: number,
88
+ by: "saad" | "orchestrator"
89
+ ): boolean {
90
+ return (
91
+ db
92
+ .prepare(
93
+ `UPDATE orchestrator_questions
94
+ SET status = ?, resolved_by = ?, resolved_at = datetime('now')
95
+ WHERE id = ? AND workspace_id = ? AND status = 'open'`
96
+ )
97
+ .run(by === "saad" ? "answered" : "cleared", by, id, workspaceId)
98
+ .changes > 0
99
+ );
100
+ }
101
+
102
+ export function dismissItem(workspaceId: string, key: string): void {
103
+ db.prepare(
104
+ `INSERT OR IGNORE INTO needs_you_dismissed (workspace_id, key) VALUES (?, ?)`
105
+ ).run(workspaceId, key);
106
+ }
107
+
108
+ export function undismissItem(workspaceId: string, key: string): void {
109
+ db.prepare(
110
+ `DELETE FROM needs_you_dismissed WHERE workspace_id = ? AND key = ?`
111
+ ).run(workspaceId, key);
112
+ }
113
+
114
+ export function dismissedKeys(workspaceId: string): string[] {
115
+ return (
116
+ db
117
+ .prepare(`SELECT key FROM needs_you_dismissed WHERE workspace_id = ?`)
118
+ .all(workspaceId) as { key: string }[]
119
+ ).map((r) => r.key);
120
+ }
@@ -23,6 +23,7 @@ import { addNote } from "./notes";
23
23
  import { addProject } from "./add-project";
24
24
  import { addRule, removeRule } from "./rules";
25
25
  import { askSaad } from "./ask-tool";
26
+ import { askQuestion, resolveQuestion } from "./questions";
26
27
  import { listOrchestratorPeers, messageOrchestrator } from "./cross";
27
28
  import { PAUSED_REFUSAL } from "./pause";
28
29
  import { readSession } from "./read";
@@ -211,5 +212,15 @@ export async function runTool(
211
212
  return disarm(w, p(tool, raw).task);
212
213
  case "ask_saad":
213
214
  return askSaad(w, p(tool, raw));
215
+ case "question": {
216
+ const a = p(tool, raw);
217
+ return askQuestion(w, a.text, a.task);
218
+ }
219
+ case "resolve_question": {
220
+ const id = p(tool, raw).id;
221
+ return resolveQuestion(w, id, "orchestrator")
222
+ ? `Question ${id} cleared.`
223
+ : `No open question ${id}.`;
224
+ }
214
225
  }
215
226
  }
@@ -29,6 +29,8 @@ export const TOOL_NAMES = {
29
29
  done: name("done"),
30
30
  add_project: name("add_project"),
31
31
  note: name("note"),
32
+ question: name("question"),
33
+ resolve_question: name("resolve_question"),
32
34
  rule_add: name("rule_add"),
33
35
  rule_remove: name("rule_remove"),
34
36
  review: name("review"),
@@ -239,6 +239,18 @@ export const TOOL_SHAPES = {
239
239
  .describe("A new project's name (default: the folder's)"),
240
240
  },
241
241
  note: { text: z.string().trim().min(1).max(2000) },
242
+ question: {
243
+ text: z
244
+ .string()
245
+ .trim()
246
+ .min(1)
247
+ .max(300)
248
+ .describe("The question as you asked it in chat, one or two sentences"),
249
+ task: ref("The task it is about, when it names one").optional(),
250
+ },
251
+ resolve_question: {
252
+ id: z.number().int().positive().describe("The question's id"),
253
+ },
242
254
  rule_add: {
243
255
  text: z
244
256
  .string()
@@ -101,6 +101,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
101
101
  arm: "Arm a task's PR to merge: AgentOS merges it, through the same gates as sign_off, the moment they all pass at its head, and tells you with one event. A gate failure comes as one event and it stays armed for the fix; one that goes to Saad disarms it. Survives restarts. Use it instead of calling sign_off until it passes.",
102
102
  disarm:
103
103
  "Disarm a task armed to merge: it merges only through sign_off again.",
104
+ question:
105
+ "Mark a question you asked Saad in chat as open: it stays pinned on his Needs-you strip until he answers or you clear it. Use it for anything you need from him that isn't an ask_saad item. The same text while open is the same question.",
106
+ resolve_question:
107
+ "Clear an open question once Saad has answered it in chat, or it no longer matters.",
104
108
  ask_saad:
105
109
  "Park an item on Saad's asks list (a decision, or anything crossing a hard line) and carry on; it never waits. One open ask per title. His answer reaches you as an event. To ask whether to merge a PR the gates refuse, give task and sha (its head): his approval lets sign_off merge it once, at that commit.",
106
110
  };
@@ -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
+ }