staysfixed 0.7.1 → 0.8.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/README.md +193 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +549 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +345 -47
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
package/src/v2/doctor.js CHANGED
@@ -36,9 +36,12 @@ import { findConfigFile, rootForConfig } from '../core/paths.js';
36
36
  import { platformTag } from '../drive/find.js';
37
37
  import { isRepo } from '../core/git.js';
38
38
  import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
39
- import { POWERSHELL_PATHS } from './remote.js';
39
+ import { POWERSHELL_PATHS, describeRemote } from './remote.js';
40
+ import { deviceToMake } from './adapters/android.js';
41
+ import { describeWindows } from './adapters/windows.js';
40
42
  import { messageOf, EXIT } from '../core/errors.js';
41
43
  import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
44
+ import { loadPlaywright } from './adapters/web-driver.js';
42
45
 
43
46
  const exec = promisify(execFile);
44
47
 
@@ -90,6 +93,19 @@ export const CHANNELS = [
90
93
  * @property {string} [powershell] The absolute path to powershell.exe that answered, when one did.
91
94
  * Kept because it is the evidence: "there is Windows behind this
92
95
  * host" is a claim, and this is the file that proves it.
96
+ * @property {import('./remote.js').RemoteDescription} [detail]
97
+ * Everything the remote runner could learn about that machine once it was known to answer:
98
+ * what is installed on it, whether anybody is signed in, whether the desktop is locked, and
99
+ * a `missing[]` carrying the exact command for each thing that is not there.
100
+ *
101
+ * WHY THE FOUR FIELDS ABOVE ARE NOT READ OUT OF IT. They come from the cheap probe, which
102
+ * is a plain `echo` down an ssh connection and needs nothing on the far machine at all.
103
+ * `detail` comes from the runner, which needs Node there. Letting the deeper answer
104
+ * overwrite the shallower one would report a perfectly reachable machine as unreachable the
105
+ * day somebody's Node is a version too old, and offer them the ssh config as the fix.
106
+ *
107
+ * Absent when the host never answered, and when `doctor --offline` skipped dialling
108
+ * altogether. Absent is "nobody asked", never "there is nothing there".
93
109
  */
94
110
 
95
111
  /**
@@ -256,7 +272,15 @@ export async function capabilities(opts = {}) {
256
272
  hosts,
257
273
  nextSteps: nextSteps(surfaces, reference, repo),
258
274
  limits: PERMANENT_LIMITS,
259
- wiring: WIRING,
275
+ // The wiring block, with the real folder in it.
276
+ //
277
+ // `WIRING` carries a placeholder, because it is written once as a constant and a constant
278
+ // cannot know where it is being asked from. Handing that placeholder straight back is
279
+ // how `doctor --json` came to answer an agent's "how do I wire this up" with the literal
280
+ // words `/absolute/path/to/your/project` — a value that fails silently if pasted, and
281
+ // one the tool knew the real answer to all along. `init` had always filled it in; this
282
+ // is the same courtesy from the command an agent is told to call first.
283
+ wiring: { ...WIRING, mcp: mcpWiringFor(root) },
260
284
  };
261
285
 
262
286
  return caps;
@@ -328,6 +352,20 @@ const WIRING = {
328
352
  },
329
353
  };
330
354
 
355
+ /**
356
+ * The wiring block for one project, with its folder filled in.
357
+ *
358
+ * @param {string} root
359
+ * @returns {Record<string, unknown>}
360
+ */
361
+ function mcpWiringFor(root) {
362
+ return {
363
+ mcpServers: {
364
+ staysfixed: { command: 'npx', args: ['-y', 'staysfixed', 'mcp'], cwd: root },
365
+ },
366
+ };
367
+ }
368
+
331
369
  /**
332
370
  * The version out of package.json, read without importing the CLI — doctor has
333
371
  * to work when everything else in the project is broken.
@@ -470,22 +508,27 @@ async function findTools(cwd, browsers) {
470
508
  })(),
471
509
 
472
510
  (async () => {
473
- const installed = hasModule(cwd, 'playwright') || hasModule(cwd, 'playwright-core');
474
- // The browsers Playwright downloads live outside any project and survive
475
- // every reinstall, so finding them means half the work is already done and
476
- // telling somebody to download them again would be wrong.
511
+ // Ask the thing that actually opens the page, not the folder it might live in.
512
+ //
513
+ // This used to look for a `playwright` folder under the project being checked. That is
514
+ // the wrong question in two directions at once. The driver now ships WITH this tool, so
515
+ // it is present even when the project has never heard of it; and a project that keeps
516
+ // its packages somewhere unusual has one when the folder walk says it does not.
517
+ //
518
+ // Getting this wrong is how 0.7.2 came to tell every agent that asked that web apps and
519
+ // sites could be checked "here and now", and then answered every single website check
520
+ // with "no web page can be opened". `loadPlaywright` is the one piece of code whose
521
+ // answer is the truth, because it is the code the walk itself runs.
522
+ const state = await loadPlaywright({ projectRoot: cwd });
477
523
  const downloaded = playwrightBrowsersDir();
478
524
  add({
479
525
  id: 'playwright',
480
- name: 'Playwright',
481
- found: installed,
482
- where: downloaded ?? undefined,
483
- why: 'Reads a page’s meaning tree properly, and brings a browser of its own so yours is left alone.',
484
- fix: installed
485
- ? undefined
486
- : downloaded
487
- ? `Its browsers are already downloaded in ${downloaded}, so only the package is missing: npm install --save-dev playwright`
488
- : INSTALL_COMMAND,
526
+ name: 'a browser to read pages with',
527
+ found: state.ok,
528
+ where: state.executable ?? downloaded ?? undefined,
529
+ version: state.version,
530
+ why: 'Reads a page’s meaning tree properly, and opens a browser that is not the one you use, so yours is left alone.',
531
+ fix: state.ok ? undefined : `${state.howToGet ?? INSTALL_COMMAND} — nothing to sign up for and nobody to ask.`,
489
532
  automatic: true,
490
533
  });
491
534
  })(),
@@ -498,7 +541,15 @@ async function findTools(cwd, browsers) {
498
541
  found: app !== null,
499
542
  where: app?.where,
500
543
  why: 'A desktop app is driven straight over its own debugging port, so no browser is needed for it — only the app itself.',
501
- fix: app ? undefined : 'Only needed if the product you are watching is a desktop app. If it is, name the built app in your settings under app.binary.',
544
+ // `electron.binary`, not `app.binary`.
545
+ //
546
+ // Both keys exist and they belong to different halves of the tool: `app.binary` is
547
+ // what version 1's picture engine reads, and `electron.binary` is what the difference
548
+ // engine reads. `doctor` describes the difference engine, so telling somebody to set
549
+ // `app.binary` sent them to write a setting the adapter they are about to run never
550
+ // looks at — and the next run says there is no desktop app, for a reason they have
551
+ // just ruled out.
552
+ fix: app ? undefined : 'Only needed if the product you are watching is a desktop app. If it is, name the built app in your settings under electron.binary.',
502
553
  automatic: false,
503
554
  });
504
555
  })(),
@@ -540,7 +591,17 @@ async function findTools(cwd, browsers) {
540
591
  where: where ?? undefined,
541
592
  version: names[0],
542
593
  why: 'The emulator is the program; a virtual device is the phone it runs. Without one there is nothing to install the app onto.',
543
- fix: names.length > 0 ? undefined : 'sdkmanager "system-images;android-35;google_apis;arm64-v8a" && avdmanager create avd -n staysfixed -k "system-images;android-35;google_apis;arm64-v8a"',
594
+ // Asked of the Android adapter, never written out again here.
595
+ //
596
+ // There were two copies of this command, one in each file, and they had drifted in
597
+ // two ways at once. They named different Android versions, so doctor and the adapter
598
+ // disagreed about which device to make. And both hardcoded `arm64-v8a`, so anybody on
599
+ // an Intel Mac or an x86 Linux box was handed a command naming an image that does not
600
+ // exist for their machine — and the failure talks about the image, which sends them
601
+ // looking for the wrong thing entirely. `deviceToMake` reads this machine's processor
602
+ // and carries the Play-Store warning in the same string, so neither half can be
603
+ // separated from the other again.
604
+ fix: names.length > 0 ? undefined : deviceToMake().both,
544
605
  automatic: true,
545
606
  });
546
607
  })(),
@@ -668,6 +729,59 @@ function hasModule(cwd, name) {
668
729
  }
669
730
  }
670
731
 
732
+ /**
733
+ * The settings file with everything commented out taken away.
734
+ *
735
+ * `staysfixed init` writes a settings file whose whole point is that the options which do
736
+ * NOT apply to your project are commented out rather than left out, so nothing is hidden
737
+ * from the person reading it. Doctor reads that file as TEXT — never by loading it, because
738
+ * a settings file may be JavaScript and doctor must not run somebody's code to answer a
739
+ * question about their machine — and it used to search the raw text.
740
+ *
741
+ * Which means it found the examples. On a folder containing one `cli.js` and nothing else,
742
+ * doctor announced "Electron desktop apps: Covered. It opens release/mac-arm64/Your App.app"
743
+ * and "An Android app is here", both read out of commented-out lines, and both false. A
744
+ * surface reported as covered when nothing will ever be walked on it is the worst answer
745
+ * this tool can give.
746
+ *
747
+ * Strings are respected, so an address like "http://localhost:3000" survives: the two
748
+ * slashes inside it are not the start of a comment.
749
+ *
750
+ * @param {string} text
751
+ * @returns {string} The same text with comments blanked, line numbering unchanged.
752
+ */
753
+ export function withoutComments(text) {
754
+ let out = '';
755
+ /** @type {"code"|"line"|"block"|"'"|'"'|'`'} */
756
+ let mode = 'code';
757
+ for (let i = 0; i < text.length; i += 1) {
758
+ const c = text[i];
759
+ const next = text[i + 1];
760
+ if (mode === 'code') {
761
+ if (c === '/' && next === '/') { mode = 'line'; out += ' '; i += 1; continue; }
762
+ if (c === '/' && next === '*') { mode = 'block'; out += ' '; i += 1; continue; }
763
+ if (c === "'" || c === '"' || c === '`') mode = /** @type {any} */ (c);
764
+ out += c;
765
+ continue;
766
+ }
767
+ if (mode === 'line') {
768
+ if (c === '\n') { mode = 'code'; out += c; continue; }
769
+ out += ' ';
770
+ continue;
771
+ }
772
+ if (mode === 'block') {
773
+ if (c === '*' && next === '/') { mode = 'code'; out += ' '; i += 1; continue; }
774
+ out += c === '\n' ? c : ' ';
775
+ continue;
776
+ }
777
+ // Inside a string. A backslash escapes whatever comes next, quote included.
778
+ if (c === '\\') { out += c + (next ?? ''); i += 1; continue; }
779
+ if (c === mode) mode = 'code';
780
+ out += c;
781
+ }
782
+ return out;
783
+ }
784
+
671
785
  /**
672
786
  * Is there a desktop app in this project to check?
673
787
  *
@@ -687,9 +801,12 @@ function findDesktopApp(cwd) {
687
801
  const configFile = findConfigFile(cwd);
688
802
  if (configFile) {
689
803
  try {
690
- const text = readFileSync(configFile, 'utf8');
804
+ // Comments taken away first. See `withoutComments`: the examples in a settings file
805
+ // are commented out on purpose, and reading them as settings reported a desktop app
806
+ // in a folder that contains one script.
807
+ const text = withoutComments(readFileSync(configFile, 'utf8'));
691
808
  const named = /["']?binary["']?\s*:\s*["'`]([^"'`]+)["'`]/.exec(text);
692
- if (named) return { where: named[1], how: 'your settings name it under app.binary' };
809
+ if (named) return { where: named[1], how: 'your settings name it under electron.binary' };
693
810
  if (/["']?kind["']?\s*:\s*["'`]electron["'`]/.test(text)) {
694
811
  return { where: configFile, how: 'your settings say this project is a desktop app' };
695
812
  }
@@ -761,7 +878,9 @@ async function phoneApps(root, configFile) {
761
878
  let settings = '';
762
879
  if (configFile) {
763
880
  try {
764
- settings = readFileSync(configFile, 'utf8');
881
+ // Comments taken away first, for the same reason `findDesktopApp` does it: a
882
+ // commented-out `apk:` line is an example, not an Android app.
883
+ settings = withoutComments(readFileSync(configFile, 'utf8'));
765
884
  } catch {
766
885
  settings = '';
767
886
  }
@@ -852,6 +971,54 @@ function blocks(need) {
852
971
  return need.why.startsWith('Nothing on this platform');
853
972
  }
854
973
 
974
+ /**
975
+ * One adapter's — or one machine's — `Missing` in the shape the rest of this file reads.
976
+ *
977
+ * The two shapes exist for good reasons and neither is going away: an adapter says what it
978
+ * needs and what that unlocks, and doctor has to say additionally whether a person is
979
+ * required. The translation between them is the only place that decision is made, so both
980
+ * callers make it the same way and `blocks` above keeps reading the sentence it expects.
981
+ *
982
+ * @param {import('./adapters/contract.js').Missing} m
983
+ * @returns {Need}
984
+ */
985
+ function needFromMissing(m) {
986
+ return {
987
+ what: String(m.what),
988
+ why: m.blocking === true ? 'Nothing on this platform can be checked at all without it.' : 'It widens what can be watched here.',
989
+ fix: String(m.howToGet ?? ''),
990
+ // Whether a person is needed is read out of the words, because the adapter
991
+ // contract has no field for it. A licence, an account, a pair of hands or a
992
+ // device is a person; everything else is a command the agent just runs. Being
993
+ // wrong in this direction only ever means telling somebody about a step they
994
+ // did not have to take, which is far cheaper than the other way round.
995
+ automatic: !/licen[cs]e|apple id|app store|plug|pair of hands|somebody has to|a person|sign in|log in|only a person/i.test(String(m.howToGet ?? '')),
996
+ unlocks: String(m.unlocks ?? ''),
997
+ };
998
+ }
999
+
1000
+ /**
1001
+ * What one platform reports when the adapter that answers for it did not answer.
1002
+ *
1003
+ * Written as blocking on purpose. It is not a claim that the platform cannot be checked — it
1004
+ * is the honest opposite, that nobody here knows — and between the two ways of being wrong,
1005
+ * saying less is covered than really is costs somebody a second look, while saying more is
1006
+ * covered than really is costs them a green run that means nothing.
1007
+ *
1008
+ * @param {string} name
1009
+ * @param {string} why
1010
+ * @returns {Need}
1011
+ */
1012
+ function couldNotAsk(name, why) {
1013
+ return {
1014
+ what: `an answer from the ${name} adapter about what it needs`,
1015
+ why: 'Nothing on this platform can be checked at all without it.',
1016
+ fix: `The ${name} adapter was asked what it needs here and ${why}, so nothing on this page says whether it would work. Run \`staysfixed doctor\` again; if it keeps happening, run a check aimed at ${name} and read what that says.`,
1017
+ automatic: true,
1018
+ unlocks: `an honest answer about whether your ${name} product can be checked on this machine — right now there is none, in either direction`,
1019
+ };
1020
+ }
1021
+
855
1022
  /**
856
1023
  * The platforms that arrive as an adapter of their own, and know their own requirements.
857
1024
  * The built-in five are described by hand above, because they are older than this
@@ -902,29 +1069,26 @@ async function askTheAdapters(root) {
902
1069
  const adapter = engine.adapters.find((a) => a.name === name);
903
1070
  if (!adapter) return;
904
1071
  try {
905
- /** @type {{missing?: {what?: string, unlocks?: string, howToGet?: string, blocking?: boolean}[]}} */
1072
+ /** @type {{missing?: {what?: string, unlocks?: string, howToGet?: string, blocking?: boolean}[]}|null} */
906
1073
  const detection = await Promise.race([
907
1074
  adapter.detect({ root, config: config[name] ?? {} }),
908
- new Promise((resolve) => setTimeout(() => resolve({ missing: [] }), REACH_MS * 2)),
1075
+ // Null, not an empty answer. "The adapter says it needs nothing" and "the adapter
1076
+ // never answered" reached this line as the same empty list, and an empty list is
1077
+ // what makes a surface READY: an Android adapter that hung for sixteen seconds
1078
+ // produced "Covered against the stored record" on a machine where nothing had been
1079
+ // asked at all. A silence that turns into an all-clear is the exact failure this
1080
+ // whole tool exists to prevent, so silence now has a value of its own.
1081
+ new Promise((resolve) => setTimeout(() => resolve(null), REACH_MS * 2)),
909
1082
  ]);
910
- const needs = (detection?.missing ?? [])
1083
+ out.set(name, detection === null ? [couldNotAsk(name, `it did not answer within ${Math.round((REACH_MS * 2) / 1000)} seconds`)] : (detection.missing ?? [])
911
1084
  .filter((m) => typeof m.what === 'string' && m.what !== '')
912
- .map((m) => /** @type {Need} */ ({
913
- what: String(m.what),
914
- why: m.blocking === true ? 'Nothing on this platform can be checked at all without it.' : 'It widens what can be watched here.',
915
- fix: String(m.howToGet ?? ''),
916
- // Whether a person is needed is read out of the words, because the adapter
917
- // contract has no field for it. A licence, an account, a pair of hands or a
918
- // device is a person; everything else is a command the agent just runs. Being
919
- // wrong in this direction only ever means telling somebody about a step they
920
- // did not have to take, which is far cheaper than the other way round.
921
- automatic: !/licen[cs]e|apple id|app store|plug|pair of hands|somebody has to|a person|sign in|log in/i.test(String(m.howToGet ?? '')),
922
- unlocks: String(m.unlocks ?? ''),
923
- }))
924
- .filter((need) => need.fix !== '');
925
- out.set(name, needs);
926
- } catch {
927
- // An adapter that cannot answer contributes nothing, and the machine survey stands.
1085
+ .map((m) => needFromMissing(/** @type {import('./adapters/contract.js').Missing} */ (m)))
1086
+ .filter((need) => need.fix !== ''));
1087
+ } catch (e) {
1088
+ // Same again for an adapter that fell over. It contributes a hole, never a silence:
1089
+ // the machine survey stands, and this platform says plainly that nothing here knows
1090
+ // whether it would work.
1091
+ out.set(name, [couldNotAsk(name, messageOf(e))]);
928
1092
  }
929
1093
  }),
930
1094
  );
@@ -1068,6 +1232,9 @@ async function describeHost(name) {
1068
1232
  const ssh = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5'];
1069
1233
  const alive = await ask('ssh', [...ssh, name, `echo ${ALIVE}`], REACH_MS);
1070
1234
  if (!answered(alive)) return readHostProbe(name, alive, null);
1235
+ // Nothing more is asked of a machine that will not even echo. Everything below costs a
1236
+ // second connection, and spending it on a host that did not answer the first one is how
1237
+ // doctor stops being the quick command somebody runs when they are already stuck.
1071
1238
 
1072
1239
  // A shell that can SEE powershell.exe on the filesystem has a real Windows desktop
1073
1240
  // behind it — the cheapest Windows runner there is, and one nobody has to provision.
@@ -1077,7 +1244,64 @@ async function describeHost(name) {
1077
1244
  // it. The one list of places to look lives in remote.js, which is the file that later
1078
1245
  // has to actually run one.
1079
1246
  const look = await ask('ssh', [...ssh, name, `ls -d ${POWERSHELL_PATHS.map((p) => `'${p}'`).join(' ')}`], REACH_MS);
1080
- return readHostProbe(name, alive, look);
1247
+ const report = readHostProbe(name, alive, look);
1248
+
1249
+ // And then the half that was written and never called.
1250
+ //
1251
+ // "Reachable" on its own is not an answer anybody can act on. The requirement this whole
1252
+ // file exists to meet is that a stranger's AI can read what the tool needs and tell the
1253
+ // person, with the exact commands — and a remote machine that comes back as one boolean
1254
+ // and a sentence fails that completely. `describeRemote` answers the rest: what is
1255
+ // installed there, whether anybody is signed in on the Windows desktop, whether it is
1256
+ // locked, and a list of what is missing with the command for each.
1257
+ //
1258
+ // It never throws, but it does open a connection, so it is still wrapped: doctor answering
1259
+ // less is a bad day, and doctor not answering is the command somebody runs when they are
1260
+ // already stuck failing on them.
1261
+ try {
1262
+ const detail = await describeRemote(name, {
1263
+ // What the cheap probe already proved, handed over rather than asked again. Without
1264
+ // this, a machine with no Node on it comes back "could not be reached" and the fix
1265
+ // offered is the ssh config that just worked.
1266
+ answered: true,
1267
+ powershell: report.powershell ?? null,
1268
+ timeoutMs: REACH_MS * 2,
1269
+ windowsTimeoutMs: REACH_MS * 4,
1270
+ });
1271
+ return withRemoteDetail(report, detail);
1272
+ } catch (e) {
1273
+ return { ...report, how: `${report.how}, but nothing more could be learned about it (${messageOf(e)})` };
1274
+ }
1275
+ }
1276
+
1277
+ /**
1278
+ * Fold what the runner learned into what the cheap probe proved.
1279
+ *
1280
+ * Split out and exported so the merge can be tested without a network: the rule that the
1281
+ * shallow answer wins on reachability is the whole point of it, and a rule that only exists
1282
+ * inside a function nothing can call without an ssh key is a rule nobody will notice
1283
+ * breaking.
1284
+ *
1285
+ * @param {HostReport} report What the plain `echo` and `ls` probes established.
1286
+ * @param {import('./remote.js').RemoteDescription} detail
1287
+ * @returns {HostReport}
1288
+ */
1289
+ export function withRemoteDetail(report, detail) {
1290
+ /** @type {HostReport} */
1291
+ const out = { ...report, detail };
1292
+ // Windows, only ever added. The cheap probe asks the filesystem for powershell.exe and is
1293
+ // right whether or not anything else on that machine works; the deep one cannot ask at all
1294
+ // unless the runner started. So a `false` from the deep probe is "could not tell", and
1295
+ // letting it clear a `true` would lose the Windows runner somebody already has.
1296
+ if (detail.windows === true && out.windows !== true) {
1297
+ out.windows = true;
1298
+ if (detail.powershell) out.powershell = detail.powershell;
1299
+ }
1300
+ // `detail.how` already opens with the fact that a shell answers, so it replaces the
1301
+ // shallower sentence rather than being appended to it. Two sentences saying the same thing
1302
+ // in different words is how a reader starts wondering which of them is the real answer.
1303
+ if (!detail.runnerStarted) out.how = detail.how;
1304
+ return out;
1081
1305
  }
1082
1306
 
1083
1307
  /**
@@ -1351,7 +1575,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1351
1575
  {
1352
1576
  what: 'settings naming the built app',
1353
1577
  why: 'Two builds of one desktop app fight over its single-instance lock and its data folder, so the tool has to know exactly which file to open and give each run its own folder.',
1354
- fix: `Run \`staysfixed init\`, or set app.binary to ${desktopApp.where}.`,
1578
+ fix: `Run \`staysfixed init\`, or set electron.binary to ${desktopApp.where}.`,
1355
1579
  automatic: true,
1356
1580
  unlocks: 'Your desktop app gets checked end to end, including every IPC channel the code registers — the doors no screenshot has ever seen.',
1357
1581
  },
@@ -1359,7 +1583,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1359
1583
  });
1360
1584
  if (desktopApp === null) {
1361
1585
  notInThisProject.add('electron');
1362
- impossible.set('electron', 'This project has no desktop app in it. If yours is built somewhere else, name the built app in your settings under app.binary and this becomes available — nothing else is needed, and no browser is needed for it at all.');
1586
+ impossible.set('electron', 'This project has no desktop app in it. If yours is built somewhere else, name the built app in your settings under electron.binary and this becomes available — nothing else is needed, and no browser is needed for it at all.');
1363
1587
  }
1364
1588
 
1365
1589
  // Three separate questions, and folding any two of them together is how a surface gets
@@ -1478,25 +1702,51 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1478
1702
  // is "detect rather than ask" at its sharpest: a runner that already answers must never
1479
1703
  // be presented as something to go and set up.
1480
1704
  const windowsDriver = canDrive('windows');
1705
+ // A Windows desktop nobody has signed into is not a runner. There is nothing on it to read
1706
+ // — no windows, no controls — so calling it "partly covered" would be the exact over-claim
1707
+ // this file exists to prevent. The question can only be asked when the runner started
1708
+ // there, so `undefined` means nobody could tell and the older, more generous answer stands.
1709
+ const windowsSignedOut = windowsHost?.detail?.desktopLoggedIn === false;
1710
+ const windowsUsable = windowsHost !== undefined && windowsDriver && !windowsSignedOut;
1481
1711
  surfaces.push({
1482
1712
  id: 'windows',
1483
1713
  name: 'native Windows apps',
1484
- status: windowsHost && windowsDriver ? 'partial' : 'unavailable',
1714
+ status: windowsUsable ? 'partial' : 'unavailable',
1485
1715
  summary: !windowsHost
1486
1716
  ? 'No Windows desktop is reachable from here. This is usually fine: an Electron product on Windows is watched over the debug port instead, from any machine.'
1487
1717
  : !windowsDriver
1488
1718
  ? `A real Windows desktop is reachable through ${windowsHost.name}, and this copy of Stays Fixed cannot drive one. ${noDriver('windows')}`
1489
- : `A real Windows desktop is already reachable through ${windowsHost.name}, and nothing has to be installed on it — the program that reads the screen is sent down the ssh connection each run and disappears when it closes. Two builds still cannot run at once, because Windows shows one desktop, so runs are one after the other and the comparison is weaker here than anywhere else.`,
1490
- canCheck: windowsHost && windowsDriver ? withoutADriver : [],
1491
- cannotCheck: windowsHost && windowsDriver ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
1719
+ // The adapter's own paragraph, not a second one written here. It knows whether
1720
+ // anybody is signed in and whether the screen is locked, and those two change the
1721
+ // answer completely; a summary kept in this file could only ever guess at them, and
1722
+ // two descriptions of one machine will eventually disagree.
1723
+ : windowsHost.detail
1724
+ ? `${describeWindows(windowsHost.detail)} Nothing has to be installed on it either — the program that reads the screen is sent down the ssh connection each run and disappears when it closes.`
1725
+ : `A real Windows desktop is already reachable through ${windowsHost.name}, and nothing has to be installed on it — the program that reads the screen is sent down the ssh connection each run and disappears when it closes. Two builds still cannot run at once, because Windows shows one desktop, so runs are one after the other and the comparison is weaker here than anywhere else.`,
1726
+ canCheck: windowsUsable ? withoutADriver : [],
1727
+ cannotCheck: windowsUsable ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
1492
1728
  // Asked of the Windows adapter, which knows what it needs — the name of a machine and
1493
1729
  // the built program — rather than kept as a second opinion here. The one thing added
1494
1730
  // is the host name, because doctor found it by dialling and the adapter cannot.
1731
+ //
1732
+ // Plus whatever the machine itself is missing, which only the dial could find out: a
1733
+ // desktop nobody has signed into, a screen that is locked. Those come with the sentence
1734
+ // saying what each one unlocks, so an agent can relay one clear line to a person instead
1735
+ // of inventing instructions.
1495
1736
  needs:
1496
1737
  windowsHost && windowsDriver
1497
- ? (asked.get('windows') ?? []).map((need) => ({ ...need, fix: need.fix.replace(/"the-ssh-host-name"/g, `"${windowsHost.name}"`) }))
1738
+ ? [
1739
+ ...(windowsHost.detail?.missing ?? []).map(needFromMissing),
1740
+ ...(asked.get('windows') ?? []).map((need) => ({ ...need, fix: need.fix.replace(/"the-ssh-host-name"/g, `"${windowsHost.name}"`) })),
1741
+ ]
1498
1742
  : [],
1499
1743
  });
1744
+ if (windowsSignedOut && windowsHost) {
1745
+ impossible.set(
1746
+ 'windows',
1747
+ `${windowsHost.name} is a Windows machine and this copy can drive one, but nobody is signed in on that desktop. Nothing can be read off a desktop nobody has signed into. Sign in on it once and leave the session running — locking the screen afterwards is fine — and this becomes available.`,
1748
+ );
1749
+ }
1500
1750
  if (windowsHost && !windowsDriver) {
1501
1751
  impossible.set('windows', `${noDriver('windows')} Nothing you install on that machine changes it. Update Stays Fixed to a copy that has it.`);
1502
1752
  }
@@ -1668,6 +1918,49 @@ function nextSteps(surfaces, reference, repo) {
1668
1918
 
1669
1919
  // ── words ───────────────────────────────────────────────────────────────────
1670
1920
 
1921
+ /**
1922
+ * What one reachable machine actually is, in the plainest words there are.
1923
+ *
1924
+ * Every one of these facts was already being collected and thrown away: `describeRemote`
1925
+ * returns what is installed there, whether anybody is signed in, whether the desktop is
1926
+ * locked and what each missing thing would unlock, and doctor was printing a host name.
1927
+ *
1928
+ * Nothing is invented when the answer is unknown. A machine whose runner would not start has
1929
+ * an empty tools list because nothing could ask, not because nothing is installed, and the
1930
+ * note that says so comes from the same place the facts do.
1931
+ *
1932
+ * @param {HostReport} host
1933
+ * @returns {string[]}
1934
+ */
1935
+ function hostLines(host) {
1936
+ /** @type {string[]} */
1937
+ const lines = [];
1938
+ const detail = host.detail;
1939
+ if (!detail) {
1940
+ lines.push(`${host.name}: ${host.how}. Nothing further was asked of it.`);
1941
+ return lines;
1942
+ }
1943
+ const has = Object.entries(detail.tools).filter(([, where]) => typeof where === 'string' && where !== '').map(([name]) => name);
1944
+ const head = [detail.os, has.length > 0 ? `has ${plainList(has)}` : null].filter(Boolean).join(', ');
1945
+ lines.push(`${host.name}: ${head || detail.how}.`);
1946
+ if (detail.windows) {
1947
+ lines.push(
1948
+ ` Windows behind it${detail.windowsVersion ? ` (${detail.windowsVersion})` : ''}, ` +
1949
+ `${detail.desktopLoggedIn === false ? 'with nobody signed in on the desktop' : detail.desktopLoggedIn === true ? 'signed in' : 'and whether anybody is signed in could not be read'}` +
1950
+ `${detail.desktopLocked === true ? ', screen locked — controls still read correctly, only whole-screen pictures come back black' : ''}.`,
1951
+ );
1952
+ }
1953
+ // Each one with the exact command, because "you need Node there" and "run this" are not the
1954
+ // same message, and only one of them can be relayed to somebody who is not a programmer.
1955
+ for (const missing of detail.missing) {
1956
+ lines.push(` ${missing.blocking === true ? 'needs' : 'would help'}: ${missing.what} — ${missing.howToGet}`);
1957
+ }
1958
+ if (!detail.runnerStarted) {
1959
+ lines.push(' What is installed on it is unknown rather than absent: the program this tool sends down the connection did not start, so nothing could be asked.');
1960
+ }
1961
+ return lines;
1962
+ }
1963
+
1671
1964
  /**
1672
1965
  * The same object, said out loud. Used by the CLI, and quoted verbatim into the
1673
1966
  * MCP answer so an agent and a person are never told different things.
@@ -1741,6 +2034,10 @@ export function describeCapabilities(caps) {
1741
2034
  const runners = caps.hosts.filter((h) => h.reachable);
1742
2035
  if (runners.length > 0) {
1743
2036
  lines.push(`Other machines it can already reach: ${runners.map((h) => h.name + (h.windows ? ' (has a real Windows desktop behind it)' : '')).join(', ')}.`);
2037
+ // And then what each of them actually is. A name and the word "reachable" is not
2038
+ // something anybody can act on — the requirement is that an agent reading this can tell
2039
+ // a person what a machine needs, with the command, and a one-word answer fails that.
2040
+ for (const host of runners) for (const line of hostLines(host)) lines.push(` ${line}`);
1744
2041
  lines.push('');
1745
2042
  }
1746
2043
  // Said out loud, because a machine left undialled is a runner somebody may be
@@ -1823,6 +2120,7 @@ export async function run(ctx) {
1823
2120
  const runners = caps.hosts.filter((h) => h.reachable);
1824
2121
  if (runners.length > 0) {
1825
2122
  say(paint.grey(` machines it can already reach: ${runners.map((h) => h.name).join(', ')}`));
2123
+ for (const host of runners) for (const line of hostLines(host)) say(paint.grey(` ${line}`));
1826
2124
  }
1827
2125
  const undialled = caps.hosts.filter((h) => h.how.startsWith('not dialled'));
1828
2126
  if (undialled.length > 0) {