staysfixed 0.6.2 → 0.7.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.
- package/CHANGELOG.md +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
package/src/v2/watch/window.js
CHANGED
|
@@ -27,10 +27,13 @@
|
|
|
27
27
|
* look at — and never the newest state.
|
|
28
28
|
*
|
|
29
29
|
* THREE. It never takes the screen. Whoever was in front before this opened is put
|
|
30
|
-
* back in front afterwards, and the same dance is exported so
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* back in front afterwards, and the same dance is exported so that anything else
|
|
31
|
+
* that moves a window can do it too — `check.js` wraps the move that puts a desktop
|
|
32
|
+
* app out of sight in it. It is only half the answer, and the smaller half: an app
|
|
33
|
+
* this tool opens is ALLOWED to appear once, because being able to watch it work is
|
|
34
|
+
* most of how you come to trust it. What must never happen is it coming forward
|
|
35
|
+
* again after that, and the thing that stops it is the screen guard in
|
|
36
|
+
* `watch/focus.js`, which runs for the whole of a check.
|
|
34
37
|
*
|
|
35
38
|
* FOUR. It is our own window, not his. Chrome for Testing wherever there is one,
|
|
36
39
|
* a throwaway profile every time, its own port, and nothing on this machine that
|
|
@@ -172,10 +175,10 @@ export async function noteTheFrontmost() {
|
|
|
172
175
|
/**
|
|
173
176
|
* Put the screen back where it was.
|
|
174
177
|
*
|
|
175
|
-
* The other half of `noteTheFrontmost`, and the one implementation of it
|
|
176
|
-
* the
|
|
177
|
-
*
|
|
178
|
-
* them ends up subtly not doing it.
|
|
178
|
+
* The other half of `noteTheFrontmost`, and the one implementation of it. Called
|
|
179
|
+
* here when the panel opens, and by `check.js` around anything that moves somebody
|
|
180
|
+
* else's window, rather than each of them writing its own AppleScript — because
|
|
181
|
+
* three copies of this is how one of them ends up subtly not doing it.
|
|
179
182
|
*
|
|
180
183
|
* Safe to call with null, on any platform, at any time. Never throws.
|
|
181
184
|
*
|
|
@@ -338,6 +341,60 @@ export async function moveWindowByPid(pid, current, target) {
|
|
|
338
341
|
}
|
|
339
342
|
}
|
|
340
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Where a desktop app's biggest window is, asked of macOS by the app's unix id.
|
|
346
|
+
*
|
|
347
|
+
* The other half of `moveWindowByPid`, and the reason either of them exists: an
|
|
348
|
+
* Electron app does not implement the part of the debugging protocol that reads or
|
|
349
|
+
* moves a window, so a desktop app under check can only be measured through the
|
|
350
|
+
* window server. Without this the panel has nothing to sit beside and a run has no
|
|
351
|
+
* way to tell whether the app it opened is on somebody's screen.
|
|
352
|
+
*
|
|
353
|
+
* The BIGGEST window, not the first. A desktop app usually has several — a status
|
|
354
|
+
* item, a hidden helper, a print panel — and the first one macOS hands back is not
|
|
355
|
+
* reliably the one a person is looking at.
|
|
356
|
+
*
|
|
357
|
+
* Null on anything but macOS, and null whenever the machine will not say: no
|
|
358
|
+
* accessibility permission, an app that refuses to be scripted, an app with no
|
|
359
|
+
* window yet. Every one of those means "we cannot place this", which is a
|
|
360
|
+
* disappointment and never a failed check.
|
|
361
|
+
*
|
|
362
|
+
* @param {number} pid The process THIS run started. Never one we attached to.
|
|
363
|
+
* @returns {Promise<Bounds|null>}
|
|
364
|
+
*/
|
|
365
|
+
export async function windowBoundsByPid(pid) {
|
|
366
|
+
if (process.platform !== 'darwin') return null;
|
|
367
|
+
if (!Number.isFinite(Number(pid)) || Number(pid) <= 0) return null;
|
|
368
|
+
const script = [
|
|
369
|
+
`tell application "System Events" to tell (first application process whose unix id is ${Math.round(pid)})`,
|
|
370
|
+
' set out to ""',
|
|
371
|
+
' repeat with w in windows',
|
|
372
|
+
' set p to position of w',
|
|
373
|
+
' set s to size of w',
|
|
374
|
+
' set out to out & (item 1 of p) & "," & (item 2 of p) & "," & (item 1 of s) & "," & (item 2 of s) & linefeed',
|
|
375
|
+
' end repeat',
|
|
376
|
+
' return out',
|
|
377
|
+
'end tell',
|
|
378
|
+
].join('\n');
|
|
379
|
+
try {
|
|
380
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 8000 });
|
|
381
|
+
/** @type {Bounds|null} */
|
|
382
|
+
let biggest = null;
|
|
383
|
+
for (const line of String(stdout).split('\n')) {
|
|
384
|
+
const parts = line.trim().split(',');
|
|
385
|
+
if (parts.length !== 4) continue;
|
|
386
|
+
const [left, top, width, height] = parts.map((n) => Math.round(Number(n)));
|
|
387
|
+
if (![left, top, width, height].every((n) => Number.isFinite(n))) continue;
|
|
388
|
+
if (!(width > 0) || !(height > 0)) continue;
|
|
389
|
+
if (biggest && biggest.width * biggest.height >= width * height) continue;
|
|
390
|
+
biggest = { left, top, width, height };
|
|
391
|
+
}
|
|
392
|
+
return biggest;
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
341
398
|
// ---------------------------------------------------------------------------
|
|
342
399
|
// The screen, and the windows standing on it
|
|
343
400
|
// ---------------------------------------------------------------------------
|
|
@@ -562,16 +619,23 @@ function askedHeight(watch) {
|
|
|
562
619
|
/**
|
|
563
620
|
* Where the panel goes before there is anything to sit beside.
|
|
564
621
|
*
|
|
622
|
+
* There is deliberately nowhere here to pass an expected app size. Nothing knows it
|
|
623
|
+
* yet — the app has not been started — and a placement made from a guess is a
|
|
624
|
+
* window that jumps the moment `snapTo` puts it where it really belongs.
|
|
625
|
+
*
|
|
626
|
+
* With no app, `planPlacement` puts the panel against the edge it is given, so the
|
|
627
|
+
* side is passed straight through rather than flipped. That flip is only needed
|
|
628
|
+
* when there IS an app, because then the side names the edge the app goes to.
|
|
629
|
+
*
|
|
565
630
|
* @param {Bounds} screen
|
|
566
631
|
* @param {{width: number, height: number}} size
|
|
567
632
|
* @param {import('../../types.js').WatchOptions|undefined} watch
|
|
568
|
-
* @param {{width: number, height: number}|null} [expectedApp]
|
|
569
633
|
* @returns {Bounds}
|
|
570
634
|
*/
|
|
571
|
-
function firstPlace(screen, size, watch
|
|
635
|
+
function firstPlace(screen, size, watch) {
|
|
572
636
|
const plan = planPlacement({
|
|
573
637
|
screen,
|
|
574
|
-
appSize:
|
|
638
|
+
appSize: null,
|
|
575
639
|
panelWidth: size.width,
|
|
576
640
|
side: watch?.side === 'left' ? 'left' : 'right',
|
|
577
641
|
gap: GAP,
|
|
@@ -858,6 +922,21 @@ h1 { margin: 8px 0 2px; font-size: 18px; font-weight: 560; letter-spacing: -0.01
|
|
|
858
922
|
* @property {(beside: BesideThis) => Promise<void>} snapTo
|
|
859
923
|
* @property {() => boolean} placedByHand
|
|
860
924
|
* @property {() => PanelHealth} health
|
|
925
|
+
* @property {PanelBrowser} browser Which browser the window is, and whose it is.
|
|
926
|
+
*/
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The browser the panel opened in.
|
|
930
|
+
*
|
|
931
|
+
* `borrowed` is the field that matters, and it is a safety rule rather than a
|
|
932
|
+
* detail: when this is the person's own everyday browser, nothing in this tool may
|
|
933
|
+
* treat its windows as belonging to the tool. The screen guard reads it for exactly
|
|
934
|
+
* that — claiming a borrowed browser would have it shoving the person out of their
|
|
935
|
+
* own tabs.
|
|
936
|
+
*
|
|
937
|
+
* @typedef {object} PanelBrowser
|
|
938
|
+
* @property {string} name
|
|
939
|
+
* @property {boolean} borrowed True when this is the browser the person uses.
|
|
861
940
|
*/
|
|
862
941
|
|
|
863
942
|
/**
|
|
@@ -882,6 +961,10 @@ h1 { margin: 8px 0 2px; font-size: 18px; font-weight: 560; letter-spacing: -0.01
|
|
|
882
961
|
*
|
|
883
962
|
* @typedef {object} BesideThis
|
|
884
963
|
* @property {number|null} [pid] The process THIS run started. Never one we attached to.
|
|
964
|
+
* A desktop app answers no protocol call about its own
|
|
965
|
+
* window, so its unix id is the only way to find out
|
|
966
|
+
* where it is — and the only way to move it out of the
|
|
967
|
+
* panel's way.
|
|
885
968
|
* @property {any} [page] Its page, when it has one we can ask.
|
|
886
969
|
* @property {Bounds} [window] Where its window is, when the caller already knows.
|
|
887
970
|
* @property {boolean} [hasWindow] False for anything headless, which is most things.
|
|
@@ -1256,7 +1339,7 @@ export async function openPanel(opts = {}) {
|
|
|
1256
1339
|
// screen is. A position on the command line is a guess; this is the answer.
|
|
1257
1340
|
const screen = await readScreen(page);
|
|
1258
1341
|
const kept = remembered && fitsOnScreen(remembered, screen) ? remembered : null;
|
|
1259
|
-
const first = kept ?? firstPlace(screen, size, watch
|
|
1342
|
+
const first = kept ?? firstPlace(screen, size, watch);
|
|
1260
1343
|
await moveWindow(page, first);
|
|
1261
1344
|
await watchForHandMove(page, first);
|
|
1262
1345
|
|
|
@@ -1284,6 +1367,7 @@ export async function openPanel(opts = {}) {
|
|
|
1284
1367
|
// Opened where they left it, so it is already theirs: nothing snaps it.
|
|
1285
1368
|
byHand: kept !== null,
|
|
1286
1369
|
foreground: watch.foreground === true,
|
|
1370
|
+
browser: { name: chrome.name, borrowed: chrome.borrowed },
|
|
1287
1371
|
});
|
|
1288
1372
|
} catch (e) {
|
|
1289
1373
|
// Somebody finishing is not something going wrong. A run that ends before its window
|
|
@@ -1374,6 +1458,7 @@ const KEEP_NOTES = 120;
|
|
|
1374
1458
|
* placed: Bounds,
|
|
1375
1459
|
* byHand: boolean,
|
|
1376
1460
|
* foreground: boolean,
|
|
1461
|
+
* browser: PanelBrowser,
|
|
1377
1462
|
* }} ctx
|
|
1378
1463
|
* @returns {Panel}
|
|
1379
1464
|
*/
|
|
@@ -1546,16 +1631,48 @@ function makePanel(ctx) {
|
|
|
1546
1631
|
|
|
1547
1632
|
// Anything headless has a window on paper and nothing on the screen, and
|
|
1548
1633
|
// snapping against one would leave the panel hugging thin air.
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1551
|
-
|
|
1634
|
+
const headless = beside?.hasWindow === false;
|
|
1635
|
+
const appPage = headless ? null : (beside?.page ?? null);
|
|
1636
|
+
const pid = headless ? 0 : Math.round(Number(beside?.pid) || 0);
|
|
1637
|
+
// Ask the protocol first, the page second, the window server last: a browser
|
|
1638
|
+
// answers the first, a desktop app answers only the second, and a desktop app
|
|
1639
|
+
// whose page we have not got answers only the third.
|
|
1552
1640
|
const current =
|
|
1553
1641
|
beside?.window ??
|
|
1554
|
-
(appPage ? ((await readWindowBounds(appPage)) ?? (await readPageWindow(appPage))) : null)
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1642
|
+
(appPage ? ((await readWindowBounds(appPage)) ?? (await readPageWindow(appPage))) : null) ??
|
|
1643
|
+
(pid > 0 ? await windowBoundsByPid(pid) : null);
|
|
1644
|
+
|
|
1645
|
+
/** @type {Bounds} */
|
|
1646
|
+
let target;
|
|
1647
|
+
if (current && pid > 0) {
|
|
1648
|
+
// This run started that app, so it is ours to move: pin it to its edge and put
|
|
1649
|
+
// the panel flush against it — one shape, two windows. `planPlacement` names the
|
|
1650
|
+
// edge the APP goes to, which is the opposite end of the same arrangement from
|
|
1651
|
+
// the side the PANEL sits on, so the side is flipped on the way in.
|
|
1652
|
+
const plan = planPlacement({
|
|
1653
|
+
screen,
|
|
1654
|
+
appSize: { width: current.width, height: current.height },
|
|
1655
|
+
panelWidth: ctx.panelWidth,
|
|
1656
|
+
side: ctx.side === 'left' ? 'right' : 'left',
|
|
1657
|
+
gap: GAP,
|
|
1658
|
+
});
|
|
1659
|
+
if (plan.app) await moveWindowByPid(pid, current, plan.app);
|
|
1660
|
+
// Where it ACTUALLY ended up, which is not always where it was asked to go: a
|
|
1661
|
+
// window manager may refuse, and there is no accessibility permission on some
|
|
1662
|
+
// machines at all. A panel placed where the app was supposed to be would then
|
|
1663
|
+
// land straight on top of it, which is worse than not snapping.
|
|
1664
|
+
const landed = (await windowBoundsByPid(pid)) ?? current;
|
|
1665
|
+
target = panelBeside(landed, screen, ctx.panelWidth, ctx.side === 'left' ? 'right' : 'left');
|
|
1666
|
+
} else if (current) {
|
|
1667
|
+
// Same flip, same reason: `panelBeside`'s side names which end of the pair the
|
|
1668
|
+
// APP is. Passing the panel's own side through here put the panel on the wrong
|
|
1669
|
+
// side of the app for every run that ever snapped.
|
|
1670
|
+
target = panelBeside(current, screen, ctx.panelWidth, ctx.side === 'left' ? 'right' : 'left');
|
|
1671
|
+
} else {
|
|
1672
|
+
// Nothing to sit beside. With no app, `planPlacement` puts the panel against the
|
|
1673
|
+
// edge it is given, so this one is NOT flipped.
|
|
1674
|
+
target = planPlacement({ screen, appSize: null, panelWidth: ctx.panelWidth, side: ctx.side, gap: GAP }).panel;
|
|
1675
|
+
}
|
|
1559
1676
|
const bounds = ctx.askedHeight ? { ...target, height: Math.min(ctx.askedHeight, screen.height) } : target;
|
|
1560
1677
|
|
|
1561
1678
|
if (await moveWindow(ctx.page, bounds)) {
|
|
@@ -1667,5 +1784,6 @@ function makePanel(ctx) {
|
|
|
1667
1784
|
snapTo,
|
|
1668
1785
|
placedByHand: () => byHand,
|
|
1669
1786
|
health: () => ({ alive: !dead, pushed, delivered, dropped, stalls, queued: queue.length }),
|
|
1787
|
+
browser: ctx.browser,
|
|
1670
1788
|
};
|
|
1671
1789
|
}
|