staysfixed 0.11.1 → 0.13.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 (57) hide show
  1. package/CHANGELOG.md +108 -2
  2. package/README.md +77 -19
  3. package/docs/design-v2.md +8 -7
  4. package/docs/getting-started.md +5 -3
  5. package/docs/guards.md +18 -0
  6. package/docs/how-v2-works.md +43 -10
  7. package/docs/mcp.md +6 -4
  8. package/docs/settings.md +11 -2
  9. package/package.json +1 -1
  10. package/src/cli/approve.js +4 -1
  11. package/src/cli/flake.js +4 -1
  12. package/src/cli/mark.js +5 -1
  13. package/src/cli/status.js +53 -1
  14. package/src/cli/trace.js +27 -2
  15. package/src/core/config.js +136 -25
  16. package/src/core/stop-tree.js +109 -0
  17. package/src/drive/browser.js +20 -31
  18. package/src/drive/page.js +74 -2
  19. package/src/guard/api.js +14 -9
  20. package/src/types.js +1 -1
  21. package/src/v2/adapters/android.js +220 -11
  22. package/src/v2/adapters/child.js +15 -17
  23. package/src/v2/adapters/contract.js +122 -1
  24. package/src/v2/adapters/extension.js +1988 -0
  25. package/src/v2/adapters/http.js +152 -30
  26. package/src/v2/adapters/ios-driver.js +95 -12
  27. package/src/v2/adapters/ios.js +220 -10
  28. package/src/v2/adapters/isolate.js +169 -14
  29. package/src/v2/adapters/linux-driver.js +1028 -0
  30. package/src/v2/adapters/linux.js +1324 -0
  31. package/src/v2/adapters/macos-driver.js +913 -0
  32. package/src/v2/adapters/macos.js +1374 -0
  33. package/src/v2/adapters/process.js +72 -8
  34. package/src/v2/adapters/source.js +254 -7
  35. package/src/v2/adapters/web.js +69 -19
  36. package/src/v2/browsers.js +145 -25
  37. package/src/v2/cause.js +46 -5
  38. package/src/v2/check.js +465 -47
  39. package/src/v2/cli.js +21 -1
  40. package/src/v2/coverage.js +556 -19
  41. package/src/v2/detect.js +742 -42
  42. package/src/v2/doctor.js +125 -18
  43. package/src/v2/escalate.js +57 -11
  44. package/src/v2/init.js +574 -23
  45. package/src/v2/journeys/answers-probe.js +376 -0
  46. package/src/v2/journeys/from-exports.js +456 -0
  47. package/src/v2/journeys/from-suite.js +9 -1
  48. package/src/v2/journeys/index.js +3 -3
  49. package/src/v2/journeys/record-session.js +839 -0
  50. package/src/v2/journeys/record.js +12 -0
  51. package/src/v2/mcp/tools.js +193 -27
  52. package/src/v2/observation.js +145 -0
  53. package/src/v2/run.js +133 -9
  54. package/src/v2/selfcheck.js +297 -11
  55. package/src/v2/store.js +16 -1
  56. package/src/v2/types.js +1 -1
  57. package/src/v2/watch/events.js +6 -0
@@ -26,6 +26,30 @@
26
26
  * never on screen during a run.
27
27
  * - COUNTERS AND PICTURES, coarse and last.
28
28
  *
29
+ * IS A PAIRED RUN POSSIBLE HERE? YES, AND IT HAS NOW BEEN MEASURED.
30
+ *
31
+ * A paired run means the old build is put back on this machine and walked minutes before the
32
+ * new one, so nothing that drifted in between — the weather, a dependency, the clock — can be
33
+ * mistaken for somebody's change. On a phone that means one device, two builds one after the
34
+ * other, and the device put back in between. Until 2026-08-31 this was not offered, because
35
+ * nobody had ever checked whether the device really does come back to the same place.
36
+ *
37
+ * It has been checked. On an Apple Silicon Mac, on 2026-08-31, against Terminal Deck's own
38
+ * iPhone app (0.15.0, build 2608221311) on a simulator this adapter made for itself — an
39
+ * iPhone 17 Pro on iOS 27.0 — ONE build was walked ten times, with the device put back
40
+ * between every walk exactly the way it is put back between two builds: the app and
41
+ * everything it had written removed, and every permission it had been granted taken back.
42
+ *
43
+ * Five pairs. 725 addresses in each walk, 215 of them read out of the RUNNING app and the
44
+ * rest out of the bundle and the source. 725 of 725 agreed, in all five pairs — 3,625
45
+ * comparisons and not one disagreement. The Mac was carrying a load average of about 500 at
46
+ * the time, which makes that the harsher version of the result rather than the flattering
47
+ * one.
48
+ *
49
+ * So a paired iOS run is offered. What actually limits it is not the simulator — it is
50
+ * getting hold of the OLD build's app bundle, because a `.app` is a build output and a
51
+ * checkout of the old commit does not contain one. See `prepare` and `ios.reference`.
52
+ *
29
53
  * WHAT IT CANNOT SEE, and these are not hedges.
30
54
  *
31
55
  * - A REAL iPHONE. Nothing here touches a device somebody is holding. A paired run means
@@ -101,6 +125,111 @@ import {
101
125
  /** Everything prepared, per build, so `run` can be called many times without re-installing. */
102
126
  const ready = new Map();
103
127
 
128
+ /**
129
+ * Which app bundle each half of a comparison was walked from, remembered across the run.
130
+ *
131
+ * It lives out here rather than on a prepared build because the engine prepares one build,
132
+ * walks one journey against it and throws it away before the next: nothing kept on a
133
+ * prepared build survives long enough to notice that the old build and the new build were
134
+ * the same folder on disk.
135
+ *
136
+ * And that is the thing worth noticing. `ios.app` in the settings is usually an absolute
137
+ * path — Xcode writes into DerivedData, which is nowhere near the project — and an absolute
138
+ * path does not move when the old commit is checked out somewhere else. So both halves of a
139
+ * paired run would read the SAME bundle, find nothing different, and the run would say
140
+ * "nothing that worked has changed" about a comparison that never took place. That is the
141
+ * one failure this tool exists to prevent, arriving through the front door. Anything caught
142
+ * here is reported as a hole on every journey; see `run`.
143
+ *
144
+ * Keyed by role — 'candidate' or 'reference' — and emptied by `teardown`.
145
+ *
146
+ * @type {Map<string, string>}
147
+ */
148
+ const walkedFrom = new Map();
149
+
150
+ /**
151
+ * The build ids that turned out to be the other half of the comparison, and the sentence
152
+ * that says so.
153
+ *
154
+ * Separate from `ready` above because `ready` only holds builds that reached a simulator,
155
+ * and the warning has to survive a build that did not: a reference half that failed to
156
+ * prepare AND was the same bundle as the candidate is two pieces of bad news, and losing the
157
+ * second one is how a comparison comes back green for the wrong reason.
158
+ *
159
+ * @type {Map<string, string>}
160
+ */
161
+ const sameBundleFor = new Map();
162
+
163
+ /**
164
+ * Which bundle this really is: the path with the links and the `..`s taken out.
165
+ *
166
+ * A symlink, a `./` or a `..` must not be able to make one bundle look like two, because one
167
+ * bundle looking like two is the whole failure being guarded against here.
168
+ *
169
+ * @param {string} appPath
170
+ * @returns {Promise<string>}
171
+ */
172
+ async function bundleIdentity(appPath) {
173
+ try {
174
+ return await fsp.realpath(appPath);
175
+ } catch {
176
+ // A path that will not resolve is still worth remembering exactly as it was typed. The
177
+ // question below is whether the two halves agree, not whether the bundle is there.
178
+ return appPath;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Did this half of the comparison read the same app bundle as the other half?
184
+ *
185
+ * Returns the sentence to put in front of a reader, or null when the two halves really are
186
+ * two different bundles.
187
+ *
188
+ * WHAT IT DOES NOT CATCH, said here rather than left to be discovered: two different paths
189
+ * holding the same build. Somebody whose release script copies today's build to
190
+ * `builds/latest/App.app` and points `reference` at it is comparing one build against itself
191
+ * with two names, and nothing here notices. That was left alone on purpose — two different
192
+ * bundles are usually two builds somebody produced on purpose, and refusing them on a guess
193
+ * would block real comparisons to prevent an unusual one.
194
+ *
195
+ * @param {'reference'|'candidate'} role
196
+ * @param {string} mine
197
+ * @returns {string|null}
198
+ */
199
+ function sameBundleAsTheOtherHalf(role, mine) {
200
+ // Only ever asked about the OLD build's half, and that is not squeamishness — it is the
201
+ // only answer that stays the same from one journey to the next. The engine prepares a
202
+ // build, walks ONE journey against it and throws it away, and it always does the new build
203
+ // first: new-run-a, new-run-b, old-run-a, old-run-b, then the same four again for the next
204
+ // journey. So from the second journey onwards the new build's half would find the previous
205
+ // journey's old half sitting in the map and flag itself as well — the warning would be
206
+ // absent on the first journey and doubled on every one after it, which reads like a bug in
207
+ // the tool rather than a fact about the run.
208
+ if (role !== 'reference') return null;
209
+ const other = walkedFrom.get('candidate');
210
+ if (!other || other !== mine) return null;
211
+ return `Both halves of this comparison were walked from the same app bundle: ${mine}. Nothing in it is older or newer than anything else in it, so no difference between the two builds could possibly show up.`;
212
+ }
213
+
214
+ /**
215
+ * Where a kept copy of the OLD build's app bundle lives, if the settings name one.
216
+ *
217
+ * Two spellings, because both read naturally and neither is worth an argument:
218
+ * `{"reference": "builds/0.14.0/YourApp.app"}` and
219
+ * `{"reference": {"app": "builds/0.14.0/YourApp.app"}}`. A relative path is resolved against
220
+ * whatever `findAppBundle` is given, which for the reference half is the checkout of the old
221
+ * commit — so a project that DOES commit a simulator build can leave this out entirely.
222
+ *
223
+ * @param {Record<string, any>} config
224
+ * @returns {string|undefined}
225
+ */
226
+ export function referenceBundle(config) {
227
+ const said = config?.reference;
228
+ if (typeof said === 'string' && said.trim() !== '') return said;
229
+ if (said && typeof said === 'object' && typeof said.app === 'string' && said.app.trim() !== '') return said.app;
230
+ return undefined;
231
+ }
232
+
104
233
  // ---------------------------------------------------------------------------
105
234
  // Reading the doors out of the source
106
235
  // ---------------------------------------------------------------------------
@@ -542,6 +671,13 @@ export const iosAdapter = defineAdapter({
542
671
  /** @type {string[]} */
543
672
  const notes = [];
544
673
 
674
+ // What was MEASURED goes on before the machine is asked, so it survives every early
675
+ // return below. These notes are what `doctor` prints and what an agent reads, and on a
676
+ // machine that is not a Mac they were dropped entirely — so somebody planning where to
677
+ // run their checks was told only "not here", never what a Mac would actually do or what
678
+ // it would need from them. A fact that exists only in a comment is a fact nobody sees.
679
+ notes.push('Putting the device back really does put it back, and that is measured rather than assumed. On 2026-08-31 one build was walked ten times on an iOS 27.0 simulator with the app removed and every permission taken back between walks: 725 of 725 addresses agreed in all five pairs — 3,625 comparisons, no disagreements. So a paired run is offered here. What it needs from you is a copy of the OLD build\'s app bundle, because a .app is a build output and a checkout of the old commit does not contain one: name it with {"reference": "path/to/TheOld.app"} under "ios" in the settings.');
680
+
545
681
  const machine = await readMachine();
546
682
  if (!machine.isMac) {
547
683
  return {
@@ -667,23 +803,61 @@ export const iosAdapter = defineAdapter({
667
803
  const scratch = path.join(ctx.scratchDir, `ios-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
668
804
  await fsp.mkdir(scratch, { recursive: true });
669
805
 
806
+ // Worked out a few lines below, and captured here so that even a build which could not
807
+ // be got ready still says it. A reference half that failed AND was the same bundle as the
808
+ // candidate is two separate pieces of bad news, and the second one is the one that would
809
+ // otherwise be lost — the run would report "could not be prepared", somebody would fix
810
+ // that, and the comparison would come back green for the wrong reason.
811
+ /** @type {string|null} */
812
+ let sameBundle = null;
813
+
670
814
  /** @param {string} why */
671
815
  const notReady = (why) => ({
672
816
  build,
673
817
  root: scratch,
674
818
  ready: false,
675
- why,
819
+ why: sameBundle ? `${why} ${sameBundle}` : why,
820
+ ...(build.role === 'reference' ? { facts: { paired: sameBundle === null } } : {}),
676
821
  dispose: async () => {
822
+ sameBundleFor.delete(build.id);
677
823
  await fsp.rm(scratch, { recursive: true, force: true });
678
824
  },
679
825
  });
680
826
 
827
+ // WHICH bundle this half of the comparison walks.
828
+ //
829
+ // For the build you have, that is whatever the settings point at. For the build you were
830
+ // happy with it is different, and the difference is the whole of paired mode on a phone:
831
+ // the engine hands over a checkout of the old commit, and a `.app` is a BUILD OUTPUT that
832
+ // nobody commits, so a checkout of the old commit contains no app at all. `ios.reference`
833
+ // is where a kept copy of the old build's bundle goes, and it is looked at first for the
834
+ // reference half and never for the candidate.
835
+ const forThisHalf = build.role === 'reference' ? { ...config, app: referenceBundle(config) ?? config.app } : config;
836
+ const found = await findAppBundle(build.root, forThisHalf);
837
+ if (!found.ok) {
838
+ return notReady(
839
+ found.why +
840
+ (build.role === 'reference'
841
+ ? ' A paired run walks the OLD build here, and a .app is a build output that a repository does not commit — so a checkout of the old commit has no app in it. Keep a copy of each release\'s simulator build and point at it with {"reference": "path/to/TheOld.app"} under "ios" in the settings, and this becomes a real comparison. Without it this journey falls back to the record the old build left the last time it ran, which is weaker and says so.'
842
+ : ''),
843
+ );
844
+ }
845
+
846
+ // Two halves, one bundle. Worked out here, said on every journey — see `run`.
847
+ const mine = await bundleIdentity(found.appPath);
848
+ sameBundle = sameBundleAsTheOtherHalf(/** @type {'reference'|'candidate'} */ (build.role), mine);
849
+ walkedFrom.set(build.role, mine);
850
+ if (sameBundle) sameBundleFor.set(build.id, sameBundle);
851
+
852
+ // The machine is asked AFTER all of that, and the order is the point. Working out which
853
+ // bundle each half walks needs nothing but the filesystem, and it is true on every
854
+ // machine. Asking the machine first meant that anywhere an iPhone app cannot run — any
855
+ // Linux box, any Windows box, a Mac without Xcode — the reference half returned before
856
+ // the check and reported `paired: true`, which is a claim about a comparison it had not
857
+ // made. Caught by CI on Linux against a green Mac suite, 2026-08-31.
681
858
  const machine = await readMachine({ signal: ctx.signal });
682
859
  if (!machine.ok) return notReady(machine.why);
683
860
 
684
- const found = await findAppBundle(build.root, config);
685
- if (!found.ok) return notReady(found.why);
686
-
687
861
  const facts = await readAppBundle(found.appPath);
688
862
  if (!facts.ok) return notReady(facts.why);
689
863
 
@@ -720,7 +894,7 @@ export const iosAdapter = defineAdapter({
720
894
  build,
721
895
  root: scratch,
722
896
  ready: true,
723
- why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}`,
897
+ why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}${sameBundle ? ` ${sameBundle}` : ''}`,
724
898
  facts: {
725
899
  device: device.device.name,
726
900
  udid: device.device.udid,
@@ -729,10 +903,15 @@ export const iosAdapter = defineAdapter({
729
903
  version: facts.version,
730
904
  readsMeaning: probe.ok,
731
905
  weBootedIt: device.device.weBootedIt,
906
+ app: found.appPath,
907
+ // Only ever set on the OLD build's half, because that is the half the question is
908
+ // about: was there really a second build here, or did both halves read one bundle.
909
+ ...(build.role === 'reference' ? { paired: sameBundle === null } : {}),
732
910
  },
733
911
  dispose: async () => {
734
912
  const kept = ready.get(build.id);
735
913
  ready.delete(build.id);
914
+ sameBundleFor.delete(build.id);
736
915
  if (kept?.device) await releaseDevice(kept.device, { signal: ctx.signal });
737
916
  await fsp.rm(scratch, { recursive: true, force: true });
738
917
  },
@@ -748,28 +927,53 @@ export const iosAdapter = defineAdapter({
748
927
  * @returns {Promise<Observation[]>}
749
928
  */
750
929
  async run(journey, build, ctx) {
930
+ // FIRST, IN FRONT OF EVERY OTHER ANSWER THIS FUNCTION CAN GIVE.
931
+ //
932
+ // Said on every journey rather than once at the start, which is the same rule the web
933
+ // adapter follows for an app read at a fixed address. A run that compared one bundle
934
+ // against itself finds no differences, and "no differences" is the sentence this whole
935
+ // tool is believed for.
936
+ //
937
+ // It has to come before the "was this build ever got ready" answer below and not after
938
+ // it, because a reference half can fail to prepare AND have been the same bundle as the
939
+ // candidate. Say only the first and somebody fixes the preparation, runs it again, and
940
+ // gets a clean comparison of one bundle against itself with nothing anywhere to say so.
941
+ const sameBundle = sameBundleFor.get(build.build.id);
942
+ /** @type {Observation[]} */
943
+ const sameBundleSaid = sameBundle
944
+ ? [notCovered({
945
+ channel: 'meaning',
946
+ path: joinPath('screen', journey.name, 'which build this was'),
947
+ reason: 'not supported here',
948
+ says:
949
+ `${sameBundle} A .app is a build output, so a checkout of the old commit does not contain one and the settings' own path was used for both halves. ` +
950
+ 'Keep a copy of the simulator build you shipped and name it with {"reference": "path/to/TheOld.app"} under "ios" in the settings, and this becomes a real comparison.',
951
+ })]
952
+ : [];
953
+
751
954
  const kept = ready.get(build.build.id);
752
955
  if (!kept) {
753
- return [notCovered({
956
+ return [...sameBundleSaid, notCovered({
754
957
  channel: 'meaning',
755
958
  path: joinPath('screen', journey.name, 'walked'),
756
959
  reason: 'not supported here',
757
- says: 'This build was never got ready, so nothing about it could be walked.',
960
+ says: `This build was never got ready, so nothing about it could be walked. ${build.why}`,
758
961
  })];
759
962
  }
760
963
  if (journey.skip) {
761
- return [notCovered({
964
+ return [...sameBundleSaid, notCovered({
762
965
  channel: 'meaning',
763
966
  path: joinPath('screen', journey.name, 'walked'),
764
967
  reason: 'missing tool',
765
968
  says: journey.skip,
766
969
  })];
767
970
  }
971
+
768
972
  if (journey.name === 'what-the-app-declares') {
769
- return declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? []);
973
+ return [...sameBundleSaid, ...declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? [])];
770
974
  }
771
975
 
772
- return walkObservations(journey, kept, ctx);
976
+ return [...sameBundleSaid, ...(await walkObservations(journey, kept, ctx))];
773
977
  },
774
978
 
775
979
  async teardown() {
@@ -777,6 +981,12 @@ export const iosAdapter = defineAdapter({
777
981
  if (kept?.device) await releaseDevice(kept.device);
778
982
  ready.delete(id);
779
983
  }
984
+ // One run's memory of which bundle each half was walked from. It must not survive into
985
+ // the next run in the same process — the MCP server and the watch panel both call
986
+ // check() more than once — or a second run would report the first run's bundles as its
987
+ // own.
988
+ walkedFrom.clear();
989
+ sameBundleFor.clear();
780
990
  },
781
991
  });
782
992
 
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import crypto from 'node:crypto';
33
+ import fs from 'node:fs';
33
34
  import fsp from 'node:fs/promises';
34
35
  import net from 'node:net';
35
36
  import os from 'node:os';
@@ -141,11 +142,20 @@ function announce(app) {
141
142
  */
142
143
  export function appNameFor(binary) {
143
144
  const text = String(binary ?? '');
144
- const parts = text.split(path.sep);
145
+ // Both separators, not just this machine's. A path is a fact about the machine it came
146
+ // from, and this one is asked about a Mac app bundle — so on Windows, where `path.sep` is a
147
+ // backslash, `/Applications/Widget.app/Contents/MacOS/Widget` did not split at all and the
148
+ // app was announced to the person's screen as "Widget" only by luck, or as the wrong name
149
+ // when the two differ. Measured on a real Windows 11 machine on 2026-08-31, where it
150
+ // answered "Electron" for an app called Terminal Deck.
151
+ const parts = text.split(/[\\/]/);
145
152
  for (let i = parts.length - 1; i >= 0; i -= 1) {
146
153
  if (parts[i].toLowerCase().endsWith('.app')) return parts[i].slice(0, -4);
147
154
  }
148
- const base = path.basename(text);
155
+ // The last part of the same split, rather than `path.basename`, so the whole function reads
156
+ // a path the same way from end to end. `path.basename` only knows this machine's separator,
157
+ // and half a function that understands both is worse than either.
158
+ const base = parts[parts.length - 1] ?? '';
149
159
  const dot = base.lastIndexOf('.');
150
160
  return dot > 0 ? base.slice(0, dot) : base;
151
161
  }
@@ -264,6 +274,52 @@ export async function takePort() {
264
274
  // Who is holding what
265
275
  // ---------------------------------------------------------------------------
266
276
 
277
+ /**
278
+ * The whole process table, on Windows: who is running, who started them, and with what.
279
+ *
280
+ * This is Windows' `ps -axo pid=,ppid=,command=`. There is no `ps` there, and the two callers
281
+ * below used to hand back an empty list rather than an answer — which reads exactly like
282
+ * "nothing is running", the most dangerous wrong answer either of them could give. PowerShell
283
+ * is on every Windows machine and answers all three columns in one call; JSON rather than a
284
+ * table, because a command line is full of spaces and quotes and columns cannot survive it.
285
+ *
286
+ * An unanswerable question still returns an empty list, because a sweep that cannot be done is
287
+ * not a reason to fail somebody's check — but it is now the rare case rather than every case.
288
+ *
289
+ * @returns {Promise<{pid: number, ppid: number, command: string}[]>}
290
+ */
291
+ async function windowsProcessTable() {
292
+ try {
293
+ const { stdout } = await execFileAsync(
294
+ 'powershell.exe',
295
+ [
296
+ '-NoProfile',
297
+ '-NonInteractive',
298
+ '-ExecutionPolicy',
299
+ 'Bypass',
300
+ '-Command',
301
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress -Depth 2',
302
+ ],
303
+ { timeout: 20_000, maxBuffer: 32 * 1024 * 1024, windowsHide: true },
304
+ );
305
+ const text = String(stdout).trim();
306
+ if (text === '') return [];
307
+ const parsed = JSON.parse(text);
308
+ // One process comes back as an object rather than a list of one, which is PowerShell being
309
+ // helpful in a way that would otherwise crash the loop below.
310
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
311
+ return rows
312
+ .map((row) => ({
313
+ pid: Number(row?.ProcessId ?? 0),
314
+ ppid: Number(row?.ParentProcessId ?? 0),
315
+ command: String(row?.CommandLine ?? ''),
316
+ }))
317
+ .filter((row) => Number.isInteger(row.pid) && row.pid > 0);
318
+ } catch {
319
+ return [];
320
+ }
321
+ }
322
+
267
323
  /**
268
324
  * Every process on this machine whose command line contains `marker`.
269
325
  *
@@ -276,6 +332,15 @@ export async function takePort() {
276
332
  */
277
333
  export async function whoIsUsing(marker) {
278
334
  if (!marker || marker.length < 8) return [];
335
+ // Windows has no `ps`, and asking it nothing at all was the same as answering "nobody is
336
+ // using this folder" — which is the one answer this function must never invent, because it
337
+ // is what the run trusts when it decides it is alone. Measured on a real Windows 11 machine
338
+ // on 2026-08-31: every call here failed silently and returned an empty list.
339
+ if (process.platform === 'win32') {
340
+ return (await windowsProcessTable())
341
+ .filter((row) => row.pid !== process.pid && row.command.includes(marker))
342
+ .map((row) => ({ pid: row.pid, command: row.command }));
343
+ }
279
344
  try {
280
345
  const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,command='], {
281
346
  timeout: 8000,
@@ -321,19 +386,29 @@ export async function descendantsOf(pids) {
321
386
  if (roots.length === 0) return [];
322
387
  /** @type {Map<number, number[]>} */
323
388
  const childrenOf = new Map();
324
- try {
325
- const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,ppid='], { timeout: 8000, maxBuffer: 8 * 1024 * 1024 });
326
- for (const line of stdout.split('\n')) {
327
- const match = line.trim().match(/^(\d+)\s+(\d+)$/);
328
- if (!match) continue;
329
- const pid = Number(match[1]);
330
- const parent = Number(match[2]);
331
- const list = childrenOf.get(parent) ?? [];
332
- list.push(pid);
333
- childrenOf.set(parent, list);
389
+ /** @param {number} pid @param {number} parent */
390
+ const note = (pid, parent) => {
391
+ const list = childrenOf.get(parent) ?? [];
392
+ list.push(pid);
393
+ childrenOf.set(parent, list);
394
+ };
395
+ // The same table, asked for in Windows' words. Returning nothing here meant the app's own
396
+ // children — the renderer processes a browser or an Electron app starts — were never found
397
+ // and never closed, so every check on Windows left a handful of them behind. Measured on a
398
+ // real Windows 11 machine on 2026-08-31.
399
+ if (process.platform === 'win32') {
400
+ for (const row of await windowsProcessTable()) note(row.pid, row.ppid);
401
+ } else {
402
+ try {
403
+ const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,ppid='], { timeout: 8000, maxBuffer: 8 * 1024 * 1024 });
404
+ for (const line of stdout.split('\n')) {
405
+ const match = line.trim().match(/^(\d+)\s+(\d+)$/);
406
+ if (!match) continue;
407
+ note(Number(match[1]), Number(match[2]));
408
+ }
409
+ } catch {
410
+ return [];
334
411
  }
335
- } catch {
336
- return [];
337
412
  }
338
413
  /** @type {Set<number>} */
339
414
  const found = new Set();
@@ -379,6 +454,21 @@ function refuseIfNotScratch(scratchDir, dir) {
379
454
  'A run always gets its own throwaway settings folder — never a real one.',
380
455
  );
381
456
  }
457
+ // Anything inside the machine's own temp folder is throwaway by definition, and the
458
+ // settings check below is skipped for it.
459
+ //
460
+ // This is not a loophole, it is the difference between the two operating systems. On
461
+ // Windows the temp folder lives INSIDE the settings folder — `C:\Users\me\AppData\Local\Temp`
462
+ // sits under `C:\Users\me\AppData` — so the rule "refuse anything under AppData" refused
463
+ // every scratch folder the tool makes for itself. Measured on a real Windows 11 machine on
464
+ // 2026-08-31: all 18 isolation cases failed with "that is where real settings live" about a
465
+ // folder the tool had just created for its own use, which means isolation, and therefore
466
+ // every check that opens an app, could never have worked on Windows at all.
467
+ //
468
+ // The guard itself is unchanged everywhere else, and is still the strict one: the folder
469
+ // has to be inside the scratch folder the engine handed us, and if it is not somewhere the
470
+ // operating system itself calls temporary, it may not be anywhere near real settings.
471
+ if (isUnderTemp(inside)) return;
382
472
  for (const real of [
383
473
  path.join(os.homedir(), 'Library', 'Application Support'),
384
474
  path.join(os.homedir(), '.config'),
@@ -390,6 +480,28 @@ function refuseIfNotScratch(scratchDir, dir) {
390
480
  }
391
481
  }
392
482
 
483
+ /**
484
+ * Is this path inside the folder the operating system itself hands out for throwaway files?
485
+ *
486
+ * Both spellings are compared, because Windows hands the same folder out under two names: the
487
+ * long one and the old eight-character one (`C:\Users\RUNNER~1\...`), and a path that came
488
+ * back from one call can be spelled the other way from the next.
489
+ *
490
+ * @param {string} resolvedWithSeparator An already-resolved path, ending in a separator.
491
+ * @returns {boolean}
492
+ */
493
+ function isUnderTemp(resolvedWithSeparator) {
494
+ const temp = os.tmpdir();
495
+ /** @type {string[]} */
496
+ const spellings = [temp];
497
+ try {
498
+ spellings.push(fs.realpathSync.native(temp));
499
+ } catch {
500
+ // No second spelling available, which is the ordinary case everywhere but Windows.
501
+ }
502
+ return spellings.some((t) => resolvedWithSeparator.startsWith(path.resolve(t) + path.sep));
503
+ }
504
+
393
505
  /**
394
506
  * The flags that make one desktop app run alone, and paint the same way twice.
395
507
  *
@@ -423,6 +535,41 @@ export function isolationArgs(isolation, opts = {}) {
423
535
  ];
424
536
  }
425
537
 
538
+ /**
539
+ * The Windows half of "its own settings folder".
540
+ *
541
+ * Everything in the list above is POSIX — HOME, TMPDIR, the XDG folders — and a Windows
542
+ * program reads none of it. It reads USERPROFILE, APPDATA and LOCALAPPDATA. So on Windows the
543
+ * promise at the very top of this file was quietly not being kept: the run got its own
544
+ * folders, and the app carried on writing into the person's real ones. Found on 2026-08-31,
545
+ * the first day anything in this file had ever run on Windows.
546
+ *
547
+ * The second group is not isolation, it is the machine. The environment handed to a child here
548
+ * REPLACES the child's own rather than adding to it, and these are the variables a Windows
549
+ * program is entitled to assume are there — SystemRoot above all, which is where it looks for
550
+ * the libraries that open a socket. `node` and `npm` were measured starting perfectly well
551
+ * without them on 2026-08-31, so this is not a fault anybody has hit; it is a hole left open
552
+ * that a real desktop app is much more likely to fall into than a command-line tool is. They
553
+ * are copied from this process rather than invented, because they describe the machine and
554
+ * not the run.
555
+ *
556
+ * @param {string} homeDir The throwaway home this run was given.
557
+ * @returns {Record<string, string>}
558
+ */
559
+ function windowsIsolationEnv(homeDir) {
560
+ /** @type {Record<string, string>} */
561
+ const env = {
562
+ USERPROFILE: homeDir,
563
+ APPDATA: path.join(homeDir, 'AppData', 'Roaming'),
564
+ LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
565
+ };
566
+ for (const name of ['SystemRoot', 'windir', 'SystemDrive', 'COMSPEC', 'PATHEXT', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE']) {
567
+ const value = process.env[name];
568
+ if (value) env[name] = value;
569
+ }
570
+ return env;
571
+ }
572
+
426
573
  /**
427
574
  * Set one run up: its own folders, its own ports, its own identity.
428
575
  *
@@ -458,6 +605,13 @@ export async function reserveIsolation(opts) {
458
605
  for (const folder of [userDataDir, homeDir, tmpDir, cacheDir, crashDir]) {
459
606
  await fsp.mkdir(folder, { recursive: true });
460
607
  }
608
+ // The two folders a Windows program expects to find already there. It is handed a home of
609
+ // its own below, and a home with no AppData in it is not one any Windows app has ever seen.
610
+ if (process.platform === 'win32') {
611
+ for (const folder of [path.join(homeDir, 'AppData', 'Roaming'), path.join(homeDir, 'AppData', 'Local')]) {
612
+ await fsp.mkdir(folder, { recursive: true });
613
+ }
614
+ }
461
615
 
462
616
  const debugPort = await takePort();
463
617
  const inspectPort = await takePort();
@@ -516,6 +670,7 @@ export async function reserveIsolation(opts) {
516
670
  // Nothing being checked should be phoning home about itself.
517
671
  ELECTRON_NO_ATTACH_CONSOLE: '1',
518
672
  ELECTRON_ENABLE_LOGGING: '1',
673
+ ...(process.platform === 'win32' ? windowsIsolationEnv(homeDir) : {}),
519
674
  ...identityEnv,
520
675
  ...opts.env,
521
676
  },