staysfixed 0.4.0 → 0.6.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.
@@ -56,7 +56,7 @@ const run = promisify(execFile);
56
56
  * @typedef {object} Case
57
57
  * @property {string} name A sentence, because it is read back as one.
58
58
  * @property {string} breaks What is wrong, in plain English.
59
- * @property {'a finding'|'nothing'} expect
59
+ * @property {'a finding'|'nothing'|'no answer'} expect
60
60
  * @property {RegExp[]} [mustSay]
61
61
  * @property {boolean} [mustBeUnstable] It has to land in `newlyUnstable`, not in the findings.
62
62
  * @property {(broken: boolean) => Record<string, string>} build
@@ -210,6 +210,46 @@ export const CASES = [
210
210
  }),
211
211
  },
212
212
 
213
+ {
214
+ name: 'a break buried in the middle of a huge output',
215
+ breaks:
216
+ 'A program prints more than the tool will store, and the thing that broke is in the middle — past the head it keeps and before the tail it keeps. This is the case the tool used to be blind to: it kept the two ends and a COARSE size, so a change in the discarded middle left a byte-identical record and the run reported that nothing had changed. Exactly the shape of failure this whole thing exists to prevent, and it survived until 2026-08-30.',
217
+ expect: 'a finding',
218
+ // The marker in the middle is what has to have caught it. If some other part of the value
219
+ // reported instead, this case has stopped testing what it was written to test.
220
+ mustSay: [/bytes left out of the middle/],
221
+ build: (broken) => ({
222
+ 'package.json': PKG,
223
+ 'cli.js': [
224
+ "console.log('report begins');",
225
+ 'for (let i = 0; i < 6000; i += 1) {',
226
+ broken
227
+ ? " console.log(i === 3000 ? `row ${i}: could not be loaded at all` : `row ${i}: ok`);"
228
+ : ' console.log(`row ${i}: ok`);',
229
+ '}',
230
+ "console.log('report ends');",
231
+ '',
232
+ ].join('\n'),
233
+ }),
234
+ },
235
+
236
+ {
237
+ name: 'a build that takes ten times longer stays silent',
238
+ breaks:
239
+ 'Nothing, and the product is markedly slower. How long something took is recorded and never compared, because a stopwatch on a shared machine measures the machine as much as the product — and comparing it is what made this corpus fail one case out of nine on a busy laptop while passing five times in a row on a quiet one. This case exists so that decision cannot be quietly undone: put timing back into the comparison and this goes red.',
240
+ expect: 'nothing',
241
+ build: (broken) => ({
242
+ 'package.json': PKG,
243
+ 'cli.js': [
244
+ // A sleep, deliberately, and never a busy loop. Loading the machine to test timing
245
+ // is how you take four other things down with you.
246
+ `await new Promise((done) => setTimeout(done, ${broken ? 900 : 40}));`,
247
+ "console.log('total 10.005');",
248
+ '',
249
+ ].join('\n'),
250
+ }),
251
+ },
252
+
213
253
  {
214
254
  name: 'a value that used to be steady is now random',
215
255
  breaks:
@@ -226,6 +266,28 @@ export const CASES = [
226
266
  'cli.js': [broken ? 'console.log(`batch id ${Math.floor(Math.random() * 1e9)}`);' : 'console.log(`batch id 4242`);', "console.log('two orders');", ''].join('\n'),
227
267
  }),
228
268
  },
269
+
270
+ {
271
+ name: 'a run that could not answer says so instead of passing',
272
+ breaks:
273
+ 'The break is real and it is hidden by the product itself: this build writes a fresh set of randomly named files on every run and stamps a random id on what it prints, so the same build disagrees with itself about nearly every address it has. Everything that wobbles is subtracted before anything is compared — which is right, and which here removes the comparison altogether. The only honest answer is that this run says nothing, and until 2026-08-30 the engine said "nothing that already worked has changed", which is the same sentence it uses when a product is genuinely fine.',
274
+ expect: 'no answer',
275
+ build: (broken) => ({
276
+ 'package.json': PKG,
277
+ 'cli.js': [
278
+ "import fs from 'node:fs';",
279
+ "fs.mkdirSync('out', { recursive: true });",
280
+ '// A build tool writing hash-named artefacts. Nothing unusual, and every one of them',
281
+ '// is a new address that was not there on the last run.',
282
+ 'for (let i = 0; i < 30; i += 1) {',
283
+ " fs.writeFileSync(`out/chunk-${Math.random().toString(36).slice(2, 10)}.txt`, 'x');",
284
+ '}',
285
+ 'console.log(`request ${Math.random().toString(36).slice(2, 10)}`);',
286
+ broken ? "console.log('orders: could not be loaded');" : "console.log('orders: 2');",
287
+ '',
288
+ ].join('\n'),
289
+ }),
290
+ },
229
291
  ];
230
292
 
231
293
  // ---------------------------------------------------------------------------
@@ -237,7 +299,7 @@ export const CASES = [
237
299
  * @property {string} name
238
300
  * @property {boolean} caught True when the case behaved: the break was found, or the clean pair stayed silent.
239
301
  * @property {string} [why] Why it did not, in one plain sentence.
240
- * @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'} verdict
302
+ * @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'|'could not tell'|'said it could not tell'} verdict
241
303
  */
242
304
 
243
305
  /**
@@ -245,6 +307,8 @@ export const CASES = [
245
307
  * @property {boolean} passed
246
308
  * @property {CaseResult[]} cases
247
309
  * @property {boolean} ran False when the engine could not be driven at all.
310
+ * @property {boolean} [certain] False when at least one case could not be told either way.
311
+ * A run that is not certain is NOT a pass and NOT a failure.
248
312
  * @property {string} [why] Why it could not run.
249
313
  * @property {string} [workDir]
250
314
  */
@@ -287,48 +351,108 @@ export async function selfcheck(opts = {}) {
287
351
  const cases = [];
288
352
 
289
353
  for (const c of wanted) {
290
- const dir = path.join(workDir, safe(c.name));
291
- /** @type {string} */
292
- let working;
293
- try {
294
- working = await plant(dir, c);
295
- } catch (e) {
296
- cases.push({ name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` });
354
+ const first = await runOne(check, workDir, c, 1);
355
+ if (first.caught) {
356
+ cases.push(first);
297
357
  continue;
298
358
  }
299
359
 
300
- /** @type {any} */
301
- let result;
302
- try {
303
- // Exactly the call an agent makes, with exactly the arguments an agent
304
- // sends. A corpus that reached past the front door would prove the engine
305
- // works when driven in a way nobody drives it.
306
- result = await check({
307
- cwd: dir,
308
- configFile: undefined,
309
- against: working,
310
- paired: true,
311
- journeys: path.join(dir, 'journeys.json'),
312
- only: [],
313
- });
314
- } catch (e) {
315
- cases.push({ name: c.name, caught: false, verdict: 'could not run', why: `the engine threw: ${why(e)}` });
360
+ // IT FAILED. Before that becomes an accusation, it has to reproduce.
361
+ //
362
+ // This is the same rule the engine itself lives by, turned on the corpus: a difference
363
+ // that will not happen twice is not a difference. On the night of 2026-08-29 this corpus
364
+ // came back "1 of 9 wrong" while the test suite was running beside it and then passed
365
+ // five times in a row on a quiet machine — and a corpus that can be perturbed by a busy
366
+ // laptop is worth nothing on a busy laptop, because nobody can tell its noise from its
367
+ // signal. The cause was found and removed (see howLongItTook in adapters/contract.js),
368
+ // and this stays anyway, because the next machine-shaped thing to creep in should land
369
+ // as "I could not tell" rather than as a false accusation somebody learns to ignore.
370
+ //
371
+ // A second run that agrees is a real failure and is reported as one. A second run that
372
+ // disagrees is filed as UNTELLABLE, which is not a pass: the exit code is 2, the same
373
+ // one used for "the corpus could not be run at all", because both mean no answer.
374
+ const second = await runOne(check, workDir, c, 2);
375
+ if (!second.caught) {
376
+ cases.push({ ...second, why: `${second.why ?? 'it did not behave'} (it did this twice in a row, so it is real)` });
316
377
  continue;
317
378
  }
318
-
319
- cases.push(judge(c, result));
379
+ cases.push({
380
+ name: c.name,
381
+ caught: false,
382
+ verdict: 'could not tell',
383
+ why:
384
+ `it behaved on the second run and not on the first, so this says nothing either way. ` +
385
+ `The first time: ${first.why ?? 'it did not behave'}. ` +
386
+ `This machine's load was ${loadNow()} — something that comes and goes with how busy the machine is is not evidence about the engine. ` +
387
+ `Run it again on a quiet machine before believing either answer.`,
388
+ });
320
389
  }
321
390
 
322
391
  if (!opts.keep) await fsp.rm(workDir, { recursive: true, force: true });
323
392
 
393
+ const untellable = cases.some((r) => r.verdict === 'could not tell');
324
394
  return {
325
395
  passed: cases.length > 0 && cases.every((r) => r.caught),
326
396
  ran: true,
397
+ certain: !untellable,
327
398
  cases,
328
399
  ...(opts.keep ? { workDir } : {}),
329
400
  };
330
401
  }
331
402
 
403
+ /**
404
+ * Build one case fresh and put the engine through it once.
405
+ *
406
+ * A fresh folder every attempt, deliberately. Re-running inside the same folder would leave
407
+ * the first attempt's stored captures sitting there, and the second attempt would be
408
+ * comparing against those rather than against the build that works.
409
+ *
410
+ * @param {any} check
411
+ * @param {string} workDir
412
+ * @param {Case} c
413
+ * @param {number} attempt
414
+ * @returns {Promise<CaseResult>}
415
+ */
416
+ async function runOne(check, workDir, c, attempt) {
417
+ const dir = path.join(workDir, `${safe(c.name)}${attempt > 1 ? `-again-${attempt}` : ''}`);
418
+ /** @type {string} */
419
+ let working;
420
+ try {
421
+ working = await plant(dir, c);
422
+ } catch (e) {
423
+ return { name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` };
424
+ }
425
+
426
+ /** @type {any} */
427
+ let result;
428
+ try {
429
+ // Exactly the call an agent makes, with exactly the arguments an agent
430
+ // sends. A corpus that reached past the front door would prove the engine
431
+ // works when driven in a way nobody drives it.
432
+ result = await check({
433
+ cwd: dir,
434
+ configFile: undefined,
435
+ against: working,
436
+ paired: true,
437
+ journeys: path.join(dir, 'journeys.json'),
438
+ only: [],
439
+ });
440
+ } catch (e) {
441
+ return { name: c.name, caught: false, verdict: 'could not run', why: `the engine threw: ${why(e)}` };
442
+ }
443
+
444
+ return judge(c, result);
445
+ }
446
+
447
+ /** How busy this machine is, in words, so an untellable result can name the likely reason. */
448
+ function loadNow() {
449
+ const [one] = os.loadavg();
450
+ const cores = os.cpus().length || 1;
451
+ const per = one / cores;
452
+ const how = per < 0.4 ? 'quiet' : per < 0.9 ? 'busy' : 'very busy';
453
+ return `${one.toFixed(1)} across ${cores} cores, which is ${how}`;
454
+ }
455
+
332
456
  /**
333
457
  * Did the engine do what this case demands?
334
458
  *
@@ -349,9 +473,35 @@ function judge(c, result) {
349
473
  const findings = Array.isArray(result?.findings) ? result.findings : [];
350
474
  const unstable = Array.isArray(result?.newlyUnstable) ? result.newlyUnstable : [];
351
475
 
476
+ // The third expectation, and the one the other two cannot express: a run that is entitled
477
+ // to no verdict at all. What is demanded here is narrow on purpose — not that it found the
478
+ // break, which it cannot, but that it refused to call the run clean and said why in words a
479
+ // person can read.
480
+ if (c.expect === 'no answer') {
481
+ const said = String(result?.summary ?? '');
482
+ if (result?.ok === false && /no answer|not a pass/i.test(said)) {
483
+ return { name: c.name, caught: true, verdict: 'said it could not tell' };
484
+ }
485
+ return {
486
+ name: c.name,
487
+ caught: false,
488
+ verdict: 'escaped',
489
+ why:
490
+ result?.ok === false
491
+ ? `it did not pass, but it never said why in a way anybody could read: ${said.slice(0, 200)}`
492
+ : `it reported a clean run over a comparison that had been thrown away: ${said.slice(0, 200)}`,
493
+ };
494
+ }
495
+
352
496
  if (c.expect === 'nothing') {
353
497
  if (findings.length === 0 && unstable.length === 0) return { name: c.name, caught: true, verdict: 'quiet' };
354
- const what = findings.length ? `${findings.length} finding${findings.length === 1 ? '' : 's'}: ${describe(findings[0])}` : `${unstable.length} newly unpredictable address${unstable.length === 1 ? '' : 'es'}: ${unstable[0]}`;
498
+ // `unstable` holds WobbleEntry objects, not strings. Interpolating one printed
499
+ // "[object Object]" and turned the most important line in a failure report — the one
500
+ // saying WHAT went wrong — into nothing at all.
501
+ const named = unstable.map((/** @type {any} */ u) => (typeof u === 'string' ? u : `${u?.path ?? 'an address'} (was ${JSON.stringify(u?.a)}, then ${JSON.stringify(u?.b)})`));
502
+ const what = findings.length
503
+ ? `${findings.length} finding${findings.length === 1 ? '' : 's'}: ${describe(findings[0])}`
504
+ : `${unstable.length} newly unpredictable address${unstable.length === 1 ? '' : 'es'}: ${named.slice(0, 3).join('; ')}`;
355
505
  return { name: c.name, caught: false, verdict: 'false alarm', why: `two builds that should have looked the same produced ${what}` };
356
506
  }
357
507
 
@@ -532,13 +682,16 @@ export async function main(argv = process.argv.slice(2)) {
532
682
 
533
683
  if (json) {
534
684
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
535
- return result.passed ? 0 : result.ran ? 1 : 2;
685
+ if (result.passed) return 0;
686
+ if (!result.ran) return 2;
687
+ return result.certain === false && result.cases.every((r) => r.caught || r.verdict === 'could not tell') ? 2 : 1;
536
688
  }
537
689
 
538
690
  if (!result.ran) {
539
691
  process.stderr.write(`Could not run the self-check.\n${result.why ?? ''}\n`);
540
692
  return 2;
541
693
  }
694
+ const untellable = result.cases.filter((r) => r.verdict === 'could not tell');
542
695
 
543
696
  /** @type {string[]} */
544
697
  const out = ['Stays Fixed - checking that it can still catch things', ''];
@@ -549,14 +702,26 @@ export async function main(argv = process.argv.slice(2)) {
549
702
  out.push('');
550
703
  if (result.passed) {
551
704
  out.push(`All ${result.cases.length} behaved: every break was caught, and every pair that should have been silent was silent.`);
705
+ } else if (untellable.length > 0 && untellable.length === result.cases.filter((r) => !r.caught).length) {
706
+ // Nothing failed twice. Saying "wrong" here would be an accusation the evidence does not
707
+ // support, and saying "fine" would be worse.
708
+ out.push(
709
+ `${untellable.length} of ${result.cases.length} could not be told either way — ${untellable.length === 1 ? 'it' : 'they'} behaved on the second run and not on the first. ` +
710
+ 'That is not a pass and not a failure. Run it again on a quiet machine.',
711
+ );
552
712
  } else {
553
- const bad = result.cases.filter((r) => !r.caught);
554
- out.push(`${bad.length} of ${result.cases.length} did not behave. Until that is fixed, a clean check from this tool does not mean what it says.`);
713
+ const bad = result.cases.filter((r) => !r.caught && r.verdict !== 'could not tell');
714
+ out.push(`${bad.length} of ${result.cases.length} did not behave, twice in a row each. Until that is fixed, a clean check from this tool does not mean what it says.`);
715
+ if (untellable.length > 0) out.push(`${untellable.length} more could not be told either way.`);
555
716
  }
556
717
  if (result.workDir) out.push(`The products were left in ${result.workDir}.`);
557
718
 
558
719
  process.stdout.write(out.join('\n') + '\n');
559
- return result.passed ? 0 : 1;
720
+ if (result.passed) return 0;
721
+ // "I could not test this" is exit 2 and never exit 0, and it is not exit 1 either: one of
722
+ // those says the engine is broken and the other says nobody knows, and they need different
723
+ // reactions from whoever is reading.
724
+ return result.certain === false && result.cases.every((r) => r.caught || r.verdict === 'could not tell') ? 2 : 1;
560
725
  }
561
726
 
562
727
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
package/src/v2/types.js CHANGED
@@ -235,6 +235,12 @@
235
235
  * False when we had no stability record for the reference,
236
236
  * so `newlyUnstable` is empty for lack of evidence rather
237
237
  * than because nothing became unstable.
238
+ * @property {boolean} [couldNotTell] True when the wobble measurement was too big to be a
239
+ * measurement — the same build answered differently at
240
+ * most of its own addresses, so subtracting it subtracts
241
+ * the answer. A run in this state has no verdict, and it
242
+ * must never be reported as a clean one.
243
+ * @property {string} [couldNotTellWhy] Said plainly, with the numbers in it.
238
244
  * @property {string} note One plain sentence stating exactly that.
239
245
  */
240
246
 
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Letting you watch, without taking your screen.
3
+ *
4
+ * The owner asked for something more precise than "run it invisibly", and he was right
5
+ * to. He wants to SEE it work — the app opening, the panel beside it, each check ticking
6
+ * green — because watching it is most of how you come to trust it. What he does not want
7
+ * is what it did to him tonight:
8
+ *
9
+ * "if i click something and bring [my app] on the first layer of the screen and i am
10
+ * working on something, after my click it will not keep bringing it up. it will just
11
+ * keep it back side and keep working."
12
+ *
13
+ * So the rule is not "stay hidden". It is: **come up once, then never come up again.**
14
+ *
15
+ * That distinction is the whole of this file. An app the tool opens is allowed to appear —
16
+ * it should, the first time, so a person can see what is happening. From the moment the
17
+ * person picks something else, whatever the tool launched loses the argument for good.
18
+ *
19
+ * ## Why a guard rather than a flag
20
+ *
21
+ * There is no flag for this. An Electron app calls `app.focus()` and `win.show()` from its
22
+ * own main process during startup, when a window opens, when a dialog appears; a simulator
23
+ * activates when it boots; a browser activates when a new window is created. None of that
24
+ * goes through us, so none of it can be forbidden at launch time. The only thing that
25
+ * actually works is to watch who is in front and put the person's app back when something
26
+ * of ours pushes in front of it.
27
+ *
28
+ * ## Why polling is the right answer here, unusually
29
+ *
30
+ * The standing rule in this codebase is events over polling. macOS does publish an
31
+ * activation notification, but reading it needs a process inside the window server session
32
+ * with an event loop — a small native helper or a persistent AppleScript, both of which are
33
+ * a thing to install and a thing to leave running on his machine. A twelve-line
34
+ * `osascript` every 400ms costs about a millisecond of CPU and installs nothing. The rule
35
+ * exists to stop wasteful polling; this is the case it does not cover.
36
+ */
37
+
38
+ import { execFile } from 'node:child_process';
39
+ import { promisify } from 'node:util';
40
+ import { detail } from '../../core/log.js';
41
+
42
+ const run = promisify(execFile);
43
+
44
+ /** How often to look. Fast enough that a stolen screen is given back before it is annoying. */
45
+ const LOOK_EVERY_MS = 400;
46
+
47
+ /**
48
+ * How long to leave the tool's window alone at the start.
49
+ *
50
+ * It has just been opened deliberately and a person is probably looking at it. Snatching
51
+ * focus away in the same instant would be its own kind of rude, and would also fight the
52
+ * launch itself while the app is still deciding which of its windows is in front.
53
+ */
54
+ const GRACE_MS = 2500;
55
+
56
+ /** @typedef {{name: string}} Frontmost */
57
+
58
+ /**
59
+ * Who is in front right now, by application name.
60
+ *
61
+ * Returns null rather than throwing on any failure — no window server, no Apple Events
62
+ * permission, a headless machine, a locked screen. Every one of those means "there is no
63
+ * screen to take", which is not an error and must never fail a check.
64
+ *
65
+ * @returns {Promise<string|null>}
66
+ */
67
+ export async function frontmostApp() {
68
+ if (process.platform !== 'darwin') return null;
69
+ try {
70
+ const { stdout } = await run(
71
+ 'osascript',
72
+ ['-e', 'tell application "System Events" to get name of first application process whose frontmost is true'],
73
+ { timeout: 3000 },
74
+ );
75
+ const name = stdout.trim();
76
+ return name.length > 0 ? name : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Bring one application back to the front.
84
+ *
85
+ * @param {string} name
86
+ * @returns {Promise<boolean>} whether it worked
87
+ */
88
+ export async function bringForward(name) {
89
+ if (process.platform !== 'darwin' || !name) return false;
90
+ try {
91
+ await run(
92
+ 'osascript',
93
+ [
94
+ '-e',
95
+ `tell application "System Events" to set frontmost of first application process whose name is ${JSON.stringify(name)} to true`,
96
+ ],
97
+ { timeout: 3000 },
98
+ );
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * @typedef {object} ScreenGuard
107
+ * @property {(name: string) => void} claim Tell the guard an application belongs to the tool.
108
+ * @property {() => Promise<void>} release Stop guarding. Always safe to call twice.
109
+ * @property {() => GuardReport} report What it did, for the run summary.
110
+ */
111
+
112
+ /**
113
+ * @typedef {object} GuardReport
114
+ * @property {number} handedBack How many times the screen was taken and given back.
115
+ * @property {string|null} yours The application the guard believes is yours.
116
+ * @property {string[]} ours Everything the tool opened.
117
+ * @property {boolean} watching False when there is no screen to guard.
118
+ */
119
+
120
+ /**
121
+ * Watch who is in front, and give the screen back when something of ours takes it.
122
+ *
123
+ * The bookkeeping is deliberately simple, because a clever version of this would guess
124
+ * wrong and fight the person for their own screen:
125
+ *
126
+ * - Anything the tool launches is `ours`, named as the tool launches it.
127
+ * - Anything else that is frontmost is *yours*, and the guard remembers the last one. That
128
+ * is how it learns what to put back — by watching what you actually chose, never by
129
+ * being told.
130
+ * - When one of ours is in front and you have chosen something since, yours goes back.
131
+ * - When one of ours is in front and you have chosen nothing yet, it is left alone. That
132
+ * first appearance is the point: it is how you see what is happening.
133
+ *
134
+ * @param {{claims?: string[], everyMs?: number, graceMs?: number}} [opts]
135
+ * @returns {ScreenGuard}
136
+ */
137
+ export function guardTheScreen(opts = {}) {
138
+ const everyMs = opts.everyMs ?? LOOK_EVERY_MS;
139
+ const graceMs = opts.graceMs ?? GRACE_MS;
140
+
141
+ /** @type {Set<string>} everything the tool opened */
142
+ const ours = new Set(opts.claims ?? []);
143
+ /** @type {string|null} the last application the person chose for themselves */
144
+ let yours = null;
145
+ let handedBack = 0;
146
+ let stopped = process.platform !== 'darwin';
147
+ /** @type {ReturnType<typeof setTimeout>|null} */
148
+ let timer = null;
149
+ const startedAt = Date.now();
150
+
151
+ /** @param {string} name */
152
+ const claim = (name) => {
153
+ if (name) ours.add(name);
154
+ };
155
+
156
+ const isOurs = (/** @type {string} */ name) => {
157
+ for (const one of ours) {
158
+ // A launched application is often reported under a slightly different name than the
159
+ // path it was started from — "Terminal Deck" for a binary called "Terminal Deck", but
160
+ // "Electron" for a development build, and "Simulator" for a simulator boot. Matching
161
+ // loosely in both directions is what makes this work without a table of special cases.
162
+ if (name === one || name.includes(one) || one.includes(name)) return true;
163
+ }
164
+ return false;
165
+ };
166
+
167
+ const look = async () => {
168
+ if (stopped) return;
169
+ const front = await frontmostApp();
170
+ if (front) {
171
+ if (!isOurs(front)) {
172
+ // The person chose this. It is now what "yours" means.
173
+ yours = front;
174
+ } else if (yours && Date.now() - startedAt > graceMs) {
175
+ // Something of ours is in front, and there is somewhere to put you back.
176
+ const ok = await bringForward(yours);
177
+ if (ok) {
178
+ handedBack += 1;
179
+ detail(`the screen was taken by ${front}; gave it back to ${yours}`);
180
+ }
181
+ }
182
+ }
183
+ if (!stopped) timer = setTimeout(look, everyMs);
184
+ };
185
+
186
+ if (!stopped) timer = setTimeout(look, everyMs);
187
+
188
+ return {
189
+ claim,
190
+ async release() {
191
+ stopped = true;
192
+ if (timer) clearTimeout(timer);
193
+ timer = null;
194
+ },
195
+ report() {
196
+ return { handedBack, yours, ours: [...ours], watching: !stopped };
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * One sentence for the summary, or nothing when there is nothing worth saying.
203
+ *
204
+ * A person who was not interrupted should not be told about the machinery that did not
205
+ * interrupt them. This only speaks when it actually did something.
206
+ *
207
+ * @param {GuardReport} report
208
+ * @returns {string|null}
209
+ */
210
+ export function describeGuard(report) {
211
+ if (!report || report.handedBack === 0) return null;
212
+ const times = report.handedBack === 1 ? 'once' : `${report.handedBack} times`;
213
+ const back = report.yours ? ` to ${report.yours}` : '';
214
+ return `Something the check opened came to the front ${times} and the screen was handed straight back${back}.`;
215
+ }