@stim-cli/core 1.13.0 → 1.15.0

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.
@@ -0,0 +1,666 @@
1
+ //#region oversight.ts
2
+ /** What a notification is about; each can be switched off on its own. */
3
+ const OVERSIGHT_CATEGORIES = [
4
+ "started",
5
+ "stuck",
6
+ "looping",
7
+ "finished",
8
+ "machine",
9
+ "control",
10
+ "attention"
11
+ ];
12
+ const DEFAULT_STUCK_MINUTES = 15;
13
+ /**
14
+ * What counts as someone working on a device: agent actions, reloads and Stim runs. App log records do not, because an
15
+ * idle app keeps logging; a new log error counts through the error count instead. The owned Chrome page records only
16
+ * an agent's input as agent actions, not its navigations or scripts, so its page log counts while an agent drives it.
17
+ */
18
+ const WORK_EVIDENCE = [
19
+ "agent-action",
20
+ "metro-bundle",
21
+ "workspace-use"
22
+ ];
23
+ const DISK_CRITICAL_BYTES = 5e9;
24
+ /** Free space a low-disk episode needs back before it ends, so space hovering at the floor notifies once. */
25
+ const DISK_RECOVERED_BYTES = 6e9;
26
+ const MEMORY_SETTLE_MS = 6e4;
27
+ const OFFLINE_SETTLE_MS = 6e4;
28
+ /** How long an agent must be gone, with nothing happening, before its green work counts as finished. */
29
+ const FINISH_SETTLE_MS = 3e5;
30
+ const LOOP_COUNT = 3;
31
+ /** A workspace absent this long is forgotten; a shorter gap is a status that briefly left it out. */
32
+ const FORGET_MS = 12e4;
33
+ const platformName = (platform) => platform === "ios" ? "iOS" : platform === "android" ? "Android" : platform;
34
+ const basename = (path) => path.split("/").findLast(Boolean) ?? path;
35
+ const time = (text) => text ? Date.parse(text) : NaN;
36
+ function worktreeRoot(path) {
37
+ const parts = path.split("/");
38
+ for (let i = parts.length - 2; i > 0; i--) {
39
+ if (parts[i] === ".worktrees") return parts.slice(0, i).join("/");
40
+ if (parts[i] === "worktrees" && parts[i - 1] === ".claude") return parts.slice(0, i - 1).join("/");
41
+ }
42
+ return null;
43
+ }
44
+ function markedCheckout(path) {
45
+ const parts = path.split("/");
46
+ for (let i = parts.length - 2; i > 0; i--) if (parts[i] === ".worktrees" || parts[i] === "worktrees" && parts[i - 1] === ".claude") return parts.slice(0, i + 2).join("/");
47
+ return null;
48
+ }
49
+ /** The name home shows for a workspace: its branch, else its checkout's folder. */
50
+ function oversightTitle(env, status) {
51
+ if (env.worktree?.branch) return env.worktree.branch;
52
+ const checkout = env.worktree?.path ?? markedCheckout(env.path);
53
+ if (checkout) return basename(checkout);
54
+ const roots = /* @__PURE__ */ new Set();
55
+ for (const { path, worktree } of status.environments) roots.add(worktreeRoot(path) ?? worktree?.repository ?? path);
56
+ for (const { path, repository } of status.unprovisionedWorktrees ?? []) {
57
+ const root = worktreeRoot(path) ?? repository;
58
+ if (root) roots.add(root);
59
+ }
60
+ const root = worktreeRoot(env.path) ?? env.worktree?.repository ?? [...roots].filter((r) => env.path === r || env.path.startsWith(`${r}/`)).reduce((best, r) => best === null || r.length < best.length ? r : best, null) ?? env.path;
61
+ return basename(root);
62
+ }
63
+ function devicesOf(env) {
64
+ const out = [];
65
+ const add = (slot, ios, android) => {
66
+ if (ios) {
67
+ const model = /\(([^()]*(?:\([^()]*\)[^()]*)*)\)\s*$/.exec(ios.name ?? "")?.[1];
68
+ out.push({
69
+ platform: "ios",
70
+ slot,
71
+ model: model ?? "iOS Simulator",
72
+ ...model === void 0 ? { modelLabel: "ios-simulator" } : {},
73
+ running: ios.state === "Booted",
74
+ activity: ios.activity
75
+ });
76
+ }
77
+ if (android) out.push({
78
+ platform: "android",
79
+ slot,
80
+ model: android.physical ? "Android device" : "Android Emulator",
81
+ modelLabel: android.physical ? "android-device" : "android-emulator",
82
+ running: android.state === "detected",
83
+ activity: android.activity
84
+ });
85
+ };
86
+ add("default", env.ios, env.android);
87
+ for (const slot of env.slots ?? []) add(slot.slot, slot.ios, slot.android);
88
+ if (env.web) out.push({
89
+ platform: "web",
90
+ slot: "default",
91
+ model: "Chrome",
92
+ modelLabel: "chrome",
93
+ running: env.web.running,
94
+ activity: env.web.activity
95
+ });
96
+ return out;
97
+ }
98
+ function agentDriven(device, ownLeases) {
99
+ const activity = device.activity;
100
+ if (activity?.state !== "driven") return false;
101
+ const since = activity.driver?.since;
102
+ return !(activity.driver?.tool === "stim device lock" && since && ownLeases.includes(since));
103
+ }
104
+ function newestBuild(env) {
105
+ return [env.lastBuilds?.ios, env.lastBuilds?.android].filter((b) => b !== void 0).reduce((a, b) => a === null || time(b.startedAt) > time(a.startedAt) ? b : a, null);
106
+ }
107
+ function lastActivityAt(env, devices, seen) {
108
+ const times = seen.map((at) => at ?? NaN);
109
+ for (const device of devices) {
110
+ const recent = device.activity?.recent;
111
+ if (!recent) times.push(time(device.activity?.lastActivityAt));
112
+ else {
113
+ times.push(...WORK_EVIDENCE.map((basis) => time(recent[basis])));
114
+ if (device.platform === "web" && device.activity?.state === "driven") times.push(time(recent["page-log"]));
115
+ }
116
+ times.push(time(device.activity?.driver?.since));
117
+ }
118
+ for (const build of [env.lastBuilds?.ios, env.lastBuilds?.android]) times.push(time(build?.startedAt), time(build?.finishedAt));
119
+ times.push(time(env.build?.startedAt));
120
+ const finite = times.filter(Number.isFinite);
121
+ return finite.length ? Math.max(...finite) : null;
122
+ }
123
+ const LANGUAGES = {
124
+ swift: "Swift",
125
+ m: "Objective-C",
126
+ mm: "Objective-C++",
127
+ kt: "Kotlin",
128
+ java: "Java",
129
+ c: "C",
130
+ cc: "C++",
131
+ cpp: "C++",
132
+ h: "C",
133
+ hpp: "C++",
134
+ js: "JavaScript",
135
+ ts: "TypeScript",
136
+ tsx: "TypeScript",
137
+ gradle: "Gradle",
138
+ kts: "Gradle"
139
+ };
140
+ const failed = (build) => (build.result ?? build.status) === "failed";
141
+ function causeOf(build) {
142
+ const at = build.diagnostics?.find((d) => d.file && d.line !== null);
143
+ if (at) return {
144
+ key: `${at.file}:${at.line}`,
145
+ at
146
+ };
147
+ return {
148
+ key: build.errorCode ?? "failed",
149
+ at: null
150
+ };
151
+ }
152
+ /** The streak of failures at the head of a platform's history that share the newest one's cause. */
153
+ function failureStreak(platform, history) {
154
+ const head = history?.[0];
155
+ if (!head || !failed(head)) return null;
156
+ const cause = causeOf(head);
157
+ let count = 0;
158
+ for (const build of history) {
159
+ if (!failed(build) || causeOf(build).key !== cause.key) break;
160
+ count++;
161
+ }
162
+ const body = (n, format = attentionBody) => {
163
+ if (cause.at) {
164
+ const file = basename(cause.at.file);
165
+ const language = LANGUAGES[file.split(".").at(-1)?.toLowerCase() ?? ""];
166
+ return format({
167
+ kind: "diagnostic-loop",
168
+ platform,
169
+ count: n,
170
+ file,
171
+ line: cause.at.line,
172
+ language
173
+ });
174
+ }
175
+ if (head.errorCode === "STIM_LAUNCH_FAILED") return format({
176
+ kind: "launch-loop",
177
+ platform,
178
+ count: n
179
+ });
180
+ return format({
181
+ kind: "build-loop",
182
+ platform,
183
+ count: n,
184
+ errorCode: head.errorCode
185
+ });
186
+ };
187
+ return {
188
+ signature: cause.key,
189
+ count,
190
+ body
191
+ };
192
+ }
193
+ function heldFor(held, now) {
194
+ return held ?? {
195
+ since: now,
196
+ notified: false
197
+ };
198
+ }
199
+ function wake(run, at) {
200
+ if (at > run.now) run.wakeAt = run.wakeAt === null ? at : Math.min(run.wakeAt, at);
201
+ }
202
+ /** Notifies an event now; one missed for quiet hours or a switched-off category is dropped. */
203
+ function event(run, notification) {
204
+ if (!run.baseline && !run.prefs.quiet && run.prefs.categories.includes(notification.category)) run.notifications.push(notification);
205
+ }
206
+ /** Whether a lasting problem is settled: notified now, or dropped when its category is off. */
207
+ function lasting(run, notification) {
208
+ if (run.baseline || !run.prefs.categories.includes(notification.category)) return true;
209
+ if (run.prefs.quiet) return false;
210
+ run.notifications.push(notification);
211
+ return true;
212
+ }
213
+ function machineNotification(run, id, body) {
214
+ return {
215
+ id: `machine:${id}`,
216
+ category: "machine",
217
+ title: run.input.machine,
218
+ body,
219
+ quiet: true,
220
+ thread: null,
221
+ target: { kind: "machine" }
222
+ };
223
+ }
224
+ const LINK_BODY = {
225
+ offline: "Offline",
226
+ unpaired: "Not paired: pair again",
227
+ refused: "Refused the connection: pair again or update"
228
+ };
229
+ function overseeMachine(run, previous, state, awakeSince) {
230
+ const { input, now } = run;
231
+ const kind = input.link;
232
+ if (kind !== null && kind !== "open") {
233
+ const held = {
234
+ ...heldFor(previous?.link?.kind === kind ? previous.link : void 0, now),
235
+ kind
236
+ };
237
+ const due = Math.max(held.since, awakeSince) + (kind === "offline" ? OFFLINE_SETTLE_MS : 0);
238
+ if (!held.notified && now < due) wake(run, due);
239
+ else if (!held.notified) held.notified = lasting(run, machineNotification(run, "link", LINK_BODY[kind]));
240
+ state.link = held;
241
+ }
242
+ const lowest = input.volumes?.reduce((min, v) => min === null ? v.freeBytes : Math.min(min, v.freeBytes), null);
243
+ if (input.volumes === null) {
244
+ if (previous?.disk) state.disk = previous.disk;
245
+ } else if (lowest !== null && lowest !== void 0 && lowest < (previous?.disk ? DISK_RECOVERED_BYTES : DISK_CRITICAL_BYTES)) {
246
+ const held = { ...heldFor(previous?.disk, now) };
247
+ const body = `${formatBytes(lowest)} free, below Stim's floor`;
248
+ if (!held.notified) held.notified = lasting(run, machineNotification(run, "disk", body));
249
+ state.disk = held;
250
+ }
251
+ state.memoryKnown = input.memoryPressure !== null || previous?.memoryKnown === true;
252
+ if (input.memoryPressure === "critical") {
253
+ const held = { ...heldFor(previous?.memory, now) };
254
+ if (!previous?.memoryKnown) held.notified = true;
255
+ const due = held.since + MEMORY_SETTLE_MS;
256
+ if (!held.notified && now < due && !run.baseline) wake(run, due);
257
+ else if (!held.notified) held.notified = lasting(run, machineNotification(run, "memory", "Memory pressure is critical"));
258
+ state.memory = held;
259
+ } else if (input.memoryPressure === null && previous?.memory) state.memory = previous.memory;
260
+ }
261
+ function deviceTarget(env, device) {
262
+ return {
263
+ kind: "device",
264
+ path: env.path,
265
+ platform: device.platform,
266
+ slot: device.slot
267
+ };
268
+ }
269
+ function overseeStart(run, { env, entry, notify, driven }) {
270
+ if (env.phase === "warming" && !entry.warmed) {
271
+ entry.warmed = true;
272
+ event(run, notify("started", `Warming on ${run.input.machine}`, {
273
+ kind: "workspace",
274
+ path: env.path
275
+ }));
276
+ }
277
+ const first = driven[0];
278
+ if (!first) return;
279
+ entry.drove = true;
280
+ entry.drivenAt = run.now;
281
+ entry.finished = false;
282
+ if (entry.driveNotified) return;
283
+ entry.driveNotified = true;
284
+ event(run, notify("started", `${first.activity?.driver?.tool ?? "An agent"} started driving ${first.model} on ${run.input.machine}`, deviceTarget(env, first)));
285
+ }
286
+ /** Work finishes when the agent stops after a green build; it looks stuck when nothing happens for too long. */
287
+ function overseeProgress(run, { env, entry, notify, devices, driven }) {
288
+ const { now } = run;
289
+ const idle = !env.live && (env.phase === void 0 || env.phase === "idle");
290
+ const building = env.build?.state === "running";
291
+ const newest = newestBuild(env);
292
+ const green = newest?.status === "ok";
293
+ const releasedGreen = driven.length === 0 && green;
294
+ const quietSince = lastActivityAt(env, devices, [entry.errorsAt, driven.length ? null : entry.drivenAt]);
295
+ if (entry.stuckAt !== null && quietSince !== null && quietSince > entry.stuckAt) entry.stuckAt = null;
296
+ if (entry.drove && releasedGreen && newest && !building && quietSince !== null && !entry.finished) {
297
+ const due = quietSince + FINISH_SETTLE_MS;
298
+ if (!idle && now < due) wake(run, due);
299
+ else {
300
+ entry.finished = true;
301
+ event(run, notify("finished", `Agent stopped after a green ${platformName(newest.platform)} build`, {
302
+ kind: "workspace",
303
+ path: env.path
304
+ }));
305
+ }
306
+ }
307
+ if (idle || entry.finished) {
308
+ entry.drove = false;
309
+ entry.driveNotified = false;
310
+ }
311
+ if (idle) {
312
+ entry.warmed = false;
313
+ entry.finished = false;
314
+ }
315
+ const device = driven[0] ?? devices.find((d) => d.running);
316
+ if (!entry.drove || !device?.running || building || quietSince === null || releasedGreen) return;
317
+ if (entry.stuckAt !== null) return;
318
+ const due = quietSince + run.prefs.stuckMinutes * 6e4;
319
+ if (now < due) return wake(run, due);
320
+ if (lasting(run, notify("stuck", `No agent activity for ${Math.floor((now - quietSince) / 6e4)} min${green && newest ? ` after a green ${platformName(newest.platform)} build` : ""}; ${device.model} still up`, deviceTarget(env, device)))) entry.stuckAt = quietSince;
321
+ }
322
+ function overseeLoops(run, { env, entry, notify }, prev) {
323
+ for (const platform of ["ios", "android"]) {
324
+ const streak = failureStreak(platform, env.builds?.[platform]);
325
+ if (!streak) continue;
326
+ const before = prev?.loops[platform];
327
+ const loop = before && before.signature === streak.signature && streak.count >= LOOP_COUNT ? { ...before } : {
328
+ signature: streak.signature,
329
+ notified: false
330
+ };
331
+ if (streak.count >= LOOP_COUNT && !loop.notified) {
332
+ const target = {
333
+ kind: "build",
334
+ path: env.path,
335
+ platform
336
+ };
337
+ loop.notified = lasting(run, notify("looping", streak.body(streak.count), target, `looping-${platform}`));
338
+ }
339
+ entry.loops[platform] = loop;
340
+ }
341
+ }
342
+ /** A pull request that became ready for review or merged, or, without GitHub, a branch git finds merged. */
343
+ function overseeMerge(run, { env, entry, notify }, prev) {
344
+ const pr = run.input.pullRequests[env.path];
345
+ if (pr !== void 0) {
346
+ const known = entry.pr;
347
+ const current = pr && {
348
+ number: pr.number,
349
+ ready: pr.state === "open" && !pr.draft,
350
+ merged: pr.state === "merged"
351
+ };
352
+ if (pr && current && known !== void 0) {
353
+ const url = {
354
+ kind: "url",
355
+ path: env.path,
356
+ url: pr.url
357
+ };
358
+ const same = known?.number === pr.number;
359
+ if (current.ready && !(same && known.ready)) event(run, notify("finished", `PR #${pr.number} is ready for review`, url));
360
+ if (current.merged && !(same && known.merged) && !entry.mergeNotified) {
361
+ entry.mergeNotified = true;
362
+ event(run, notify("finished", `PR #${pr.number} merged`, url));
363
+ }
364
+ } else if (current?.merged) entry.mergeNotified = true;
365
+ entry.pr = current;
366
+ }
367
+ if (!prev) {
368
+ if (entry.mergedInto !== null) entry.mergeNotified = true;
369
+ } else if (entry.mergedInto === null) {
370
+ if (env.worktree?.git && !entry.pr?.merged) entry.mergeNotified = false;
371
+ } else if (prev.mergedInto === null && !entry.mergeNotified) {
372
+ entry.mergeNotified = true;
373
+ event(run, notify("finished", `Merged into ${entry.mergedInto}`, {
374
+ kind: "workspace",
375
+ path: env.path
376
+ }));
377
+ }
378
+ }
379
+ /** What only a person can fix, notified once per episode; what an agent handles is left out. */
380
+ function overseeAttention(run, { env, entry, notify }, prev) {
381
+ const items = workspaceItems(env, {
382
+ now: run.now,
383
+ stuckMinutes: run.prefs.stuckMinutes,
384
+ easSessionMinutes: EAS_SESSION_MINUTES,
385
+ ownLeases: run.input.ownLeases
386
+ });
387
+ for (const item of items) {
388
+ if (item.category !== "attention") continue;
389
+ let target = {
390
+ kind: "workspace",
391
+ path: env.path
392
+ };
393
+ if (item.id.startsWith("run-")) {
394
+ const platform = item.id.startsWith("run-ios") ? "ios" : "android";
395
+ if ((failureStreak(platform, env.builds?.[platform])?.count ?? 0) >= LOOP_COUNT) continue;
396
+ target = {
397
+ kind: "build",
398
+ path: env.path,
399
+ platform
400
+ };
401
+ }
402
+ const notification = {
403
+ ...notify("attention", item.body, target),
404
+ id: item.id
405
+ };
406
+ if (prev?.attention?.includes(item.id) || lasting(run, notification)) entry.attention.push(item.id);
407
+ }
408
+ }
409
+ function overseeWorkspace(run, env, status, prev) {
410
+ const errors = env.logs?.errorsSinceMarker ?? 0;
411
+ const git = env.worktree?.git;
412
+ const entry = {
413
+ seenAt: run.now,
414
+ warmed: prev?.warmed ?? false,
415
+ drove: prev?.drove ?? false,
416
+ drivenAt: prev?.drivenAt ?? null,
417
+ driveNotified: prev?.driveNotified ?? false,
418
+ errors,
419
+ errorsAt: prev && prev.errors !== errors ? run.now : prev?.errorsAt ?? null,
420
+ stuckAt: prev?.stuckAt ?? null,
421
+ finished: prev?.finished ?? false,
422
+ attention: [],
423
+ loops: {},
424
+ pr: prev?.pr,
425
+ mergedInto: git ? git.mergedInto : prev?.mergedInto ?? null,
426
+ mergeNotified: prev?.mergeNotified ?? false
427
+ };
428
+ const title = oversightTitle(env, status);
429
+ const notify = (category, body, target, id = category) => ({
430
+ id: `${id}:${env.path}`,
431
+ category,
432
+ title,
433
+ body,
434
+ quiet: true,
435
+ thread: category === "started" ? `started:${run.input.machine}` : null,
436
+ target
437
+ });
438
+ const devices = devicesOf(env);
439
+ const look = {
440
+ env,
441
+ entry,
442
+ notify,
443
+ devices,
444
+ driven: devices.filter((device) => agentDriven(device, run.input.ownLeases))
445
+ };
446
+ overseeStart(run, look);
447
+ overseeProgress(run, look);
448
+ overseeLoops(run, look, prev);
449
+ overseeMerge(run, look, prev);
450
+ overseeAttention(run, look, prev);
451
+ return entry;
452
+ }
453
+ /**
454
+ * The notifications a machine owes since `previous`, the state the last call returned. Null `previous` records
455
+ * what is already true without notifying, so a restart or a new registration stays quiet. Each workspace and
456
+ * machine problem notifies once per episode, under one id per category, so a later episode replaces it.
457
+ * `awakeSince` restarts the offline settle time, for a checker that was not running.
458
+ */
459
+ function oversee(previous, input, prefs, now, awakeSince = 0) {
460
+ const run = {
461
+ input,
462
+ prefs,
463
+ now,
464
+ baseline: previous === null,
465
+ notifications: [],
466
+ wakeAt: null
467
+ };
468
+ const state = { workspaces: {} };
469
+ overseeMachine(run, previous, state, awakeSince);
470
+ const status = input.status;
471
+ if (status === null) state.workspaces = { ...previous?.workspaces };
472
+ else {
473
+ for (const env of status.environments) state.workspaces[env.path] = overseeWorkspace(run, env, status, previous?.workspaces[env.path]);
474
+ for (const [path, entry] of Object.entries(previous?.workspaces ?? {})) if (!(path in state.workspaces) && now - entry.seenAt < FORGET_MS) state.workspaces[path] = entry;
475
+ }
476
+ return {
477
+ state,
478
+ notifications: run.notifications,
479
+ wakeAt: run.wakeAt
480
+ };
481
+ }
482
+ function formatBytes(bytes) {
483
+ if (bytes >= 0xe8d4a51000) return `${(bytes / 0xe8d4a51000).toFixed(1)} TB`;
484
+ const gb = bytes / 1e9;
485
+ return gb >= 100 ? `${Math.round(gb)} GB` : `${gb.toFixed(1)} GB`;
486
+ }
487
+ /** Whether `minuteOfDay` falls in quiet hours from `start` to `end`, minutes after midnight; they may span midnight. */
488
+ function inQuietHours(quietHours, minuteOfDay) {
489
+ if (!quietHours || quietHours.start === quietHours.end) return false;
490
+ const { start, end } = quietHours;
491
+ return start < end ? minuteOfDay >= start && minuteOfDay < end : minuteOfDay >= start || minuteOfDay < end;
492
+ }
493
+ /** How long a billable EAS session may run with no agent before it needs a person. */
494
+ const EAS_SESSION_MINUTES = 30;
495
+ function attentionBody(message) {
496
+ switch (message.kind) {
497
+ case "issue": return message.slot ? `${message.slot}: ${message.message}` : message.message;
498
+ case "signing": return `${platformName(message.platform)} signing or provisioning failed (${message.code})`;
499
+ case "diagnostic-loop": return `Same ${message.language ? `${message.language} ` : `${platformName(message.platform)} build `}error ${message.count}x at ${message.file}:${message.line}`;
500
+ case "launch-loop": return `App failed to launch on ${platformName(message.platform)} ${message.count}x in a row`;
501
+ case "build-loop": {
502
+ const code = message.errorCode ? ` (${message.errorCode})` : "";
503
+ return `${platformName(message.platform)} build failed ${message.count}x in a row${code}`;
504
+ }
505
+ case "lease": return `Lease on ${message.leased} expired`;
506
+ case "eas": return `EAS session running for ${message.minutes} min with no agent; billed while it runs`;
507
+ case "stuck": {
508
+ const after = message.green ? ` after a green ${platformName(message.green)} build` : "";
509
+ return `No agent activity for ${message.minutes} min${after}; ${message.model} still up`;
510
+ }
511
+ case "disk": return `${formatBytes(message.freeBytes)} free, below Stim's floor`;
512
+ }
513
+ }
514
+ const PERSON_ISSUES = /* @__PURE__ */ new Set([
515
+ "port-not-ours",
516
+ "supervisor-unverified",
517
+ "browser-unverified",
518
+ "avd-unchecked"
519
+ ]);
520
+ const SIGNING_CODES = [
521
+ "STIM_NO_SIGNING_IDENTITY",
522
+ "STIM_CODESIGN_FAILED",
523
+ "STIM_NO_PROFILE",
524
+ "STIM_PROFILE_MISMATCH"
525
+ ];
526
+ /** A failed run older than this on an idle workspace is history, not a problem. */
527
+ const STALE_MS = 864e5;
528
+ const recent = (env, at, now) => env.live || now - time(at) < 864e5;
529
+ function workspaceItems(env, input, format = attentionBody) {
530
+ const { now } = input;
531
+ const items = [];
532
+ for (const issue of env.issues ?? []) {
533
+ if (issue.severity === "info" || !PERSON_ISSUES.has(issue.code)) continue;
534
+ items.push({
535
+ id: `issue-${issue.code}-${issue.slot ?? "default"}:${env.path}`,
536
+ category: "attention",
537
+ severity: issue.severity === "error" ? "error" : "warning",
538
+ workspace: env.path,
539
+ body: format({
540
+ kind: "issue",
541
+ slot: issue.slot,
542
+ message: issue.message
543
+ }),
544
+ remedy: issue.remedy
545
+ });
546
+ }
547
+ for (const platform of ["ios", "android"]) {
548
+ const last = env.lastBuilds?.[platform];
549
+ const building = env.build?.state === "running" && env.build.platform === platform;
550
+ const code = last?.errorCode ?? "";
551
+ if (last && last.status === "failed" && !building && SIGNING_CODES.includes(code)) {
552
+ if (recent(env, last.finishedAt ?? last.startedAt, now)) {
553
+ items.push({
554
+ id: `run-${platform}:${env.path}`,
555
+ category: "attention",
556
+ severity: "error",
557
+ workspace: env.path,
558
+ body: format({
559
+ kind: "signing",
560
+ platform,
561
+ code
562
+ }),
563
+ remedy: null
564
+ });
565
+ continue;
566
+ }
567
+ }
568
+ const history = env.builds?.[platform];
569
+ const streak = failureStreak(platform, history);
570
+ const head = history?.[0];
571
+ if (streak && head && streak.count >= LOOP_COUNT && recent(env, head.finishedAt ?? head.startedAt, now)) items.push({
572
+ id: `looping-${platform}:${env.path}`,
573
+ category: "looping",
574
+ severity: "error",
575
+ workspace: env.path,
576
+ body: streak.body(streak.count, format),
577
+ remedy: null
578
+ });
579
+ }
580
+ for (const device of env.physicalDevices ?? []) {
581
+ if (!(time(device.lease.expiresAt) <= now)) continue;
582
+ const slot = device.slot === "default" ? "" : ` --slot ${device.slot}`;
583
+ items.push({
584
+ id: `lease-${device.platform}-${device.slot}:${env.path}`,
585
+ category: "attention",
586
+ severity: "warning",
587
+ workspace: env.path,
588
+ body: format({
589
+ kind: "lease",
590
+ leased: device.name ?? device.model ?? device.id
591
+ }),
592
+ remedy: `stim device unlock ${device.platform}${slot}`
593
+ });
594
+ }
595
+ const devices = devicesOf(env);
596
+ const driven = devices.find((d) => d.running && agentDriven(d, input.ownLeases ?? []));
597
+ for (const session of env.remoteDevices ?? []) {
598
+ const started = time(session.startedAt);
599
+ if (driven || !(now - started >= input.easSessionMinutes * 6e4)) continue;
600
+ items.push({
601
+ id: `eas-${session.sessionId}:${env.path}`,
602
+ category: "attention",
603
+ severity: "warning",
604
+ workspace: env.path,
605
+ body: format({
606
+ kind: "eas",
607
+ minutes: Math.floor((now - started) / 6e4)
608
+ }),
609
+ remedy: "stim stop"
610
+ });
611
+ }
612
+ const since = lastActivityAt(env, devices, []);
613
+ if (driven && env.build?.state !== "running" && since !== null && now - since >= input.stuckMinutes * 6e4) {
614
+ const newest = newestBuild(env);
615
+ items.push({
616
+ id: `stuck:${env.path}`,
617
+ category: "stuck",
618
+ severity: "warning",
619
+ workspace: env.path,
620
+ body: format({
621
+ kind: "stuck",
622
+ minutes: Math.floor((now - since) / 6e4),
623
+ green: newest?.status === "ok" && (newest.platform === "ios" || newest.platform === "android") ? newest.platform : null,
624
+ model: driven.model,
625
+ modelLabel: driven.modelLabel
626
+ }),
627
+ remedy: null
628
+ });
629
+ }
630
+ return items;
631
+ }
632
+ const severityRank = (item) => item.severity === "error" ? 0 : 1;
633
+ /**
634
+ * The items, errors first, then the machine's, then live workspaces' before idle ones', each in status order. Log
635
+ * errors, a single failed run, and issues an agent's next `stim` command repairs are left out: agents handle them.
636
+ */
637
+ function needsAttention(input, format = attentionBody) {
638
+ const ranked = [];
639
+ const lowest = input.volumes?.reduce((min, v) => min === null ? v.freeBytes : Math.min(min, v.freeBytes), null);
640
+ if (lowest !== null && lowest !== void 0 && lowest < DISK_CRITICAL_BYTES) ranked.push({
641
+ scope: 0,
642
+ item: {
643
+ id: "machine:disk",
644
+ category: "machine",
645
+ severity: "error",
646
+ workspace: null,
647
+ body: format({
648
+ kind: "disk",
649
+ freeBytes: lowest
650
+ }),
651
+ remedy: null
652
+ }
653
+ });
654
+ for (const env of input.environments) for (const item of workspaceItems(env, input, format)) ranked.push({
655
+ item,
656
+ scope: env.live ? 1 : 2
657
+ });
658
+ const order = ranked.map((entry, index) => ({
659
+ entry,
660
+ index
661
+ }));
662
+ order.sort((a, b) => severityRank(a.entry.item) - severityRank(b.entry.item) || a.entry.scope - b.entry.scope || a.index - b.index);
663
+ return order.map(({ entry }) => entry.item);
664
+ }
665
+ //#endregion
666
+ export { DEFAULT_STUCK_MINUTES, OVERSIGHT_CATEGORIES, STALE_MS, inQuietHours, needsAttention, oversee, oversightTitle };