@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.
- package/README.md +82 -3
- package/app/api/doctor/route.ts +8 -0
- package/app/api/sessions/[id]/simulator/route.ts +32 -0
- package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
- package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
- package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
- package/app/page.tsx +8 -3
- package/components/Chat/ChatPanel.tsx +96 -8
- package/components/Chat/EventRow.tsx +2 -2
- package/components/Chat/EventTurns.test.ts +56 -0
- package/components/Chat/EventTurns.tsx +141 -0
- package/components/Chat/useFind.ts +5 -3
- package/components/Chat/useGoToItem.ts +71 -0
- package/components/Orchestrator/AsksList.tsx +16 -11
- package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
- package/components/Orchestrator/OrchestratorBar.tsx +27 -2
- package/components/Settings/SettingsDialog.tsx +2 -2
- package/components/Settings/SetupSection.tsx +6 -1
- package/components/Simulator/ScreenshotStrip.test.ts +91 -0
- package/components/Simulator/ScreenshotStrip.tsx +102 -0
- package/components/Simulator/index.ts +1 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Welcome/DevicesStep.tsx +90 -0
- package/components/Welcome/HealthList.tsx +69 -0
- package/components/Welcome/ProjectStep.tsx +2 -8
- package/components/Welcome/WelcomeDialog.tsx +31 -7
- package/data/doctor/index.ts +29 -0
- package/data/orchestrators/index.ts +59 -0
- package/data/push/topics.ts +2 -0
- package/data/simulator/index.test.ts +14 -0
- package/data/simulator/index.ts +33 -0
- package/data/simulator/keys.ts +5 -0
- package/hooks/useNeedsYou.ts +53 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/find.ts +25 -4
- package/lib/chat/group.ts +50 -2
- package/lib/chat/turns.ts +111 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations.ts +51 -0
- package/lib/doctor/checks.ts +222 -0
- package/lib/orchestrator/ask-view.ts +13 -0
- package/lib/orchestrator/brief.ts +5 -1
- package/lib/orchestrator/deliver.ts +3 -0
- package/lib/orchestrator/events.ts +19 -0
- package/lib/orchestrator/needs-you-items.ts +104 -0
- package/lib/orchestrator/overview.ts +3 -0
- package/lib/orchestrator/questions.ts +120 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +12 -0
- package/lib/orchestrator/tools.ts +4 -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/lib/welcome-url.ts +6 -1
- package/package.json +1 -1
- package/server.ts +16 -0
- package/stores/needsYouFocus.ts +13 -0
- package/stores/welcomeUi.test.ts +15 -0
- 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(),
|
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
|
+
}
|