pi-ui-extend 1.0.41 → 1.0.45

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 (236) hide show
  1. package/README.md +50 -13
  2. package/dist/app/app.d.ts +13 -0
  3. package/dist/app/app.js +158 -8
  4. package/dist/app/cli/install.js +2 -2
  5. package/dist/app/commands/command-controller.d.ts +4 -0
  6. package/dist/app/commands/command-controller.js +11 -0
  7. package/dist/app/commands/command-git-actions.d.ts +25 -0
  8. package/dist/app/commands/command-git-actions.js +381 -0
  9. package/dist/app/commands/command-host.d.ts +4 -0
  10. package/dist/app/commands/command-host.js +5 -2
  11. package/dist/app/commands/command-model-actions.d.ts +1 -0
  12. package/dist/app/commands/command-model-actions.js +57 -35
  13. package/dist/app/commands/command-navigation-actions.d.ts +1 -0
  14. package/dist/app/commands/command-navigation-actions.js +16 -3
  15. package/dist/app/commands/command-registry.d.ts +2 -0
  16. package/dist/app/commands/command-registry.js +15 -1
  17. package/dist/app/commands/command-session-actions.js +9 -3
  18. package/dist/app/commands/reload-context-inventory.d.ts +11 -0
  19. package/dist/app/commands/reload-context-inventory.js +70 -0
  20. package/dist/app/extensions/extension-actions-controller.d.ts +1 -0
  21. package/dist/app/extensions/extension-actions-controller.js +6 -0
  22. package/dist/app/extensions/subagent-catalog-state.d.ts +9 -0
  23. package/dist/app/extensions/subagent-catalog-state.js +23 -0
  24. package/dist/app/input/autocomplete-controller.js +61 -34
  25. package/dist/app/input/input-action-controller.d.ts +2 -0
  26. package/dist/app/input/input-action-controller.js +8 -1
  27. package/dist/app/input/input-controller.d.ts +2 -1
  28. package/dist/app/input/input-controller.js +7 -2
  29. package/dist/app/input/prompt-enhancer-controller.js +53 -35
  30. package/dist/app/input/voice-controller.d.ts +41 -45
  31. package/dist/app/input/voice-controller.js +351 -419
  32. package/dist/app/model/model-usage-controller.js +17 -7
  33. package/dist/app/model/model-usage-status.d.ts +4 -1
  34. package/dist/app/model/model-usage-status.js +270 -46
  35. package/dist/app/popup/menu-items-controller.d.ts +13 -3
  36. package/dist/app/popup/menu-items-controller.js +37 -21
  37. package/dist/app/popup/popup-action-controller.d.ts +12 -2
  38. package/dist/app/popup/popup-action-controller.js +77 -24
  39. package/dist/app/popup/popup-menu-controller.d.ts +36 -14
  40. package/dist/app/popup/popup-menu-controller.js +239 -69
  41. package/dist/app/rendering/dcp-stats.d.ts +9 -4
  42. package/dist/app/rendering/dcp-stats.js +40 -425
  43. package/dist/app/rendering/editor-panels.js +10 -4
  44. package/dist/app/rendering/popup-menu-renderer.d.ts +3 -5
  45. package/dist/app/rendering/popup-menu-renderer.js +42 -37
  46. package/dist/app/rendering/render-controller.js +23 -2
  47. package/dist/app/rendering/status-line-renderer.d.ts +5 -0
  48. package/dist/app/rendering/status-line-renderer.js +41 -5
  49. package/dist/app/rendering/tab-line-renderer.js +26 -20
  50. package/dist/app/runtime.d.ts +12 -1
  51. package/dist/app/runtime.js +120 -12
  52. package/dist/app/screen/mouse-controller.d.ts +2 -0
  53. package/dist/app/screen/mouse-controller.js +17 -7
  54. package/dist/app/screen/status-controller.d.ts +4 -0
  55. package/dist/app/screen/status-controller.js +5 -0
  56. package/dist/app/session/lazy-session-manager.js +34 -0
  57. package/dist/app/session/session-event-controller.d.ts +1 -0
  58. package/dist/app/session/session-event-controller.js +10 -1
  59. package/dist/app/session/session-history.d.ts +1 -0
  60. package/dist/app/session/session-history.js +12 -1
  61. package/dist/app/session/session-lifecycle-controller.d.ts +7 -1
  62. package/dist/app/session/session-lifecycle-controller.js +16 -1
  63. package/dist/app/session/tabs-controller.d.ts +26 -1
  64. package/dist/app/session/tabs-controller.js +454 -147
  65. package/dist/app/subagents/subagents-files.js +60 -1
  66. package/dist/app/subagents/subagents-model.d.ts +1 -0
  67. package/dist/app/subagents/subagents-model.js +18 -2
  68. package/dist/app/subagents/subagents-widget-controller.d.ts +1 -0
  69. package/dist/app/subagents/subagents-widget-controller.js +6 -0
  70. package/dist/app/types.d.ts +16 -1
  71. package/dist/app/workspace/workspace-actions-controller.js +10 -2
  72. package/dist/app/workspace/workspace-undo.d.ts +1 -0
  73. package/dist/app/workspace/workspace-undo.js +1 -0
  74. package/dist/bundled-extensions/question/index.js +9 -1
  75. package/dist/bundled-extensions/question/remote.d.ts +4 -0
  76. package/dist/bundled-extensions/question/remote.js +33 -0
  77. package/dist/bundled-extensions/telegram-connector/bot.d.ts +43 -0
  78. package/dist/bundled-extensions/telegram-connector/bot.js +166 -0
  79. package/dist/bundled-extensions/telegram-connector/config.d.ts +8 -0
  80. package/dist/bundled-extensions/telegram-connector/config.js +87 -0
  81. package/dist/bundled-extensions/telegram-connector/coordinator.d.ts +66 -0
  82. package/dist/bundled-extensions/telegram-connector/coordinator.js +413 -0
  83. package/dist/bundled-extensions/telegram-connector/index.d.ts +3 -0
  84. package/dist/bundled-extensions/telegram-connector/index.js +195 -0
  85. package/dist/bundled-extensions/terminal-bell/index.d.ts +0 -8
  86. package/dist/bundled-extensions/terminal-bell/index.js +0 -76
  87. package/dist/bundled-extensions/workspace-undo/index.d.ts +26 -0
  88. package/dist/bundled-extensions/workspace-undo/index.js +191 -0
  89. package/dist/config.d.ts +21 -2
  90. package/dist/config.js +159 -32
  91. package/dist/default-pix-config.js +25 -5
  92. package/dist/schemas/index.d.ts +1 -0
  93. package/dist/schemas/index.js +1 -0
  94. package/dist/schemas/pi-tools-suite-schema.d.ts +88 -62
  95. package/dist/schemas/pi-tools-suite-schema.js +54 -83
  96. package/dist/schemas/pix-schema.d.ts +19 -2
  97. package/dist/schemas/pix-schema.js +47 -5
  98. package/dist/schemas/tasks-schema.d.ts +18 -0
  99. package/dist/schemas/tasks-schema.js +41 -0
  100. package/docs/concurrency.md +9 -1
  101. package/docs/desktop-mvp.md +22 -6
  102. package/docs/desktop-task-manager.md +148 -84
  103. package/docs/release.md +30 -6
  104. package/external/pi-tools-suite/README.md +197 -84
  105. package/external/pi-tools-suite/docs/evals.md +1 -1
  106. package/external/pi-tools-suite/docs/session-recovery.md +47 -14
  107. package/external/pi-tools-suite/docs/subagent-model-pools.md +48 -30
  108. package/external/pi-tools-suite/docs/ui-qa-subagent.md +440 -0
  109. package/external/pi-tools-suite/package.json +6 -1
  110. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +2 -1
  111. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +9 -3
  112. package/external/pi-tools-suite/src/antigravity-auth/headers.ts +41 -3
  113. package/external/pi-tools-suite/src/antigravity-auth/models.ts +94 -21
  114. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +5 -17
  115. package/external/pi-tools-suite/src/antigravity-auth/payload.ts +72 -7
  116. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +13 -1
  117. package/external/pi-tools-suite/src/async-subagents/agents/frontier-review.md +23 -0
  118. package/external/pi-tools-suite/src/async-subagents/agents/implement.md +1 -1
  119. package/external/pi-tools-suite/src/async-subagents/agents/presets.jsonc +16 -0
  120. package/external/pi-tools-suite/src/async-subagents/agents/research.md +5 -3
  121. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/browser.mjs +346 -0
  122. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/desktop.mjs +1038 -0
  123. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/tui.mjs +759 -0
  124. package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/scripts/browser-qa-runner.mjs +32 -23
  125. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/chrome-devtools/chrome-devtools-provider.mjs +895 -0
  126. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/linux/linux-atspi.py +494 -0
  127. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/macos/macos-accessibility.swift +1085 -0
  128. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/bridge-client.mjs +50 -0
  129. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/native-terminal-host.mjs +501 -0
  130. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/windows/windows-uia.ps1 +454 -0
  131. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-auth.md +114 -0
  132. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-chrome-devtools.md +91 -0
  133. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-playwright.md +145 -0
  134. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser.md +82 -0
  135. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-linux.md +26 -0
  136. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-macos.md +29 -0
  137. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-windows.md +23 -0
  138. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop.md +54 -0
  139. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-native-terminal.md +59 -0
  140. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-pty.md +37 -0
  141. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui.md +60 -0
  142. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/scripts/ui-qa-runner.mjs +535 -0
  143. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa.md +75 -0
  144. package/external/pi-tools-suite/src/async-subagents/commands.ts +15 -71
  145. package/external/pi-tools-suite/src/async-subagents/core/activity.ts +33 -0
  146. package/external/pi-tools-suite/src/async-subagents/core/agent-catalog.ts +5 -4
  147. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
  148. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +8 -5
  149. package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +16 -2
  150. package/external/pi-tools-suite/src/async-subagents/core/config.ts +160 -327
  151. package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +2 -1
  152. package/external/pi-tools-suite/src/async-subagents/core/registry.ts +158 -33
  153. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +21 -5
  154. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +40 -26
  155. package/external/pi-tools-suite/src/async-subagents/core/state.ts +40 -0
  156. package/external/pi-tools-suite/src/async-subagents/core/types.ts +7 -0
  157. package/external/pi-tools-suite/src/async-subagents/core/ultrawork-auto.ts +51 -45
  158. package/external/pi-tools-suite/src/async-subagents/index.ts +69 -5
  159. package/external/pi-tools-suite/src/async-subagents/lib.ts +16 -9
  160. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +8 -5
  161. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +5 -4
  162. package/external/pi-tools-suite/src/async-subagents/types.ts +1 -0
  163. package/external/pi-tools-suite/src/coding-discipline/index.ts +90 -59
  164. package/external/pi-tools-suite/src/config.ts +55 -1
  165. package/external/pi-tools-suite/src/context-gateway/accounting-log.ts +282 -0
  166. package/external/pi-tools-suite/src/context-gateway/config.ts +77 -3
  167. package/external/pi-tools-suite/src/context-gateway/efficiency.ts +414 -0
  168. package/external/pi-tools-suite/src/context-gateway/enforcement.ts +222 -0
  169. package/external/pi-tools-suite/src/context-gateway/index.ts +206 -19
  170. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +7 -7
  171. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +101 -28
  172. package/external/pi-tools-suite/src/context-gateway/types.ts +16 -3
  173. package/external/pi-tools-suite/src/context-inventory.ts +99 -0
  174. package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +39 -4
  175. package/external/pi-tools-suite/src/dcp/auto-compress.ts +128 -49
  176. package/external/pi-tools-suite/src/dcp/commands.ts +32 -156
  177. package/external/pi-tools-suite/src/dcp/compress-tool.ts +177 -46
  178. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +6 -58
  179. package/external/pi-tools-suite/src/dcp/compression-preview.ts +9 -0
  180. package/external/pi-tools-suite/src/dcp/compression-progress.ts +22 -0
  181. package/external/pi-tools-suite/src/dcp/config.ts +114 -17
  182. package/external/pi-tools-suite/src/dcp/conversation-index.ts +36 -9
  183. package/external/pi-tools-suite/src/dcp/diagnostics.ts +41 -0
  184. package/external/pi-tools-suite/src/dcp/fresh-tool-results.ts +38 -0
  185. package/external/pi-tools-suite/src/dcp/index.ts +338 -66
  186. package/external/pi-tools-suite/src/dcp/journal.ts +126 -4
  187. package/external/pi-tools-suite/src/dcp/progress-controller.ts +2 -1
  188. package/external/pi-tools-suite/src/dcp/prompts.ts +83 -192
  189. package/external/pi-tools-suite/src/dcp/protected-continuity.ts +175 -0
  190. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +93 -3
  191. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +6 -10
  192. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +36 -33
  193. package/external/pi-tools-suite/src/dcp/pruner-tools.ts +6 -3
  194. package/external/pi-tools-suite/src/dcp/pruner.ts +1 -0
  195. package/external/pi-tools-suite/src/dcp/routine-pressure.ts +86 -0
  196. package/external/pi-tools-suite/src/dcp/state.ts +9 -0
  197. package/external/pi-tools-suite/src/dcp/statistics.d.ts +8 -0
  198. package/external/pi-tools-suite/src/dcp/statistics.js +156 -0
  199. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +78 -22
  200. package/external/pi-tools-suite/src/index.ts +7 -1
  201. package/external/pi-tools-suite/src/lib/project.ts +36 -1
  202. package/external/pi-tools-suite/src/model-tools/index.ts +10 -7
  203. package/external/pi-tools-suite/src/repo-discovery/index.ts +304 -4
  204. package/external/pi-tools-suite/src/resource-registry/index.ts +2551 -0
  205. package/external/pi-tools-suite/src/session-recovery/index.ts +17 -0
  206. package/external/pi-tools-suite/src/shell-command-policy.ts +219 -0
  207. package/external/pi-tools-suite/src/todo/index.ts +21 -0
  208. package/external/pi-tools-suite/src/todo/todo.ts +19 -1
  209. package/external/pi-tools-suite/src/tool-descriptions.ts +47 -19
  210. package/package.json +10 -9
  211. package/schemas/pi-tools-suite.json +466 -287
  212. package/schemas/pix.json +129 -11
  213. package/schemas/tasks.json +131 -0
  214. package/skills/simplify/SKILL.md +33 -5
  215. package/docs/desktop-markdown-media.md +0 -77
  216. package/external/pi-tools-suite/docs/browser-qa-subagent.md +0 -177
  217. package/external/pi-tools-suite/docs/dcp-emergency-current-turn.md +0 -102
  218. package/external/pi-tools-suite/src/async-subagents/agents/browser-qa.md +0 -598
  219. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +0 -54
  220. package/external/pi-tools-suite/src/skill-installer/index.ts +0 -333
  221. package/skills/playwright-cli/SKILL.md +0 -420
  222. package/skills/playwright-cli/references/element-attributes.md +0 -23
  223. package/skills/playwright-cli/references/playwright-tests.md +0 -50
  224. package/skills/playwright-cli/references/request-mocking.md +0 -87
  225. package/skills/playwright-cli/references/running-code.md +0 -241
  226. package/skills/playwright-cli/references/session-management.md +0 -273
  227. package/skills/playwright-cli/references/spec-driven-testing.md +0 -311
  228. package/skills/playwright-cli/references/storage-state.md +0 -290
  229. package/skills/playwright-cli/references/test-generation.md +0 -142
  230. package/skills/playwright-cli/references/tracing.md +0 -154
  231. package/skills/playwright-cli/references/video-recording.md +0 -147
  232. package/skills/spec-lite/SKILL.md +0 -140
  233. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-auth.example.jsonc +0 -0
  234. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-flow.example.jsonc +0 -0
  235. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.LICENSE +0 -0
  236. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.mjs +0 -0
@@ -0,0 +1,1038 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const MACOS_DRIVER_SOURCE = fileURLToPath(new URL("../drivers/macos/macos-accessibility.swift", import.meta.url));
8
+ const WINDOWS_DRIVER_SOURCE = fileURLToPath(new URL("../drivers/windows/windows-uia.ps1", import.meta.url));
9
+ const LINUX_DRIVER_SOURCE = fileURLToPath(new URL("../drivers/linux/linux-atspi.py", import.meta.url));
10
+ const MAX_CAPTURE_BYTES = 1024 * 1024;
11
+ const CLEANUP_TIMEOUT_MS = 5_000;
12
+ const SAFE_ENV = new Set(["CI", "NO_COLOR", "FORCE_COLOR", "LANG", "LC_ALL", "TERM", "TZ"]);
13
+ const SHELL_EXECUTABLES = new Set([
14
+ "sh", "bash", "zsh", "fish", "dash", "ksh", "cmd", "cmd.exe", "powershell", "powershell.exe",
15
+ "pwsh", "pwsh.exe", "osascript", "wscript", "wscript.exe", "cscript", "cscript.exe",
16
+ ]);
17
+ const ACTION_FIELDS = {
18
+ waitForWindow: new Set(["action", "timeoutMs"]),
19
+ activateWindow: new Set(["action", "timeoutMs"]),
20
+ snapshotAccessibility: new Set(["action", "name", "depth", "limit", "timeoutMs"]),
21
+ activate: new Set(["action", "selector", "timeoutMs"]),
22
+ setValue: new Set(["action", "selector", "value", "timeoutMs"]),
23
+ inputText: new Set(["action", "text", "timeoutMs"]),
24
+ pressKey: new Set(["action", "key", "modifiers", "timeoutMs"]),
25
+ waitForText: new Set(["action", "text", "timeoutMs"]),
26
+ assertText: new Set(["action", "text", "timeoutMs"]),
27
+ assertState: new Set(["action", "selector", "attribute", "equals", "timeoutMs"]),
28
+ screenshot: new Set(["action", "name", "timeoutMs"]),
29
+ capture: new Set(["action", "name", "depth", "limit", "timeoutMs"]),
30
+ };
31
+ const MAX_RECORD_DURATION_MS = 30_000;
32
+ // Worst case after SIGTERM: the helper may still be completing startup
33
+ // (activation plus shareable-content enumeration) before it can finalize the
34
+ // MP4; deadline clamping keeps this inside the runner's hard-exit grace.
35
+ const RECORD_FINALIZE_GRACE_MS = 15_000;
36
+ const RECORD_RUNNER_MARGIN_MS = 5_000;
37
+
38
+ export async function probeDesktopBackend(context = {}) {
39
+ const supportedCapabilities = ["launch", "attach", "dialogs", "boundedActions", "ownedCleanup"];
40
+ const platform = desktopPlatformContract(process.platform);
41
+ if (!platform.available) return { ...platform, supportedCapabilities };
42
+ const guideTopic = platform.platformDriver;
43
+ if (context.shallow) {
44
+ // Shallow probes are used only for non-selected backends. They describe the
45
+ // bundled platform driver without executing host-specific prerequisites or
46
+ // claiming permission/dependency-gated capabilities.
47
+ return {
48
+ available: true,
49
+ guideTopic,
50
+ platformDriver: platform.platformDriver,
51
+ supportedCapabilities,
52
+ missingCapabilities: [],
53
+ reason: platform.reason,
54
+ };
55
+ }
56
+ if (process.platform === "darwin") return { ...(await probeMacosDesktopBackend(context, supportedCapabilities)), guideTopic };
57
+ if (process.platform === "win32") return { ...(await probeWindowsDesktopBackend(context, supportedCapabilities)), guideTopic };
58
+ if (process.platform === "linux") return { ...(await probeLinuxDesktopBackend(context, supportedCapabilities)), guideTopic };
59
+ return { ...platform, supportedCapabilities };
60
+ }
61
+
62
+ async function probeMacosDesktopBackend(context, supportedCapabilities) {
63
+ try {
64
+ const helper = await ensureDesktopHelper(context);
65
+ const doctor = await runHelperProcess(helper, ["doctor"], {
66
+ cwd: context.projectRoot,
67
+ timeoutMs: remainingTimeout(context, 30_000),
68
+ });
69
+ if (doctor.code !== 0) throw new Error(doctor.stderr || `driver doctor exited ${doctor.code}`);
70
+ const accessibility = /(?:^|\n)accessibility=granted(?:\n|$)/.test(doctor.stdout);
71
+ const screenRecording = /(?:^|\n)screen_recording=granted(?:\n|$)/.test(doctor.stdout);
72
+ // windowVideo has a stricter producer than screenshots: it also needs the
73
+ // ScreenCaptureKit window-stream API (macOS 12.3+), reported by doctor.
74
+ const sck = /(?:^|\n)sck=available(?:\n|$)/.test(doctor.stdout);
75
+ const supported = [...supportedCapabilities];
76
+ const missing = [];
77
+ if (accessibility) supported.push("semanticAccessibility", "accessibilitySnapshot", "semanticActivation", "valueInput", "keyboardInput", "stateAssertions");
78
+ else missing.push("semanticAccessibility", "accessibilitySnapshot", "semanticActivation", "valueInput", "keyboardInput", "stateAssertions");
79
+ if (screenRecording) supported.push("windowScreenshot");
80
+ else missing.push("windowScreenshot");
81
+ if (screenRecording && sck) supported.push("windowVideo");
82
+ else missing.push("windowVideo");
83
+ return {
84
+ available: accessibility,
85
+ platformDriver: "macos-accessibility",
86
+ supportedCapabilities: supported,
87
+ missingCapabilities: missing,
88
+ reason: accessibility
89
+ ? "macOS Accessibility permission is granted for the bundled semantic driver"
90
+ : "macOS Accessibility permission is missing for the process that runs UI QA",
91
+ remediation: accessibility ? undefined : "grant Accessibility access to the terminal/Pi host in System Settings > Privacy & Security > Accessibility, then rerun",
92
+ details: { accessibility, screenRecording, sck },
93
+ };
94
+ } catch (error) {
95
+ return {
96
+ available: false,
97
+ platformDriver: "macos-accessibility",
98
+ supportedCapabilities,
99
+ missingCapabilities: ["semanticAccessibility", "accessibilitySnapshot", "windowScreenshot", "windowVideo"],
100
+ reason: `macOS accessibility driver is unavailable: ${safeReason(error)}`,
101
+ remediation: "install Apple Command Line Tools (xcrun/swiftc) or use a packaged build of the bundled macOS accessibility helper",
102
+ };
103
+ }
104
+ }
105
+
106
+ async function probeWindowsDesktopBackend(context, supportedCapabilities) {
107
+ try {
108
+ const helper = await ensureDesktopHelper(context);
109
+ const doctor = await runHelperProcess(helper, ["doctor"], {
110
+ cwd: context.projectRoot,
111
+ timeoutMs: remainingTimeout(context, 15_000),
112
+ });
113
+ if (doctor.code !== 0) throw new Error(doctor.stderr || `driver doctor exited ${doctor.code}`);
114
+ const uia = /(?:^|\n)uia=available(?:\n|$)/.test(doctor.stdout);
115
+ const screenshot = /(?:^|\n)screenshot=available(?:\n|$)/.test(doctor.stdout);
116
+ const supported = [...supportedCapabilities];
117
+ const missing = [];
118
+ if (uia) supported.push("semanticAccessibility", "accessibilitySnapshot", "semanticActivation", "valueInput", "keyboardInput", "stateAssertions");
119
+ else missing.push("semanticAccessibility", "accessibilitySnapshot", "semanticActivation", "valueInput", "keyboardInput", "stateAssertions");
120
+ if (screenshot) supported.push("windowScreenshot");
121
+ else missing.push("windowScreenshot");
122
+ missing.push("windowVideo");
123
+ return {
124
+ available: uia,
125
+ platformDriver: "windows-uia",
126
+ supportedCapabilities: supported,
127
+ missingCapabilities: missing,
128
+ reason: uia
129
+ ? "Windows UI Automation is available through the bundled trusted helper"
130
+ : "Windows UI Automation could not be initialized by the bundled trusted helper",
131
+ remediation: uia ? undefined : "enable Windows UI Automation for the current desktop session, or repair Windows PowerShell/.NET UIAutomation assemblies, then rerun",
132
+ details: { uia, screenshot, windowVideo: false },
133
+ };
134
+ } catch (error) {
135
+ return {
136
+ available: false,
137
+ platformDriver: "windows-uia",
138
+ supportedCapabilities,
139
+ missingCapabilities: ["semanticAccessibility", "accessibilitySnapshot", "semanticActivation", "valueInput", "keyboardInput", "stateAssertions", "windowScreenshot", "windowVideo"],
140
+ reason: `Windows UI Automation driver is unavailable: ${safeReason(error)}`,
141
+ remediation: "restore Windows PowerShell 5+ (or compatible pwsh) with .NET UIAutomation assemblies, then rerun UI QA",
142
+ };
143
+ }
144
+ }
145
+
146
+ async function probeLinuxDesktopBackend(context, supportedCapabilities) {
147
+ try {
148
+ const helper = await ensureDesktopHelper(context);
149
+ const doctor = await runHelperProcess(helper, ["doctor"], {
150
+ cwd: context.projectRoot,
151
+ timeoutMs: remainingTimeout(context, 15_000),
152
+ });
153
+ if (doctor.code !== 0) throw new Error(doctor.stderr || `driver doctor exited ${doctor.code}`);
154
+ const atspi = /(?:^|\n)atspi=available(?:\n|$)/.test(doctor.stdout);
155
+ const screenshot = /(?:^|\n)screenshot=available(?:\n|$)/.test(doctor.stdout);
156
+ const keyboard = /(?:^|\n)keyboard_input=available(?:\n|$)/.test(doctor.stdout);
157
+ const supported = [...supportedCapabilities];
158
+ const missing = [];
159
+ if (atspi) supported.push("semanticAccessibility", "accessibilitySnapshot", "semanticActivation", "valueInput", "stateAssertions");
160
+ else missing.push("semanticAccessibility", "accessibilitySnapshot", "semanticActivation", "valueInput", "stateAssertions");
161
+ if (keyboard) supported.push("keyboardInput");
162
+ else missing.push("keyboardInput");
163
+ if (screenshot) supported.push("windowScreenshot");
164
+ else missing.push("windowScreenshot");
165
+ missing.push("windowVideo");
166
+ return {
167
+ available: atspi,
168
+ platformDriver: "linux-at-spi",
169
+ supportedCapabilities: supported,
170
+ missingCapabilities: missing,
171
+ reason: atspi
172
+ ? "Linux AT-SPI is available through the bundled trusted helper"
173
+ : "Linux AT-SPI could not be initialized in the current graphical session",
174
+ remediation: atspi ? undefined : "install/enable Python 3 AT-SPI bindings (python3-pyatspi) and ensure the graphical session accessibility bus is available, then rerun",
175
+ details: { atspi, screenshot, keyboard, windowVideo: false },
176
+ };
177
+ } catch (error) {
178
+ return {
179
+ available: false,
180
+ platformDriver: "linux-at-spi",
181
+ supportedCapabilities,
182
+ missingCapabilities: ["semanticAccessibility", "accessibilitySnapshot", "semanticActivation", "valueInput", "keyboardInput", "stateAssertions", "windowScreenshot", "windowVideo"],
183
+ reason: `Linux AT-SPI driver is unavailable: ${safeReason(error)}`,
184
+ remediation: "install Python 3 plus AT-SPI bindings (python3-pyatspi); for keyboard input install xdotool, and for Wayland screenshots provide a supported screenshot utility such as gnome-screenshot",
185
+ };
186
+ }
187
+ }
188
+
189
+ export function desktopPlatformContract(platform) {
190
+ if (platform === "darwin") {
191
+ return {
192
+ available: true,
193
+ platformDriver: "macos-accessibility",
194
+ missingCapabilities: [],
195
+ reason: "macOS has a bundled Accessibility/CGWindow driver",
196
+ };
197
+ }
198
+ if (platform === "win32") {
199
+ return {
200
+ available: true,
201
+ platformDriver: "windows-uia",
202
+ missingCapabilities: [],
203
+ reason: "Windows has a bundled UI Automation helper",
204
+ };
205
+ }
206
+ if (platform === "linux") {
207
+ return {
208
+ available: true,
209
+ platformDriver: "linux-at-spi",
210
+ missingCapabilities: [],
211
+ reason: "Linux has a bundled AT-SPI helper with runtime dependency probing",
212
+ };
213
+ }
214
+ return {
215
+ available: false,
216
+ platformDriver: `${platform}-desktop-unsupported`,
217
+ missingCapabilities: ["semanticAccessibility", "accessibilitySnapshot", "windowScreenshot", "windowVideo"],
218
+ reason: `the bundled desktop accessibility driver is not implemented on ${platform}`,
219
+ remediation: "run UI QA on macOS, Windows, or Linux, or add a platform accessibility driver for this operating system",
220
+ };
221
+ }
222
+
223
+ export async function runDesktopBackend(context) {
224
+ const probe = await probeDesktopBackend(context);
225
+ if (!probe.available) return blocked(probe.reason, probe.remediation);
226
+ const flow = context.flow;
227
+ const application = validateApplication(flow.target.application, context.projectRoot);
228
+ const steps = validateSteps(flow.steps, probe);
229
+ const helper = await ensureDesktopHelper(context);
230
+ const assertions = [];
231
+ const observations = [];
232
+ const artifacts = emptyArtifacts();
233
+ let recorder;
234
+ let launched;
235
+ let ownedGuiPid;
236
+ let selector = application.selector;
237
+ let failure;
238
+ try {
239
+ if (application.launch) {
240
+ launched = launchApplication(application.launch, context);
241
+ selector = application.selector ?? launchedApplicationSelector(launched);
242
+ observations.push({ action: "launch", pid: launched.pid, argv0: application.launch.argv[0] });
243
+ if (process.platform === "win32") {
244
+ const output = await helperCall(helper, ["wait-window", ...selector, "--timeout", "15", "--print-pid"], context, 20_000);
245
+ const parsed = Number(output.trim());
246
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new Error("Windows UI Automation helper did not return the correlated GUI process id");
247
+ ownedGuiPid = parsed;
248
+ observations.push({ action: "correlateOwnedGui", pid: ownedGuiPid, ownerPid: launched.pid });
249
+ }
250
+ }
251
+ if (!selector) throw new Error("desktop target requires an app selector or a launch contract");
252
+ if (probe.supportedCapabilities.includes("windowVideo")) {
253
+ recorder = await startWindowVideoRecorder({ context, helper, selector, observations });
254
+ } else {
255
+ observations.push({
256
+ action: "windowVideo",
257
+ status: "unavailable",
258
+ reason: process.platform === "darwin"
259
+ ? "exact-window recording requires macOS 12.3+ and Screen Recording permission"
260
+ : `exact-window video recording is not implemented by the ${probe.platformDriver} driver`,
261
+ });
262
+ }
263
+ for (let index = 0; index < steps.length; index += 1) {
264
+ const step = steps[index];
265
+ context.progress("desktop_action_started", { index, action: step.action });
266
+ try {
267
+ await executeStep({ context, helper, selector, step, index, assertions, observations, artifacts });
268
+ context.progress("desktop_action_finished", { index, action: step.action });
269
+ } catch (error) {
270
+ failure = new Error(`step ${index + 1} (${step.action}) failed: ${safeReason(error)}`);
271
+ break;
272
+ }
273
+ }
274
+ if (!failure) {
275
+ await captureAccessibility({ context, helper, selector, name: "final", artifacts, depth: 10, limit: 100 }).catch((error) => {
276
+ observations.push({ action: "finalCapture", status: "unavailable", reason: safeReason(error) });
277
+ });
278
+ }
279
+ } catch (error) {
280
+ failure = error;
281
+ } finally {
282
+ // Automatic window video is best-effort evidence: recorders run
283
+ // concurrently with the remaining flow and are stopped and finalized
284
+ // here, and their outcome is always a structured observation that can
285
+ // never fail deterministic UI assertions.
286
+ await finalizeWindowRecorder(recorder, artifacts, observations);
287
+ if (failure && selector) {
288
+ await captureAccessibility({ context, helper, selector, name: "failure", artifacts, depth: 10, limit: 100 }).catch(() => {});
289
+ if (probe.supportedCapabilities.includes("windowScreenshot")) {
290
+ await captureScreenshot({ context, helper, selector, name: "failure", artifacts }).catch(() => {});
291
+ }
292
+ }
293
+ if (launched) await terminateOwnedProcessTree(launched, ownedGuiPid ? [ownedGuiPid] : []);
294
+ }
295
+ return {
296
+ status: failure ? "FAILED" : "PASSED",
297
+ ...(failure ? { reason: safeReason(failure) } : {}),
298
+ assertions,
299
+ observations,
300
+ artifacts,
301
+ };
302
+ }
303
+
304
+ export async function createWindowEvidenceController({ context, selector, observations, artifacts }) {
305
+ const helper = await ensureDesktopHelper(context);
306
+ const recorder = context.selection?.supportedCapabilities?.includes("windowVideo")
307
+ ? await startWindowVideoRecorder({ context, helper, selector, observations })
308
+ : null;
309
+ let finished = false;
310
+ return {
311
+ async capture(name, options = {}) {
312
+ const depth = boundedInt(options.depth, 10, 1, 20, "depth");
313
+ const limit = boundedInt(options.limit, 100, 1, 500, "limit");
314
+ await captureAccessibility({ context, helper, selector, name, artifacts, depth, limit });
315
+ if (probeHasScreenshot(context)) await captureScreenshot({ context, helper, selector, name, artifacts });
316
+ },
317
+ async finish() {
318
+ if (finished) return;
319
+ finished = true;
320
+ await finalizeWindowRecorder(recorder, artifacts, observations);
321
+ },
322
+ };
323
+ }
324
+
325
+ // Compatibility export for the original macOS-native-terminal integration.
326
+ export const createMacosWindowEvidenceController = createWindowEvidenceController;
327
+
328
+ async function finalizeWindowRecorder(recorder, artifacts, observations) {
329
+ if (!recorder) return;
330
+ const outcome = await recorder.stop().catch((error) => ({ name: recorder.name, status: "unavailable", reason: safeReason(error) }));
331
+ if (outcome.status === "recorded") {
332
+ artifacts.videos.push(outcome.artifact);
333
+ observations.push({ action: "windowVideo", name: outcome.name, status: "recorded", durationMs: outcome.durationMs, bytes: outcome.bytes });
334
+ } else {
335
+ observations.push({ action: "windowVideo", name: outcome.name, status: "unavailable", reason: outcome.reason });
336
+ }
337
+ }
338
+
339
+ async function executeStep(options) {
340
+ const { context, helper, selector, step, index, assertions, observations, artifacts } = options;
341
+ const timeoutMs = stepTimeout(step, context);
342
+ switch (step.action) {
343
+ case "waitForWindow":
344
+ await helperCall(helper, ["wait-window", ...selector, "--timeout", String(timeoutMs / 1000)], context, timeoutMs);
345
+ observations.push({ action: step.action, ready: true });
346
+ return;
347
+ case "activateWindow":
348
+ await helperCall(helper, ["focus", ...selector], context, timeoutMs);
349
+ observations.push({ action: step.action, focused: true });
350
+ return;
351
+ case "snapshotAccessibility":
352
+ await captureAccessibility({ context, helper, selector, name: desktopEvidenceName(step.name, index, "snapshot"), artifacts, depth: boundedInt(step.depth, 10, 1, 20, "depth"), limit: boundedInt(step.limit, 100, 1, 500, "limit"), timeoutMs });
353
+ return;
354
+ case "activate":
355
+ await helperCall(helper, ["click", ...selector, ...elementSelectorArgs(step.selector)], context, timeoutMs);
356
+ observations.push({ action: step.action, selector: publicSelector(step.selector) });
357
+ return;
358
+ case "setValue":
359
+ requireBoundedString(step.value, "setValue.value", 4096);
360
+ await helperCall(helper, ["set-value", ...selector, ...elementSelectorArgs(step.selector), "--value", step.value], context, timeoutMs);
361
+ observations.push({ action: step.action, selector: publicSelector(step.selector) });
362
+ return;
363
+ case "inputText":
364
+ requireBoundedString(step.text, "inputText.text", 4096);
365
+ await helperCall(helper, ["type", ...selector, "--text", step.text], context, timeoutMs);
366
+ observations.push({ action: step.action, characters: [...step.text].length });
367
+ return;
368
+ case "pressKey": {
369
+ const key = requireBoundedString(step.key, "pressKey.key", 32);
370
+ const args = ["key", ...selector, "--key", key];
371
+ if (step.modifiers !== undefined) {
372
+ if (!Array.isArray(step.modifiers) || step.modifiers.some((value) => !["cmd", "shift", "ctrl", "opt", "fn"].includes(value))) throw new Error("pressKey.modifiers contains an unsupported modifier");
373
+ args.push("--modifiers", step.modifiers.join(","));
374
+ }
375
+ await helperCall(helper, args, context, timeoutMs);
376
+ observations.push({ action: step.action, key, modifiers: step.modifiers ?? [] });
377
+ return;
378
+ }
379
+ case "waitForText": {
380
+ const text = requireBoundedString(step.text, "waitForText.text", 4096);
381
+ await waitFor(async () => (await inspect(helper, selector, context, timeoutMs)).includes(text), timeoutMs, `text ${JSON.stringify(text)} did not appear`);
382
+ observations.push({ action: step.action, text });
383
+ return;
384
+ }
385
+ case "assertText": {
386
+ const text = requireBoundedString(step.text, "assertText.text", 4096);
387
+ const snapshot = await inspect(helper, selector, context, timeoutMs);
388
+ const passed = snapshot.includes(text);
389
+ assertions.push({ action: step.action, expected: { visibleText: text }, passed });
390
+ if (!passed) throw new Error(`visible accessibility text does not contain ${JSON.stringify(text)}`);
391
+ return;
392
+ }
393
+ case "assertState": {
394
+ const attribute = requireBoundedString(step.attribute, "assertState.attribute", 32);
395
+ if (!["title", "description", "identifier", "help", "value", "enabled", "focused", "selected"].includes(attribute)) throw new Error(`unsupported accessibility state: ${attribute}`);
396
+ const described = await describe(helper, selector, step.selector, context, timeoutMs);
397
+ const actual = described.attributes?.[attribute];
398
+ const expected = String(step.equals);
399
+ const passed = actual === expected;
400
+ assertions.push({ action: step.action, selector: publicSelector(step.selector), attribute, expected, actual, passed });
401
+ if (!passed) throw new Error(`expected ${attribute}=${JSON.stringify(expected)}, observed ${JSON.stringify(actual)}`);
402
+ return;
403
+ }
404
+ case "screenshot":
405
+ await captureScreenshot({ context, helper, selector, name: desktopEvidenceName(step.name, index, "screenshot"), artifacts, timeoutMs });
406
+ return;
407
+ case "capture": {
408
+ const name = desktopEvidenceName(step.name, index, "capture");
409
+ await captureAccessibility({ context, helper, selector, name, artifacts, depth: boundedInt(step.depth, 10, 1, 20, "depth"), limit: boundedInt(step.limit, 100, 1, 500, "limit"), timeoutMs });
410
+ if (probeHasScreenshot(context)) await captureScreenshot({ context, helper, selector, name, artifacts, timeoutMs });
411
+ return;
412
+ }
413
+ default:
414
+ throw new Error(`unsupported desktop action: ${step.action}`);
415
+ }
416
+ }
417
+
418
+ function validateApplication(value, projectRoot) {
419
+ if (!isObject(value)) throw new Error("target.application must be an object");
420
+ const allowed = new Set(["pid", "name", "bundleId", "launch"]);
421
+ assertKnownFields(value, allowed, "target.application");
422
+ const selectors = [];
423
+ if (value.pid !== undefined) {
424
+ if (!Number.isInteger(value.pid) || value.pid <= 0) throw new Error("target.application.pid must be a positive integer");
425
+ selectors.push(["--pid", String(value.pid)]);
426
+ }
427
+ if (value.name !== undefined) selectors.push(["--app", requireBoundedString(value.name, "target.application.name", 200)]);
428
+ if (value.bundleId !== undefined) {
429
+ if (process.platform !== "darwin") throw new Error("target.application.bundleId is supported only by the macOS desktop driver");
430
+ selectors.push(["--bundle-id", requireBoundedString(value.bundleId, "target.application.bundleId", 200)]);
431
+ }
432
+ if (selectors.length > 1) throw new Error("target.application must use only one of pid, name, or bundleId");
433
+ const launch = value.launch === undefined ? undefined : validateLaunch(value.launch, projectRoot);
434
+ if (launch && selectors.length > 0) throw new Error("target.application.launch cannot be combined with pid, name, or bundleId");
435
+ if (!launch && selectors.length !== 1) throw new Error("target.application requires one selector when launch is absent");
436
+ return { selector: selectors[0], launch };
437
+ }
438
+
439
+ function validateLaunch(value, projectRoot) {
440
+ if (!isObject(value)) throw new Error("application.launch must be an object");
441
+ assertKnownFields(value, new Set(["argv", "cwd", "env"]), "application.launch");
442
+ if (!Array.isArray(value.argv) || value.argv.length < 1 || value.argv.length > 64) throw new Error("application.launch.argv must contain 1..64 strings");
443
+ const argv = value.argv.map((part, index) => requireBoundedString(part, `application.launch.argv[${index}]`, 4096));
444
+ if (SHELL_EXECUTABLES.has(path.basename(argv[0]).toLowerCase())) throw new Error("application launch cannot invoke a shell or scripting host");
445
+ const cwd = resolveProjectDirectory(projectRoot, value.cwd ?? ".");
446
+ validateLaunchContract(argv, cwd, projectRoot);
447
+ return { argv, cwd, env: validateEnv(value.env) };
448
+ }
449
+
450
+ function validateLaunchContract(argv, cwd, projectRoot) {
451
+ const executable = argv[0];
452
+ const base = path.basename(executable).toLowerCase();
453
+ if (path.isAbsolute(executable) || executable.includes("/") || executable.includes("\\")) {
454
+ if (!isProjectFile(projectRoot, path.resolve(cwd, executable), true)) throw new Error("desktop executable paths must resolve to a non-symlink executable inside the project");
455
+ return;
456
+ }
457
+ if (["node", "node.exe", "python", "python3", "python.exe", "ruby", "perl", "deno", "deno.exe"].includes(base)) {
458
+ const scriptIndex = base.startsWith("deno") ? (argv[1] === "run" ? 2 : -1) : 1;
459
+ if (scriptIndex < 1 || !argv[scriptIndex] || argv[scriptIndex].startsWith("-") || !isProjectFile(projectRoot, path.resolve(cwd, argv[scriptIndex]), false)) {
460
+ throw new Error(`${base} launch requires an explicit project-local script file`);
461
+ }
462
+ return;
463
+ }
464
+ if (base === "bun" || base === "bun.exe") {
465
+ if (argv[1] === "run" && /^[A-Za-z0-9:_-]+$/.test(argv[2] ?? "")) return;
466
+ if (isProjectFile(projectRoot, path.resolve(cwd, argv[1] ?? ""), false)) return;
467
+ throw new Error("bun launch requires a project-local script or `bun run <script>`");
468
+ }
469
+ if (["npm", "npm.cmd", "pnpm", "pnpm.cmd", "yarn", "yarn.cmd"].includes(base)) {
470
+ const offset = argv[1] === "run" ? 2 : 1;
471
+ if ((argv[1] === "run" || argv[1] === "start" || argv[1] === "test") && /^[A-Za-z0-9:_-]+$/.test(argv[offset] ?? "")) return;
472
+ throw new Error(`${base} launch must use a bounded run, start, or test script`);
473
+ }
474
+ if (["cargo", "go", "dotnet"].includes(base) && argv[1] === "run") return;
475
+ throw new Error("desktop launch executable must be project-local or a bounded package/runtime launch contract");
476
+ }
477
+
478
+ function isProjectFile(projectRoot, value, requireExecutable) {
479
+ if (!isInside(projectRoot, value) || !fs.existsSync(value)) return false;
480
+ let current = projectRoot;
481
+ for (const part of path.relative(projectRoot, value).split(path.sep).filter(Boolean)) {
482
+ current = path.join(current, part);
483
+ if (fs.lstatSync(current).isSymbolicLink()) return false;
484
+ }
485
+ const stat = fs.statSync(value);
486
+ return stat.isFile() && (!requireExecutable || process.platform === "win32" || (stat.mode & 0o111) !== 0);
487
+ }
488
+
489
+ function validateEnv(value) {
490
+ if (value === undefined) return {};
491
+ if (!isObject(value)) throw new Error("launch.env must be an object");
492
+ const result = {};
493
+ for (const [key, raw] of Object.entries(value)) {
494
+ if (!SAFE_ENV.has(key) && !key.startsWith("PI_UI_QA_")) throw new Error(`launch.env key is not allowed: ${key}`);
495
+ result[key] = requireBoundedString(raw, `launch.env.${key}`, 4096);
496
+ }
497
+ return result;
498
+ }
499
+
500
+ function validateSteps(steps, probe) {
501
+ if (!Array.isArray(steps) || steps.length < 1 || steps.length > 100) throw new Error("desktop flow requires 1..100 steps");
502
+ return steps.map((step, index) => {
503
+ if (!isObject(step) || typeof step.action !== "string" || !ACTION_FIELDS[step.action]) throw new Error(`desktop step ${index + 1} has an unsupported action`);
504
+ assertKnownFields(step, ACTION_FIELDS[step.action], `desktop step ${index + 1}`);
505
+ return validateDesktopStepCapabilities(step, probe.supportedCapabilities ?? []);
506
+ });
507
+ }
508
+
509
+ // Exported for focused unit tests: capability gating must be honest about what
510
+ // the probed producer actually supports on this host.
511
+ export function validateDesktopStepCapabilities(step, supportedCapabilities) {
512
+ if ((step.action === "screenshot" || step.action === "capture") && !supportedCapabilities.includes("windowScreenshot")) {
513
+ throw new Error("desktop flow requires windowScreenshot, but the selected desktop driver does not currently provide it");
514
+ }
515
+ const required = {
516
+ activate: "semanticActivation",
517
+ setValue: "valueInput",
518
+ inputText: "keyboardInput",
519
+ pressKey: "keyboardInput",
520
+ assertState: "stateAssertions",
521
+ }[step.action];
522
+ if (required && !supportedCapabilities.includes(required)) {
523
+ throw new Error(`desktop action ${step.action} requires ${required}, but the selected desktop driver does not currently provide it`);
524
+ }
525
+ return step;
526
+ }
527
+
528
+ function launchApplication(launch, context) {
529
+ const [file, ...args] = launch.argv;
530
+ const logPath = path.join(context.evidenceDir, "application.log");
531
+ const child = spawn(file, args, {
532
+ cwd: launch.cwd,
533
+ env: launchEnvironment(launch.env),
534
+ stdio: ["ignore", "pipe", "pipe"],
535
+ detached: process.platform !== "win32",
536
+ windowsHide: true,
537
+ });
538
+ const stream = fs.createWriteStream(logPath, { flags: "wx", mode: 0o600 });
539
+ let bytes = 0;
540
+ const write = (chunk) => {
541
+ if (bytes >= MAX_CAPTURE_BYTES) return;
542
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
543
+ const kept = buffer.subarray(0, MAX_CAPTURE_BYTES - bytes);
544
+ bytes += kept.length;
545
+ stream.write(kept);
546
+ };
547
+ child.stdout?.on("data", write);
548
+ child.stderr?.on("data", write);
549
+ child.once("error", (error) => write(`\nlaunch error: ${safeReason(error)}\n`));
550
+ child.once("close", () => stream.end());
551
+ if (!child.pid) throw new Error("desktop launch did not return a process id");
552
+ return child;
553
+ }
554
+
555
+ // A detached POSIX launch is a session/process-group leader. Package managers
556
+ // commonly exit after handing the actual GUI process to a descendant, so PID
557
+ // lookup of that wrapper is not a reliable application selector. The group is
558
+ // both the launch ownership boundary and the selector boundary on POSIX.
559
+ // Windows uses the owned launcher PID as a process-tree root; the UIA helper
560
+ // resolves descendants instead of mistaking a short-lived package wrapper for
561
+ // the actual GUI process.
562
+ export function launchedApplicationSelector(launched, platform = process.platform) {
563
+ if (!launched?.pid) throw new Error("desktop launch did not return a process id");
564
+ return platform === "win32" ? ["--owner-pid", String(launched.pid)] : ["--pgid", String(launched.pid)];
565
+ }
566
+
567
+ function launchEnvironment(extra) {
568
+ const result = {};
569
+ for (const key of ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "SystemRoot", "WINDIR", "USERPROFILE"]) {
570
+ if (process.env[key] !== undefined) result[key] = process.env[key];
571
+ }
572
+ return { ...result, ...extra, PI_UI_QA: "1" };
573
+ }
574
+
575
+ async function ensureDesktopHelper(context) {
576
+ if (process.platform === "darwin") return ensureMacosHelper(context);
577
+ if (process.platform === "win32") {
578
+ if (!fs.existsSync(WINDOWS_DRIVER_SOURCE)) throw new Error("bundled Windows UI Automation helper source is missing");
579
+ const powershell = findWindowsPowerShell();
580
+ if (!powershell) throw new Error("Windows PowerShell/pwsh is unavailable");
581
+ return {
582
+ file: powershell,
583
+ // Process-scoped execution-policy bypass is limited to this immutable
584
+ // bundled helper path; it does not modify user/machine policy or execute
585
+ // model-provided script text.
586
+ argsPrefix: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", WINDOWS_DRIVER_SOURCE],
587
+ label: "Windows UI Automation helper",
588
+ supportsWindowVideo: false,
589
+ };
590
+ }
591
+ if (process.platform === "linux") {
592
+ if (!fs.existsSync(LINUX_DRIVER_SOURCE)) throw new Error("bundled Linux AT-SPI helper source is missing");
593
+ const python = findExecutable(["python3", "python"]);
594
+ if (!python) throw new Error("Python 3 is unavailable");
595
+ return {
596
+ file: python,
597
+ argsPrefix: [LINUX_DRIVER_SOURCE],
598
+ label: "Linux AT-SPI helper",
599
+ supportsWindowVideo: false,
600
+ };
601
+ }
602
+ throw new Error(`desktop helper is unavailable on ${process.platform}`);
603
+ }
604
+
605
+ async function ensureMacosHelper(context) {
606
+ if (!fs.existsSync(MACOS_DRIVER_SOURCE)) throw new Error("bundled macOS accessibility helper source is missing");
607
+ const helpersDir = path.join(context.workspaceDir, "helpers");
608
+ createPrivateDirectory(context.workspaceDir, helpersDir);
609
+ const digest = createHash("sha256").update(fs.readFileSync(MACOS_DRIVER_SOURCE)).digest("hex").slice(0, 16);
610
+ const binary = path.join(helpersDir, `macos-accessibility-${digest}`);
611
+ if (fs.existsSync(binary)) {
612
+ const stat = fs.lstatSync(binary);
613
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("macOS helper cache entry is not a regular file");
614
+ return { file: binary, argsPrefix: [], label: "macOS accessibility helper", supportsWindowVideo: true };
615
+ }
616
+ const temporary = `${binary}.${process.pid}.tmp`;
617
+ const result = await runOwnedProcess("xcrun", ["swiftc", "-O", MACOS_DRIVER_SOURCE, "-o", temporary], {
618
+ cwd: context.workspaceDir,
619
+ timeoutMs: remainingTimeout(context, 60_000),
620
+ });
621
+ if (result.code !== 0) throw new Error(result.stderr || `swiftc exited ${result.code}`);
622
+ fs.chmodSync(temporary, 0o700);
623
+ fs.renameSync(temporary, binary);
624
+ return { file: binary, argsPrefix: [], label: "macOS accessibility helper", supportsWindowVideo: true };
625
+ }
626
+
627
+ function runHelperProcess(helper, args, options) {
628
+ return runOwnedProcess(helper.file, [...helper.argsPrefix, ...args], options);
629
+ }
630
+
631
+ async function helperCall(helper, args, context, timeoutMs) {
632
+ const result = await runHelperProcess(helper, args, { cwd: context.projectRoot, timeoutMs: Math.min(timeoutMs, remainingTimeout(context, timeoutMs)) });
633
+ if (result.code !== 0) throw new Error(result.stderr || `${helper.label} exited ${result.code}`);
634
+ return result.stdout.trim();
635
+ }
636
+
637
+ async function inspect(helper, selector, context, timeoutMs, depth = 12, limit = 300) {
638
+ return helperCall(helper, ["inspect", ...selector, "--depth", String(depth), "--limit", String(limit), "--all"], context, timeoutMs);
639
+ }
640
+
641
+ async function describe(helper, selector, elementSelector, context, timeoutMs) {
642
+ const output = await helperCall(helper, ["describe", ...selector, ...elementSelectorArgs(elementSelector)], context, timeoutMs);
643
+ try { return JSON.parse(output); } catch { throw new Error("desktop accessibility driver returned an invalid semantic record"); }
644
+ }
645
+
646
+ async function captureAccessibility({ context, helper, selector, name, artifacts, depth, limit, timeoutMs = 15_000 }) {
647
+ const output = await inspect(helper, selector, context, timeoutMs, depth, limit);
648
+ const filename = `${name}.accessibility.txt`;
649
+ writePrivate(path.join(context.evidenceDir, filename), `${output}\n`);
650
+ artifacts.accessibilitySnapshots.push(context.artifact(filename, "accessibility snapshot"));
651
+ }
652
+
653
+ async function captureScreenshot({ context, helper, selector, name, artifacts, timeoutMs = 15_000 }) {
654
+ const filename = `${name}.png`;
655
+ const target = path.join(context.evidenceDir, filename);
656
+ await helperCall(helper, ["screenshot", ...selector, "--out", target], context, timeoutMs);
657
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) throw new Error("desktop screenshot was not created");
658
+ artifacts.screenshots.push(context.artifact(filename, "screenshot"));
659
+ }
660
+
661
+ // Starts the helper's ScreenCaptureKit exact-window producer. The recorder only
662
+ // starts after the selector resolves and the target window is ready, records
663
+ // concurrently with the remaining flow, enforces a helper-side 30s hard cap,
664
+ // and is stopped/finalized by the backend's finally block.
665
+ async function startWindowVideoRecorder({ context, helper, selector, observations }) {
666
+ const name = "window";
667
+ try {
668
+ if (!helper.supportsWindowVideo) throw new Error(`${helper.label} does not implement exact-window video recording`);
669
+ const remaining = Math.max(0, context.deadline - Date.now());
670
+ const durationMs = Math.min(MAX_RECORD_DURATION_MS, remaining - RECORD_RUNNER_MARGIN_MS);
671
+ if (durationMs < 500) {
672
+ observations.push({ action: "windowVideo", name, status: "unavailable", reason: "not enough runner time remains to record window video" });
673
+ return null;
674
+ }
675
+ await helperCall(helper, ["wait-window", ...selector, "--timeout", "10"], context, 15_000);
676
+ const filename = `${name}.mp4`;
677
+ const target = path.join(context.evidenceDir, filename);
678
+ const child = spawn(helper.file, [...helper.argsPrefix, "record-window", ...selector, "--out", target, "--duration", String(durationMs / 1000)], {
679
+ cwd: context.projectRoot,
680
+ env: launchEnvironment({}),
681
+ stdio: ["ignore", "pipe", "pipe"],
682
+ detached: process.platform !== "win32",
683
+ windowsHide: true,
684
+ });
685
+ if (!child.pid) throw new Error("window recorder did not return a process id");
686
+ const recorder = windowVideoRecorderHandle({ context, child, name, durationMs, filename, target });
687
+ if (!await recorder.ready(Math.min(15_000, remainingTimeout(context, 15_000)))) {
688
+ const outcome = await recorder.stop();
689
+ cleanupPartialVideo(target);
690
+ observations.push({ action: "windowVideo", name, status: "unavailable", reason: outcome.reason ?? "window recorder exited before capture started" });
691
+ return null;
692
+ }
693
+ observations.push({ action: "windowVideo", name, status: "started", durationMs, pid: child.pid });
694
+ return recorder;
695
+ } catch (error) {
696
+ observations.push({ action: "windowVideo", name, status: "unavailable", reason: safeReason(error) });
697
+ return null;
698
+ }
699
+ }
700
+
701
+ function windowVideoRecorderHandle({ context, child, name, durationMs, filename, target }) {
702
+ let stdout = Buffer.alloc(0);
703
+ let stderr = Buffer.alloc(0);
704
+ let exitCode = null;
705
+ let ready = false;
706
+ let startedAt;
707
+ let resolveReady;
708
+ const readyPromise = new Promise((resolve) => { resolveReady = resolve; });
709
+ const exited = new Promise((resolve) => {
710
+ child.stdout?.on("data", (chunk) => {
711
+ stdout = appendBounded(stdout, chunk);
712
+ if (!ready && stdout.toString("utf8").includes("recording-started\n")) {
713
+ ready = true;
714
+ startedAt = Date.now();
715
+ resolveReady(true);
716
+ }
717
+ });
718
+ child.stderr?.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
719
+ child.once("error", (error) => {
720
+ exitCode = 1;
721
+ stderr = appendBounded(stderr, Buffer.from(safeReason(error)));
722
+ resolveReady(false);
723
+ resolve();
724
+ });
725
+ child.once("close", (code) => { exitCode = code ?? 1; resolveReady(false); resolve(); });
726
+ });
727
+ return {
728
+ name,
729
+ durationMs,
730
+ async ready(timeoutMs) {
731
+ return Promise.race([readyPromise, sleep(timeoutMs).then(() => false)]);
732
+ },
733
+ // Signals the helper (SIGTERM finalizes and publishes the MP4), waits a
734
+ // bounded grace, then escalates to SIGKILL and cleans partial files.
735
+ // Never throws: every outcome is structured.
736
+ async stop() {
737
+ const graceMs = Math.max(1_000, Math.min(RECORD_FINALIZE_GRACE_MS, context.deadline - Date.now()));
738
+ if (exitCode === null) {
739
+ killOwnedPid(child.pid, "SIGTERM");
740
+ const finished = await Promise.race([exited.then(() => true), sleep(graceMs).then(() => false)]);
741
+ if (!finished) {
742
+ killOwnedPid(child.pid, "SIGKILL");
743
+ await Promise.race([exited, sleep(2_000)]);
744
+ cleanupPartialVideo(target);
745
+ return { name, status: "unavailable", reason: "the window recorder did not finalize within its grace period" };
746
+ }
747
+ }
748
+ if (exitCode !== 0) {
749
+ cleanupPartialVideo(target);
750
+ const reason = stderr.toString("utf8").trim();
751
+ return { name, status: "unavailable", reason: reason || `window recorder exited with code ${exitCode}` };
752
+ }
753
+ const stat = fs.existsSync(target) ? fs.statSync(target) : null;
754
+ if (!stat?.isFile() || stat.size < 1) {
755
+ cleanupPartialVideo(target);
756
+ return { name, status: "unavailable", reason: stdout.toString("utf8").trim() || "the window recorder produced no video file" };
757
+ }
758
+ if (process.platform !== "win32") fs.chmodSync(target, 0o600);
759
+ const recordedDurationMs = startedAt === undefined ? 0 : Math.min(durationMs, Math.max(0, Date.now() - startedAt));
760
+ return {
761
+ name,
762
+ status: "recorded",
763
+ durationMs: recordedDurationMs,
764
+ bytes: stat.size,
765
+ artifact: { ...context.artifact(filename, "window video"), format: "mp4", durationMs: recordedDurationMs },
766
+ };
767
+ },
768
+ };
769
+ }
770
+
771
+ function appendBounded(current, chunk) {
772
+ return Buffer.concat([current, Buffer.from(chunk)]).subarray(0, MAX_CAPTURE_BYTES);
773
+ }
774
+
775
+ function cleanupPartialVideo(target) {
776
+ try { fs.rmSync(target, { force: true }); } catch { /* best effort */ }
777
+ try {
778
+ const prefix = `${path.basename(target)}.`;
779
+ for (const entry of fs.readdirSync(path.dirname(target))) {
780
+ if (entry.startsWith(prefix) && entry.endsWith(".tmp")) fs.rmSync(path.join(path.dirname(target), entry), { force: true });
781
+ }
782
+ } catch { /* best effort */ }
783
+ }
784
+
785
+ function runOwnedProcess(file, args, options) {
786
+ return new Promise((resolve, reject) => {
787
+ const child = spawn(file, args, {
788
+ cwd: options.cwd,
789
+ env: launchEnvironment({}),
790
+ stdio: ["ignore", "pipe", "pipe"],
791
+ detached: process.platform !== "win32",
792
+ windowsHide: true,
793
+ });
794
+ let stdout = Buffer.alloc(0);
795
+ let stderr = Buffer.alloc(0);
796
+ let settled = false;
797
+ const append = (current, chunk) => Buffer.concat([current, Buffer.from(chunk)]).subarray(0, MAX_CAPTURE_BYTES);
798
+ child.stdout?.on("data", (chunk) => { stdout = append(stdout, chunk); });
799
+ child.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
800
+ const timer = setTimeout(() => {
801
+ if (settled) return;
802
+ settled = true;
803
+ killOwnedPid(child.pid, "SIGKILL");
804
+ reject(new Error(`desktop driver stage timed out after ${options.timeoutMs} ms`));
805
+ }, Math.max(1, options.timeoutMs));
806
+ child.once("error", (error) => {
807
+ if (settled) return;
808
+ settled = true;
809
+ clearTimeout(timer);
810
+ reject(error);
811
+ });
812
+ child.once("close", (code) => {
813
+ if (settled) return;
814
+ settled = true;
815
+ clearTimeout(timer);
816
+ resolve({ code: code ?? 1, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8").trim() });
817
+ });
818
+ });
819
+ }
820
+
821
+ export async function terminateOwnedProcessTree(child, correlatedPids = []) {
822
+ if (!child.pid) return;
823
+ if (process.platform === "win32") {
824
+ const ownedRoots = [...new Set([child.pid, ...correlatedPids].filter((pid) => Number.isInteger(pid) && pid > 0))];
825
+ for (const pid of ownedRoots) {
826
+ await runOwnedProcess("taskkill.exe", ["/PID", String(pid), "/T"], {
827
+ cwd: process.cwd(),
828
+ timeoutMs: CLEANUP_TIMEOUT_MS,
829
+ }).catch(() => {});
830
+ if (isPidAlive(pid)) {
831
+ await runOwnedProcess("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
832
+ cwd: process.cwd(),
833
+ timeoutMs: CLEANUP_TIMEOUT_MS,
834
+ }).catch(() => {});
835
+ }
836
+ }
837
+ return;
838
+ }
839
+
840
+ // Do not use the wrapper's liveness as the cleanup condition: npm/pnpm and
841
+ // similar launchers can exit while their GUI child remains in this owned
842
+ // group. SIGTERM gives the group a bounded graceful shutdown; SIGKILL is
843
+ // sent to the same group if any member survives the grace period.
844
+ const pgid = child.pid;
845
+ killOwnedProcessGroup(pgid, "SIGTERM");
846
+ const deadline = Date.now() + CLEANUP_TIMEOUT_MS;
847
+ while (isOwnedProcessGroupAlive(pgid) && Date.now() < deadline) await sleep(Math.min(50, deadline - Date.now()));
848
+ if (isOwnedProcessGroupAlive(pgid)) killOwnedProcessGroup(pgid, "SIGKILL");
849
+ }
850
+
851
+ function killOwnedProcessGroup(pgid, signal) {
852
+ if (!Number.isInteger(pgid) || pgid <= 0) return;
853
+ try { process.kill(-pgid, signal); } catch { /* group already exited */ }
854
+ }
855
+
856
+ function isOwnedProcessGroupAlive(pgid) {
857
+ if (!Number.isInteger(pgid) || pgid <= 0) return false;
858
+ try { process.kill(-pgid, 0); return true; } catch { return false; }
859
+ }
860
+
861
+ function killOwnedPid(pid, signal) {
862
+ if (!Number.isInteger(pid) || pid <= 0) return;
863
+ if (process.platform !== "win32") {
864
+ try { process.kill(-pid, signal); return; } catch { /* process may not be a group leader */ }
865
+ }
866
+ try { process.kill(pid, signal); } catch { /* already exited */ }
867
+ }
868
+
869
+ function isPidAlive(pid) {
870
+ try { process.kill(pid, 0); return true; } catch { return false; }
871
+ }
872
+
873
+ function elementSelectorArgs(value) {
874
+ if (!isObject(value)) throw new Error("semantic action requires selector");
875
+ assertKnownFields(value, new Set(["path", "name", "role", "occurrence"]), "selector");
876
+ const args = [];
877
+ if (value.path !== undefined) args.push("--path", requireBoundedString(value.path, "selector.path", 200));
878
+ else if (value.name !== undefined) args.push("--match", requireBoundedString(value.name, "selector.name", 500));
879
+ else throw new Error("selector requires path or name");
880
+ if (value.role !== undefined) args.push("--role", requireBoundedString(value.role, "selector.role", 100));
881
+ if (value.occurrence !== undefined) {
882
+ if (!Number.isInteger(value.occurrence) || value.occurrence < 1 || value.occurrence > 100) throw new Error("selector.occurrence must be 1..100");
883
+ args.push("--occurrence", String(value.occurrence));
884
+ }
885
+ return args;
886
+ }
887
+
888
+ function publicSelector(value) {
889
+ return isObject(value) ? value : {};
890
+ }
891
+
892
+ function resolveProjectDirectory(projectRoot, value) {
893
+ const resolved = path.resolve(projectRoot, requireBoundedString(value, "launch.cwd", 4096));
894
+ if (!isInside(projectRoot, resolved)) throw new Error("launch.cwd must stay inside the project");
895
+ if (!fs.existsSync(resolved)) throw new Error("launch.cwd does not exist");
896
+ let current = projectRoot;
897
+ for (const part of path.relative(projectRoot, resolved).split(path.sep).filter(Boolean)) {
898
+ current = path.join(current, part);
899
+ if (fs.lstatSync(current).isSymbolicLink()) throw new Error("launch.cwd must not contain symlinks");
900
+ }
901
+ const real = fs.realpathSync(resolved);
902
+ if (!isInside(projectRoot, real) || !fs.statSync(real).isDirectory()) throw new Error("launch.cwd must be a project-local directory");
903
+ return real;
904
+ }
905
+
906
+ function createPrivateDirectory(root, target) {
907
+ if (!isInside(root, target)) throw new Error("helper cache must stay inside the UI QA workspace");
908
+ if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true, mode: 0o700 });
909
+ const stat = fs.lstatSync(target);
910
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("helper cache must be a real directory");
911
+ if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("helper cache must use mode 0700");
912
+ }
913
+
914
+ function stepTimeout(step, context) {
915
+ const value = step.timeoutMs ?? Math.min(15_000, context.stageTimeoutMs);
916
+ if (!Number.isFinite(value) || value < 50 || value > 30_000) throw new Error("step timeoutMs must be between 50 and 30000");
917
+ return Math.min(Math.round(value), remainingTimeout(context, Math.round(value)));
918
+ }
919
+
920
+ function remainingTimeout(context, cap) {
921
+ const remaining = context.deadline - Date.now();
922
+ if (remaining <= 0) throw new Error("UI QA runner deadline expired");
923
+ return Math.max(1, Math.min(cap, remaining));
924
+ }
925
+
926
+ async function waitFor(check, timeoutMs, reason) {
927
+ const deadline = Date.now() + timeoutMs;
928
+ while (Date.now() < deadline) {
929
+ if (await check()) return;
930
+ await sleep(100);
931
+ }
932
+ throw new Error(reason);
933
+ }
934
+
935
+ function sleep(ms) {
936
+ return new Promise((resolve) => setTimeout(resolve, Math.max(1, ms)));
937
+ }
938
+
939
+ function boundedInt(value, fallback, min, max, name) {
940
+ if (value === undefined) return fallback;
941
+ if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${name} must be an integer from ${min} to ${max}`);
942
+ return value;
943
+ }
944
+
945
+ function captureName(value, fallback) {
946
+ if (value === undefined) return fallback;
947
+ const name = requireBoundedString(value, "capture name", 80);
948
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error("capture name must contain only letters, digits, underscore, or dash");
949
+ return name;
950
+ }
951
+
952
+ export function desktopEvidenceName(value, zeroBasedStepIndex, fallback) {
953
+ if (!Number.isInteger(zeroBasedStepIndex) || zeroBasedStepIndex < 0) throw new Error("step index must be a non-negative integer");
954
+ return captureName(value, `${fallback}-${zeroBasedStepIndex + 1}`);
955
+ }
956
+
957
+ function assertKnownFields(value, allowed, label) {
958
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${label} contains unsupported field: ${key}`);
959
+ }
960
+
961
+ function requireBoundedString(value, label, maxLength) {
962
+ if (typeof value !== "string" || value.length < 1 || value.length > maxLength || value.includes("\0")) throw new Error(`${label} must be a non-empty string no longer than ${maxLength}`);
963
+ return value;
964
+ }
965
+
966
+ function writePrivate(file, content) {
967
+ fs.writeFileSync(file, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
968
+ if (process.platform !== "win32") fs.chmodSync(file, 0o600);
969
+ }
970
+
971
+ function findWindowsPowerShell() {
972
+ const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
973
+ const fixed = systemRoot
974
+ ? path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
975
+ : undefined;
976
+ if (fixed && fs.existsSync(fixed)) return fixed;
977
+ return findExecutable(["powershell.exe", "pwsh.exe", "powershell", "pwsh"]);
978
+ }
979
+
980
+ function findExecutable(candidates) {
981
+ const pathValue = process.env.PATH ?? "";
982
+ const directories = pathValue.split(path.delimiter).filter(Boolean);
983
+ const windowsExtensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
984
+ .split(";")
985
+ .filter(Boolean);
986
+ for (const candidate of candidates) {
987
+ if (path.isAbsolute(candidate)) {
988
+ if (isExecutableFile(candidate)) return candidate;
989
+ continue;
990
+ }
991
+ const hasExtension = path.extname(candidate).length > 0;
992
+ const extensions = process.platform === "win32" && !hasExtension ? windowsExtensions : [""];
993
+ for (const directory of directories) {
994
+ for (const extension of extensions) {
995
+ const resolved = path.join(directory, `${candidate}${extension}`);
996
+ if (isExecutableFile(resolved)) return resolved;
997
+ }
998
+ }
999
+ }
1000
+ return undefined;
1001
+ }
1002
+
1003
+ function isExecutableFile(file) {
1004
+ try {
1005
+ const stat = fs.statSync(file);
1006
+ if (!stat.isFile()) return false;
1007
+ if (process.platform === "win32") return true;
1008
+ fs.accessSync(file, fs.constants.X_OK);
1009
+ return true;
1010
+ } catch {
1011
+ return false;
1012
+ }
1013
+ }
1014
+
1015
+ function probeHasScreenshot(context) {
1016
+ return context.selection?.supportedCapabilities?.includes("windowScreenshot") === true;
1017
+ }
1018
+
1019
+ function emptyArtifacts() {
1020
+ return { screenshots: [], videos: [], traces: [], terminalCaptures: [], accessibilitySnapshots: [], observations: [], downloads: [] };
1021
+ }
1022
+
1023
+ function blocked(reason, remediation) {
1024
+ return { status: "BLOCKED", reason, remediation, assertions: [], observations: [], artifacts: emptyArtifacts() };
1025
+ }
1026
+
1027
+ function isInside(root, target) {
1028
+ const relative = path.relative(root, target);
1029
+ return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
1030
+ }
1031
+
1032
+ function isObject(value) {
1033
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1034
+ }
1035
+
1036
+ function safeReason(error) {
1037
+ return error instanceof Error ? error.message : String(error);
1038
+ }