@saadnvd1/agent-os 0.3.609 → 0.3.611

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.
@@ -20,6 +20,7 @@ export async function POST(
20
20
  stderr: "",
21
21
  code: 0,
22
22
  screenshot: null,
23
+ read: null,
23
24
  });
24
25
  if (
25
26
  !Array.isArray(body.args) ||
package/lib/load/alert.ts CHANGED
@@ -18,9 +18,14 @@ export function alertText(view: LoadView): string {
18
18
  : "";
19
19
  return `${s.name} ${s.cores.toFixed(1)} cores, ${gb(s.rssBytes)}${heavy}`;
20
20
  });
21
+ const others = (view.unattributed ?? []).map(
22
+ (g) =>
23
+ `${g.name}${g.count > 1 ? ` ×${g.count}` : ""} ${g.cores.toFixed(1)} cores, ${gb(g.rssBytes)}`
24
+ );
21
25
  return [
22
26
  `Machine load red for 2+ minutes: ${view.load1} on ${view.cores} cores${memory}.`,
23
27
  top.length ? `Top: ${top.join("; ")}.` : "",
28
+ others.length ? `Not from a session: ${others.join("; ")}.` : "",
24
29
  ]
25
30
  .filter(Boolean)
26
31
  .join(" ");
@@ -0,0 +1,217 @@
1
+ // Who is using the machine: every process goes to the session whose tree it
2
+ // is in, or, for a heavy one that left its tree (a build daemon reparented to
3
+ // init), to the session whose worktree it runs in. What's left is grouped by
4
+ // name so the orchestrator can tell its tasks from everything else.
5
+ import { execFile } from "child_process";
6
+ import { existsSync } from "fs";
7
+ import { readlink } from "fs/promises";
8
+ import path from "path";
9
+ import type { ProcRow } from "../process-table";
10
+ import { workerTmuxName } from "../chat/worker/protocol";
11
+ import { claimTrees, type Usage } from "./usage";
12
+
13
+ // A row shows its usage, and a group is listed, from here up.
14
+ export const BUSY_CORES = 0.5;
15
+ const HOT_PCPU = 20;
16
+ const MAX_LOOKUPS = 40;
17
+ const MAX_HOT = 20;
18
+ const MAX_GROUPS = 3;
19
+ const BIG_RSS = 2 ** 30;
20
+
21
+ export interface SessionLoad extends Usage {
22
+ sessionId: string;
23
+ name: string;
24
+ heavy: string[];
25
+ }
26
+
27
+ export interface ProcGroup {
28
+ name: string;
29
+ count: number;
30
+ cores: number;
31
+ rssBytes: number;
32
+ }
33
+
34
+ export interface SessionRow {
35
+ id: string;
36
+ name: string;
37
+ tmux_name: string;
38
+ worktree_path: string | null;
39
+ working_directory: string;
40
+ }
41
+
42
+ const round1 = (n: number) => Math.round(n * 10) / 10;
43
+ const baseName = (comm: string) =>
44
+ comm.split("/").pop()?.replace(/^-/, "") || comm;
45
+
46
+ // Each session's own tmux session, plus its chat worker's.
47
+ export function claimSessions(
48
+ procs: ProcRow[],
49
+ panes: { tmux: string; pid: number }[],
50
+ rows: SessionRow[]
51
+ ): Map<number, string> {
52
+ const roots: { key: string; pid: number }[] = [];
53
+ for (const row of rows)
54
+ for (const p of panes)
55
+ if (p.tmux === row.tmux_name || p.tmux === workerTmuxName(row.id))
56
+ roots.push({ key: row.id, pid: p.pid });
57
+ return claimTrees(procs, roots);
58
+ }
59
+
60
+ // The directories a process's cwd can place it by, longest first. The
61
+ // worktree when there is one; otherwise the working directory, but only when
62
+ // it is specific to the session.
63
+ export function sessionPaths(
64
+ rows: SessionRow[],
65
+ home: string
66
+ ): { sessionId: string; path: string }[] {
67
+ const count = new Map<string, number>();
68
+ for (const r of rows)
69
+ count.set(r.working_directory, (count.get(r.working_directory) ?? 0) + 1);
70
+ const out: { sessionId: string; path: string }[] = [];
71
+ for (const r of rows) {
72
+ let dir = r.worktree_path;
73
+ if (!dir) {
74
+ dir = r.working_directory.replace(/^~(?=$|\/)/, home);
75
+ if (
76
+ !path.isAbsolute(dir) ||
77
+ dir === "/" ||
78
+ dir === home ||
79
+ count.get(r.working_directory)! > 1
80
+ )
81
+ continue;
82
+ }
83
+ out.push({ sessionId: r.id, path: dir.replace(/\/+$/, "") });
84
+ }
85
+ return out.sort((a, b) => b.path.length - a.path.length);
86
+ }
87
+
88
+ export function matchSession(
89
+ cwd: string,
90
+ paths: { sessionId: string; path: string }[]
91
+ ): string | null {
92
+ const hit = paths.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
93
+ return hit?.sessionId ?? null;
94
+ }
95
+
96
+ // The heavy processes no session owns, and their unowned ancestors (the
97
+ // daemon that spawned the workers), whose cwd is worth looking up.
98
+ export function cwdCandidates(
99
+ procs: ProcRow[],
100
+ owner: Map<number, string>
101
+ ): number[] {
102
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
103
+ const hot = procs
104
+ .filter((p) => !owner.has(p.pid) && p.pcpu >= HOT_PCPU)
105
+ .sort((a, b) => b.pcpu - a.pcpu)
106
+ .slice(0, MAX_HOT);
107
+ const out = new Set<number>();
108
+ for (const p of hot) {
109
+ let at: ProcRow | undefined = p;
110
+ while (at && at.pid > 1 && !owner.has(at.pid) && out.size < MAX_LOOKUPS) {
111
+ out.add(at.pid);
112
+ at = byPid.get(at.ppid);
113
+ }
114
+ }
115
+ return [...out];
116
+ }
117
+
118
+ // `lsof -a -d cwd -Fpn`: a `p<pid>` line, then `n<path>` for its cwd.
119
+ export function parseLsofCwd(out: string): Map<number, string> {
120
+ const cwds = new Map<number, string>();
121
+ let pid = 0;
122
+ for (const line of out.split("\n")) {
123
+ if (line.startsWith("p")) pid = Number(line.slice(1)) || 0;
124
+ else if (line.startsWith("n") && pid) cwds.set(pid, line.slice(1));
125
+ }
126
+ return cwds;
127
+ }
128
+
129
+ const LSOF = ["/usr/sbin/lsof", "/usr/bin/lsof"].find((p) => existsSync(p));
130
+
131
+ export async function readCwds(pids: number[]): Promise<Map<number, string>> {
132
+ const cwds = new Map<number, string>();
133
+ if (!pids.length) return cwds;
134
+ if (process.platform === "linux") {
135
+ await Promise.all(
136
+ pids.map((pid) =>
137
+ readlink(`/proc/${pid}/cwd`).then(
138
+ (dir) => void cwds.set(pid, dir),
139
+ () => undefined
140
+ )
141
+ )
142
+ );
143
+ return cwds;
144
+ }
145
+ return new Promise((resolve) =>
146
+ execFile(
147
+ LSOF ?? "lsof",
148
+ ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")],
149
+ { timeout: 2000, maxBuffer: 1 << 20 },
150
+ // lsof exits 1 when some pids are gone but still prints the rest.
151
+ (_err, stdout) => resolve(parseLsofCwd(stdout ?? ""))
152
+ )
153
+ );
154
+ }
155
+
156
+ export function attributeLoad(input: {
157
+ procs: ProcRow[];
158
+ owner: Map<number, string>;
159
+ rows: SessionRow[];
160
+ cwds: Map<number, string>;
161
+ home: string;
162
+ }): { sessions: Record<string, SessionLoad>; unattributed: ProcGroup[] } {
163
+ const { procs, owner, rows, cwds, home } = input;
164
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
165
+ const paths = sessionPaths(rows, home);
166
+ // Memoised: the session an unowned process belongs to, by its own cwd or
167
+ // its nearest unowned ancestor's.
168
+ const placed = new Map<number, string | null>();
169
+ const place = (p: ProcRow, depth = 0): string | null => {
170
+ if (placed.has(p.pid)) return placed.get(p.pid)!;
171
+ const cwd = cwds.get(p.pid);
172
+ let id = cwd ? matchSession(cwd, paths) : null;
173
+ const parent = byPid.get(p.ppid);
174
+ if (!id && parent && parent.pid > 1 && depth < 64 && !owner.has(parent.pid))
175
+ id = place(parent, depth + 1);
176
+ placed.set(p.pid, id);
177
+ return id;
178
+ };
179
+
180
+ const raw = new Map<string, Usage>();
181
+ const groups = new Map<string, ProcGroup>();
182
+ for (const p of procs) {
183
+ const id = owner.get(p.pid) ?? place(p);
184
+ if (id) {
185
+ const u = raw.get(id) ?? { cores: 0, rssBytes: 0 };
186
+ u.cores += p.pcpu / 100;
187
+ u.rssBytes += p.rss * 1024;
188
+ raw.set(id, u);
189
+ continue;
190
+ }
191
+ const name = baseName(p.comm);
192
+ const g = groups.get(name) ?? { name, count: 0, cores: 0, rssBytes: 0 };
193
+ g.count++;
194
+ g.cores += p.pcpu / 100;
195
+ g.rssBytes += p.rss * 1024;
196
+ groups.set(name, g);
197
+ }
198
+
199
+ const sessions: Record<string, SessionLoad> = {};
200
+ for (const row of rows) {
201
+ const u = raw.get(row.id);
202
+ if (!u) continue;
203
+ sessions[row.id] = {
204
+ sessionId: row.id,
205
+ name: row.name,
206
+ cores: round1(u.cores),
207
+ rssBytes: u.rssBytes,
208
+ heavy: [],
209
+ };
210
+ }
211
+ const unattributed = [...groups.values()]
212
+ .filter((g) => g.cores >= BUSY_CORES || g.rssBytes >= BIG_RSS)
213
+ .sort((a, b) => b.cores - a.cores)
214
+ .slice(0, MAX_GROUPS)
215
+ .map((g) => ({ ...g, cores: round1(g.cores) }));
216
+ return { sessions, unattributed };
217
+ }
@@ -9,21 +9,24 @@ import { LoadAlarm } from "./alarm";
9
9
  import { HeavyRegistry, type HeavyRun } from "./heavy";
10
10
  import { nextLevel, type LoadLevel, type Pressure } from "./level";
11
11
  import { readPressure } from "./pressure";
12
- import { readUsage, type Usage } from "./usage";
13
- import { workerTmuxName } from "../chat/worker/protocol";
12
+ import type { Usage } from "./usage";
13
+ import { readSnapshot } from "./usage";
14
+ import {
15
+ attributeLoad,
16
+ BUSY_CORES,
17
+ claimSessions,
18
+ cwdCandidates,
19
+ readCwds,
20
+ type ProcGroup,
21
+ type SessionLoad,
22
+ type SessionRow,
23
+ } from "./attribute";
14
24
  import { alertText, raiseAlert } from "./alert";
15
25
  import { LOCAL, feedGate, gateOpen } from "./start-gate";
16
26
 
17
27
  export const SAMPLE_MS = 10_000;
18
28
  export const USAGE_MS = 30_000;
19
- // A row shows its usage from here up.
20
- const BUSY_CORES = 0.5;
21
-
22
- export interface SessionLoad extends Usage {
23
- sessionId: string;
24
- name: string;
25
- heavy: string[];
26
- }
29
+ export type { SessionLoad, ProcGroup };
27
30
 
28
31
  export interface LoadView {
29
32
  level: LoadLevel;
@@ -32,6 +35,8 @@ export interface LoadView {
32
35
  memUsedPct: number;
33
36
  pressure: Pressure;
34
37
  top: SessionLoad[];
38
+ // Heavy processes that belong to no session, grouped by name.
39
+ unattributed: ProcGroup[];
35
40
  sessions: Record<string, Usage>;
36
41
  }
37
42
 
@@ -39,6 +44,7 @@ interface State {
39
44
  level: LoadLevel | null;
40
45
  view: LoadView | null;
41
46
  usage: Record<string, SessionLoad>;
47
+ unattributed: ProcGroup[];
42
48
  registry: HeavyRegistry;
43
49
  alarm: LoadAlarm;
44
50
  timers: ReturnType<typeof setInterval>[];
@@ -50,6 +56,7 @@ const state: State = (g.__agentosLoad ??= {
50
56
  level: null,
51
57
  view: null,
52
58
  usage: {},
59
+ unattributed: [],
53
60
  registry: new HeavyRegistry(),
54
61
  alarm: new LoadAlarm(),
55
62
  timers: [],
@@ -107,6 +114,7 @@ async function sample(): Promise<void> {
107
114
  memUsedPct: Math.round(((total - os.freemem()) / total) * 100),
108
115
  pressure,
109
116
  top: topSessions(),
117
+ unattributed: state.unattributed,
110
118
  sessions,
111
119
  };
112
120
  state.view = view;
@@ -145,39 +153,24 @@ export function loadAlarmFromDb(): void {
145
153
  state.alarm = new LoadAlarm(readArmed());
146
154
  }
147
155
 
148
- type UsageRow = { id: string; name: string; tmux_name: string };
149
-
150
- // Each session's share of the usage: its own tmux session's, plus its chat
151
- // worker's, where a chat's agent runs.
152
- export function sessionUsage(
153
- byTmux: Record<string, Usage>,
154
- rows: UsageRow[]
155
- ): Record<string, SessionLoad> {
156
- const usage: Record<string, SessionLoad> = {};
157
- for (const row of rows) {
158
- const parts = [byTmux[row.tmux_name], byTmux[workerTmuxName(row.id)]];
159
- const found = parts.filter((u): u is Usage => !!u);
160
- if (!found.length) continue;
161
- const cores = found.reduce((n, u) => n + u.cores, 0);
162
- usage[row.id] = {
163
- sessionId: row.id,
164
- name: row.name,
165
- cores: Math.round(cores * 10) / 10,
166
- rssBytes: found.reduce((n, u) => n + u.rssBytes, 0),
167
- heavy: [],
168
- };
169
- }
170
- return usage;
171
- }
172
-
173
156
  async function sampleUsage(): Promise<void> {
174
- const byTmux = await readUsage();
157
+ const { procs, panes } = await readSnapshot();
175
158
  const rows = db
176
159
  .prepare(
177
- `SELECT id, name, tmux_name FROM sessions WHERE archived_at IS NULL AND host_id = 'local'`
160
+ `SELECT id, name, tmux_name, worktree_path, working_directory FROM sessions WHERE archived_at IS NULL AND host_id = 'local'`
178
161
  )
179
- .all() as UsageRow[];
180
- state.usage = sessionUsage(byTmux, rows);
162
+ .all() as SessionRow[];
163
+ const owner = claimSessions(procs, panes, rows);
164
+ const cwds = await readCwds(cwdCandidates(procs, owner));
165
+ const { sessions, unattributed } = attributeLoad({
166
+ procs,
167
+ owner,
168
+ rows,
169
+ cwds,
170
+ home: os.homedir(),
171
+ });
172
+ state.usage = sessions;
173
+ state.unattributed = unattributed;
181
174
  }
182
175
 
183
176
  // Off the hot path: each tick is async, never overlaps the last, and logs
package/lib/load/usage.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // What each session's processes use: one `ps` for the whole machine and one
2
- // `tmux list-panes`, then each pane's process tree summed. Never a call per
2
+ // `tmux list-panes`, then each pane's process tree walked. Never a call per
3
3
  // session.
4
4
  import { execFile } from "child_process";
5
+ import type { ProcRow } from "../process-table";
5
6
 
6
7
  export interface Proc {
7
8
  pid: number;
@@ -37,36 +38,45 @@ export function parsePanes(out: string): { tmux: string; pid: number }[] {
37
38
  .map(([tmux, pid]) => ({ tmux, pid: Number(pid) }));
38
39
  }
39
40
 
40
- // Each key's panes and everything under them. A process is counted once,
41
- // for the first key that reaches it.
42
- export function sumTrees(
41
+ // The key that owns each process: its panes and everything under them. A
42
+ // process goes to the first key that reaches it.
43
+ export function claimTrees(
43
44
  procs: Proc[],
44
45
  roots: { key: string; pid: number }[]
45
- ): Record<string, Usage> {
46
- const byPid = new Map(procs.map((p) => [p.pid, p]));
46
+ ): Map<number, string> {
47
47
  const children = new Map<number, number[]>();
48
48
  for (const p of procs) {
49
49
  const list = children.get(p.ppid);
50
50
  if (list) list.push(p.pid);
51
51
  else children.set(p.ppid, [p.pid]);
52
52
  }
53
- const seen = new Set<number>();
54
- const out: Record<string, Usage> = {};
53
+ const owner = new Map<number, string>();
55
54
  for (const { key, pid } of roots) {
56
- const usage = (out[key] ??= { cores: 0, rssBytes: 0 });
57
55
  const stack = [pid];
58
56
  while (stack.length) {
59
57
  const at = stack.pop()!;
60
- if (seen.has(at)) continue;
61
- seen.add(at);
62
- const p = byPid.get(at);
63
- if (p) {
64
- usage.cores += p.pcpu / 100;
65
- usage.rssBytes += p.rss * 1024;
66
- }
58
+ if (owner.has(at)) continue;
59
+ owner.set(at, key);
67
60
  stack.push(...(children.get(at) ?? []));
68
61
  }
69
62
  }
63
+ return owner;
64
+ }
65
+
66
+ // Each key's panes and everything under them, summed.
67
+ export function sumTrees(
68
+ procs: Proc[],
69
+ roots: { key: string; pid: number }[]
70
+ ): Record<string, Usage> {
71
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
72
+ const out: Record<string, Usage> = {};
73
+ for (const { key } of roots) out[key] ??= { cores: 0, rssBytes: 0 };
74
+ for (const [pid, key] of claimTrees(procs, roots)) {
75
+ const p = byPid.get(pid);
76
+ if (!p) continue;
77
+ out[key].cores += p.pcpu / 100;
78
+ out[key].rssBytes += p.rss * 1024;
79
+ }
70
80
  return out;
71
81
  }
72
82
 
@@ -77,11 +87,13 @@ const run = (cmd: string, args: string[], timeout: number) =>
77
87
  )
78
88
  );
79
89
 
80
- // Usage by tmux session name. Empty when ps or tmux can't be read. The
81
- // process table is the one shared with the rest of the server.
82
- export async function readUsage(
83
- timeoutMs = 3000
84
- ): Promise<Record<string, Usage>> {
90
+ // The process table and the tmux panes, for attributing load to sessions.
91
+ // Empty when ps or tmux can't be read. The table is the one shared with the
92
+ // rest of the server.
93
+ export async function readSnapshot(timeoutMs = 3000): Promise<{
94
+ procs: ProcRow[];
95
+ panes: { tmux: string; pid: number }[];
96
+ }> {
85
97
  const { processTable } = await import("../process-table");
86
98
  const [procs, panes] = await Promise.all([
87
99
  processTable(),
@@ -91,8 +103,5 @@ export async function readUsage(
91
103
  timeoutMs
92
104
  ),
93
105
  ]);
94
- return sumTrees(
95
- procs ?? [],
96
- parsePanes(panes).map((p) => ({ key: p.tmux, pid: p.pid }))
97
- );
106
+ return { procs: procs ?? [], panes: parsePanes(panes) };
98
107
  }
@@ -50,6 +50,10 @@ export function runningBrief(
50
50
  lines.push(
51
51
  `- \`${browse.map}\` maps every page to its URL: navigate by URL, don't explore.`
52
52
  );
53
+ if (browse)
54
+ lines.push(
55
+ "- Check state with the browser's text snapshot, not screenshots. Screenshot only the final changed screen, and just the changed element (its uid) rather than the full page where the tool allows; every image stays in your context."
56
+ );
53
57
  const sim = simulatorBrief(config.simulator);
54
58
  if (sim) lines.push(sim);
55
59
  for (const note of notesOf(config.notes))
@@ -12,7 +12,8 @@ export function simulatorBrief(
12
12
  return "- The iOS simulator tools are not available on this machine; skip simulator checks and say so in the PR.";
13
13
  return [
14
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.',
15
+ ' Drive and check state with `aos sim snapshot -i` (accessibility tree with @refs, text, cheap), `aos sim click @e3`, `aos sim fill @e5 "text"`, `aos sim logs --last 2m`. Refs are only valid from the latest snapshot.',
16
+ " Every image you read stays in your context for every later turn, so screenshot only the final changed screens, and prefer `aos sim screenshot --region @e3` (one element from the latest snapshot) for a single row or banner. It prints two paths: read the `read:` one (reduced to 800px); attach the `full-size` file to the PR.",
16
17
  " 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
18
  ].join("\n");
18
19
  }
@@ -24,6 +24,13 @@ import {
24
24
  simulatorDir,
25
25
  type CallResult,
26
26
  } from "./agent-device";
27
+ import {
28
+ RegionError,
29
+ cropToFrame,
30
+ frameOf,
31
+ reducedCopy,
32
+ takeRegion,
33
+ } from "./screenshot";
27
34
  import {
28
35
  allLeases,
29
36
  bootedCount,
@@ -320,7 +327,7 @@ async function wake(sessionId: string, lease: Lease): Promise<void> {
320
327
  export async function runSimulator(
321
328
  sessionId: string,
322
329
  args: string[]
323
- ): Promise<CallResult & { screenshot: string | null }> {
330
+ ): Promise<CallResult & { screenshot: string | null; read: string | null }> {
324
331
  const { config, cwd } = declared(sessionId);
325
332
  let lease = getLease(sessionId);
326
333
  if (!lease || lease.state === "failed")
@@ -333,23 +340,58 @@ export async function runSimulator(
333
340
  await wake(sessionId, lease);
334
341
  lease = getLease(sessionId)!;
335
342
  }
343
+ let region: string | null = null;
344
+ if (args[0] === "screenshot") {
345
+ try {
346
+ ({ args, region } = takeRegion(args));
347
+ } catch (error) {
348
+ throw new SimulatorError((error as Error).message, 400);
349
+ }
350
+ }
351
+ const target = { udid: lease.udid!, sessionId, app: config.app, cwd };
336
352
  let argv: string[];
337
353
  try {
338
- argv = buildArgs(args, {
339
- udid: lease.udid!,
340
- sessionId,
341
- app: config.app,
342
- cwd,
343
- });
354
+ argv = buildArgs(args, target);
344
355
  } catch (error) {
345
356
  throw new SimulatorError((error as Error).message, 400);
346
357
  }
347
358
  const shot = screenshotPath(argv);
348
359
  if (shot) fs.mkdirSync(path.dirname(shot), { recursive: true });
349
360
  touchLease(sessionId);
361
+ // The element's frame comes from a fresh snapshot, before the shot, so a
362
+ // bad ref fails without writing anything.
363
+ let located: ReturnType<typeof frameOf> | null = null;
364
+ if (region) {
365
+ const snap = await callTool(
366
+ buildArgs(["snapshot", "-i", "--json"], target)
367
+ );
368
+ try {
369
+ if (snap.code) throw new RegionError(snap.stderr || "snapshot failed");
370
+ located = frameOf(snap.stdout, region);
371
+ } catch (error) {
372
+ if (error instanceof RegionError)
373
+ throw new SimulatorError(error.message, 400);
374
+ throw error;
375
+ }
376
+ }
350
377
  const result = await callTool(argv);
378
+ const taken = shot && fs.existsSync(shot) ? shot : null;
379
+ let note = "";
380
+ if (taken && located) {
381
+ try {
382
+ await cropToFrame(taken, located.frame, located.screen);
383
+ } catch (error) {
384
+ note = `region crop failed (${(error as Error).message}); kept the full screen\n`;
385
+ }
386
+ }
387
+ const read = taken ? await reducedCopy(taken) : null;
351
388
  touchLease(sessionId);
352
- return { ...result, screenshot: shot && fs.existsSync(shot) ? shot : null };
389
+ return {
390
+ ...result,
391
+ stderr: result.stderr + note,
392
+ screenshot: taken,
393
+ read,
394
+ };
353
395
  }
354
396
 
355
397
  export async function simulatorLogs(sessionId: string, last: string) {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Cheaper screenshots for the agent: every image it reads costs tokens on
3
+ * every later turn, so it reads a copy capped at READ_SIZE px (or just one
4
+ * element of the screen) while the full-size file stays for the PR.
5
+ */
6
+
7
+ import { execFile } from "child_process";
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { promisify } from "util";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ export const READ_SIZE = 800;
15
+ // Points of context kept around a cropped element.
16
+ export const REGION_MARGIN = 8;
17
+
18
+ export type Sips = (args: string[]) => Promise<string>;
19
+
20
+ const realSips: Sips = async (args) =>
21
+ (await execFileAsync("sips", args, { timeout: 30_000 })).stdout;
22
+ let sips: Sips = realSips;
23
+ export const setSips = (fn: Sips | null) => {
24
+ sips = fn ?? realSips;
25
+ };
26
+
27
+ export interface Frame {
28
+ x: number;
29
+ y: number;
30
+ width: number;
31
+ height: number;
32
+ }
33
+ export interface Crop {
34
+ x: number;
35
+ y: number;
36
+ width: number;
37
+ height: number;
38
+ }
39
+
40
+ export class RegionError extends Error {}
41
+
42
+ // Where the agent reads from: a copy under shots/read, so the shots strip and
43
+ // the PR files stay the full-size ones.
44
+ export const readCopyPath = (full: string) =>
45
+ path.join(path.dirname(full), "read", path.basename(full));
46
+
47
+ async function pixelSize(file: string) {
48
+ const out = await sips(["-g", "pixelWidth", "-g", "pixelHeight", file]);
49
+ const w = Number(/pixelWidth:\s*(\d+)/.exec(out)?.[1]);
50
+ const h = Number(/pixelHeight:\s*(\d+)/.exec(out)?.[1]);
51
+ if (!w || !h) throw new Error("cannot read image size");
52
+ return { width: w, height: h };
53
+ }
54
+
55
+ // The reduced copy, or the full file when resizing fails.
56
+ export async function reducedCopy(full: string): Promise<string> {
57
+ const out = readCopyPath(full);
58
+ try {
59
+ fs.mkdirSync(path.dirname(out), { recursive: true });
60
+ await sips(["-Z", String(READ_SIZE), full, "--out", out]);
61
+ return fs.existsSync(out) ? out : full;
62
+ } catch {
63
+ return full;
64
+ }
65
+ }
66
+
67
+ // An element's frame (points) to a pixel rectangle of the image, with a
68
+ // margin, clamped to the image. `screen` is the size of the frame's space.
69
+ export function cropRect(
70
+ frame: Frame,
71
+ screen: { width: number; height: number },
72
+ image: { width: number; height: number },
73
+ margin = REGION_MARGIN
74
+ ): Crop {
75
+ const sx = image.width / screen.width;
76
+ const sy = image.height / screen.height;
77
+ const x0 = Math.max(0, Math.floor((frame.x - margin) * sx));
78
+ const y0 = Math.max(0, Math.floor((frame.y - margin) * sy));
79
+ const x1 = Math.min(
80
+ image.width,
81
+ Math.ceil((frame.x + frame.width + margin) * sx)
82
+ );
83
+ const y1 = Math.min(
84
+ image.height,
85
+ Math.ceil((frame.y + frame.height + margin) * sy)
86
+ );
87
+ if (x1 <= x0 || y1 <= y0)
88
+ throw new RegionError("that element is off the screen");
89
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
90
+ }
91
+
92
+ type Node = { ref?: unknown; rect?: Frame };
93
+
94
+ // Every node with a ref and a rect, wherever the JSON nests them.
95
+ function nodesOf(value: unknown, out: Node[] = []): Node[] {
96
+ if (Array.isArray(value)) value.forEach((v) => nodesOf(v, out));
97
+ else if (value && typeof value === "object") {
98
+ const n = value as Node;
99
+ if (typeof n.ref === "string" && n.rect) out.push(n);
100
+ for (const v of Object.values(value)) nodesOf(v, out);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ const isFrame = (r: unknown): r is Frame =>
106
+ !!r &&
107
+ ["x", "y", "width", "height"].every(
108
+ (k) => typeof (r as Record<string, unknown>)[k] === "number"
109
+ );
110
+
111
+ // The frame of `ref` in a `snapshot -i --json` output, and the screen size
112
+ // (the widest, tallest frame: the app's window).
113
+ export function frameOf(
114
+ json: string,
115
+ ref: string
116
+ ): { frame: Frame; screen: { width: number; height: number } } {
117
+ let nodes: Node[];
118
+ try {
119
+ nodes = nodesOf(JSON.parse(json)).filter((n) => isFrame(n.rect));
120
+ } catch {
121
+ throw new RegionError(
122
+ "could not read the snapshot; run `aos sim snapshot -i`"
123
+ );
124
+ }
125
+ const want = ref.replace(/^@/, "");
126
+ const hit = nodes.find((n) => String(n.ref).replace(/^@/, "") === want);
127
+ if (!hit)
128
+ throw new RegionError(
129
+ `unknown or stale ref @${want}: run \`aos sim snapshot -i\` and use a ref from it`
130
+ );
131
+ const screen = nodes.reduce(
132
+ (s, n) => ({
133
+ width: Math.max(s.width, n.rect!.x + n.rect!.width),
134
+ height: Math.max(s.height, n.rect!.y + n.rect!.height),
135
+ }),
136
+ { width: 0, height: 0 }
137
+ );
138
+ return { frame: hit.rect!, screen };
139
+ }
140
+
141
+ // Crops `file` in place to the rectangle.
142
+ export async function cropInPlace(file: string, c: Crop): Promise<void> {
143
+ await sips([
144
+ "--cropToHeightWidth",
145
+ String(c.height),
146
+ String(c.width),
147
+ "--cropOffset",
148
+ String(c.y),
149
+ String(c.x),
150
+ file,
151
+ ]);
152
+ }
153
+
154
+ export async function cropToFrame(
155
+ file: string,
156
+ frame: Frame,
157
+ screen: { width: number; height: number }
158
+ ): Promise<void> {
159
+ await cropInPlace(file, cropRect(frame, screen, await pixelSize(file)));
160
+ }
161
+
162
+ // `--region <ref>` / `--region=<ref>` out of a screenshot's args.
163
+ export function takeRegion(args: string[]): {
164
+ args: string[];
165
+ region: string | null;
166
+ } {
167
+ const rest: string[] = [];
168
+ let region: string | null = null;
169
+ for (let i = 0; i < args.length; i++) {
170
+ const a = args[i];
171
+ if (a === "--region") {
172
+ region = args[++i] ?? "";
173
+ if (!region) throw new RegionError("--region needs a ref, like @e3");
174
+ } else if (a.startsWith("--region=")) region = a.slice(9);
175
+ else rest.push(a);
176
+ }
177
+ if (region !== null && !/^@?e?\d+$/.test(region) && !/^@\S+$/.test(region))
178
+ throw new RegionError("--region takes a ref from `aos sim snapshot -i`");
179
+ return { args: rest, region };
180
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.609",
3
+ "version": "0.3.611",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"