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,535 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
7
+ import { probeBrowserBackend, runBrowserBackend } from "../backends/browser.mjs";
8
+ import { probeDesktopBackend, runDesktopBackend } from "../backends/desktop.mjs";
9
+ import { probeTuiBackend, runTuiBackend } from "../backends/tui.mjs";
10
+
11
+ const AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
12
+ const WORKSPACE_RELATIVE = "ui-qa";
13
+ // Guides are a fixed allowlist of bundled documents, never model-provided
14
+ // paths. `guide` is read-only: it touches only these files and requires no
15
+ // agent workspace, so it stays usable from any cwd.
16
+ const GUIDES_DIR_RELATIVE_URL = "../guides/";
17
+ const GUIDE_FILES = {
18
+ browser: "browser.md",
19
+ tui: "tui.md",
20
+ desktop: "desktop.md",
21
+ };
22
+ const GUIDE_TOPIC_FILES = {
23
+ browser: {
24
+ auth: "browser-auth.md",
25
+ playwright: "browser-playwright.md",
26
+ "chrome-devtools": "browser-chrome-devtools.md",
27
+ },
28
+ tui: {
29
+ pty: "tui-pty.md",
30
+ "native-terminal": "tui-native-terminal.md",
31
+ },
32
+ desktop: {
33
+ "macos-accessibility": "desktop-macos.md",
34
+ "windows-uia": "desktop-windows.md",
35
+ "linux-at-spi": "desktop-linux.md",
36
+ },
37
+ };
38
+ const MAX_GUIDE_BYTES = 256 * 1024;
39
+ const FLOW_RELATIVE = "flows";
40
+ const EVIDENCE_RELATIVE = "evidence";
41
+ // Preserve the trusted browser runner's bounded in-memory upload contract.
42
+ // Native/TUI schemas remain small, while browser flows may contain base64 file
43
+ // payloads up to the legacy runner's 16 MiB flow limit.
44
+ const MAX_FLOW_BYTES = 16 * 1024 * 1024;
45
+ const MAX_STEPS = 100;
46
+ const DEFAULT_BLOCKED_REMEDIATION = "report this blocker to the parent agent; no safe automatic remediation is defined";
47
+ const DEFAULT_RUNNER_TIMEOUT_MS = 90_000;
48
+ const MAX_RUNNER_TIMEOUT_MS = 100_000;
49
+ // Backends may spend up to five seconds escalating owned children from SIGTERM
50
+ // to SIGKILL. The process-level hard exit must be strictly later or it can win
51
+ // that cleanup race and orphan an app, PTY, or trusted browser runner.
52
+ const HARD_EXIT_GRACE_MS = 10_000;
53
+ const PROGRESS_MAX_BYTES = 1024 * 1024;
54
+ const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
55
+
56
+ process.umask(0o077);
57
+
58
+ class UiQaError extends Error {
59
+ constructor(status, reason, exitCode = 1, details = {}) {
60
+ super(reason);
61
+ this.status = status;
62
+ this.reason = reason;
63
+ this.exitCode = exitCode;
64
+ this.details = details;
65
+ }
66
+ }
67
+
68
+ main().catch((error) => {
69
+ const failure = error instanceof UiQaError
70
+ ? error
71
+ : new UiQaError("FAILED", safeReason(error));
72
+ writeStatus(normalizeResult({
73
+ status: failure.status,
74
+ reason: failure.reason,
75
+ ...failure.details,
76
+ }));
77
+ process.exit(failure.exitCode);
78
+ });
79
+
80
+ async function main() {
81
+ const [command = "probe", ...rawArgs] = process.argv.slice(2);
82
+ if (command !== "guide" && command !== "probe" && command !== "run") {
83
+ throw new UiQaError("FAILED", `unknown command: ${command}`);
84
+ }
85
+ if (command === "guide") return runGuideCommand(rawArgs);
86
+ const args = parseArgs(rawArgs, command);
87
+ const projectRoot = fs.realpathSync(process.cwd());
88
+ const agentDir = resolveAgentDirectory(projectRoot, process.env[AGENT_DIR_ENV]);
89
+ const workspaceDir = path.join(agentDir, WORKSPACE_RELATIVE);
90
+ const flowPath = resolveFlowPath(workspaceDir, args.flow);
91
+ const flow = readFlow(flowPath, command === "run");
92
+ const runnerTimeoutMs = parseRunnerTimeout(args.runnerTimeoutMs);
93
+ const deadline = Date.now() + runnerTimeoutMs;
94
+ const progress = createProgress(workspaceDir);
95
+ const common = {
96
+ flow,
97
+ projectRoot,
98
+ agentDir,
99
+ workspaceDir,
100
+ deadline,
101
+ stageTimeoutMs: Math.min(30_000, runnerTimeoutMs),
102
+ progress,
103
+ };
104
+ progress("runner_started", { command, timeoutMs: runnerTimeoutMs });
105
+
106
+ const hardTimer = setTimeout(() => {
107
+ writeStatus(normalizeResult({
108
+ status: "FAILED",
109
+ reason: `UI QA runner exceeded ${runnerTimeoutMs + HARD_EXIT_GRACE_MS} ms`,
110
+ timedOut: true,
111
+ }));
112
+ process.exit(124);
113
+ }, runnerTimeoutMs + HARD_EXIT_GRACE_MS);
114
+ hardTimer.unref?.();
115
+
116
+ try {
117
+ const selection = await selectBackend(common);
118
+ progress("backend_selected", {
119
+ targetKind: selection.detectedTargetKind,
120
+ backend: selection.selectedBackend,
121
+ });
122
+ if (command === "probe") {
123
+ const status = selection.available ? "AVAILABLE" : "BLOCKED";
124
+ writeStatus(normalizeResult({
125
+ status,
126
+ selection: publicSelection(selection),
127
+ ...(selection.available ? {} : {
128
+ reason: selection.reason,
129
+ remediation: selection.remediation,
130
+ }),
131
+ }));
132
+ if (!selection.available) process.exitCode = 2;
133
+ return;
134
+ }
135
+ if (!selection.available) {
136
+ throw new UiQaError("BLOCKED", selection.reason ?? "selected UI backend is unavailable", 2, {
137
+ selection: publicSelection(selection),
138
+ remediation: selection.remediation,
139
+ });
140
+ }
141
+
142
+ const runId = safeRunId(args.runId ?? timestamp());
143
+ const evidenceDir = path.join(workspaceDir, EVIDENCE_RELATIVE, runId);
144
+ createExclusivePrivateDirectory(workspaceDir, evidenceDir);
145
+ const artifact = (value, kind) => artifactRecord(evidenceDir, value, kind);
146
+ const context = {
147
+ ...common,
148
+ runId,
149
+ evidenceDir,
150
+ artifact,
151
+ // The selected backend's probed capability record, so backends can
152
+ // gate producer-dependent steps (screenshot/video) honestly instead
153
+ // of assuming capabilities the host never granted.
154
+ selection: publicSelection(selection),
155
+ browserRunnerPath: fileURLToPath(new URL("../browser/scripts/browser-qa-runner.mjs", import.meta.url)),
156
+ };
157
+ const backendResult = await runSelectedBackend(selection.selectedBackend, context);
158
+ const deadlineExpired = Date.now() >= deadline;
159
+ const result = normalizeResult({
160
+ ...backendResult,
161
+ ...(deadlineExpired ? { status: "FAILED", timedOut: true, reason: backendResult.reason ?? "UI QA runner deadline expired" } : {}),
162
+ selection: publicSelection(selection),
163
+ evidenceDir: relativePath(projectRoot, evidenceDir),
164
+ });
165
+ writeJsonPrivate(path.join(evidenceDir, "result.json"), result);
166
+ writeStatus(result);
167
+ if (result.status === "BLOCKED") process.exitCode = 2;
168
+ else if (result.status !== "PASSED") process.exitCode = result.timedOut ? 124 : 1;
169
+ } finally {
170
+ clearTimeout(hardTimer);
171
+ progress("runner_finished");
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Read-only progressive-disclosure loader. Prints exactly one allowlisted
177
+ * bundled guide to stdout; anything else (unknown backend/topic/option, extra
178
+ * or positional arguments, traversal) fails closed. Deliberately independent
179
+ * of PI_SUBAGENT_AGENT_DIR and the project cwd.
180
+ */
181
+ function runGuideCommand(rawArgs) {
182
+ const options = parseGuideArgs(rawArgs);
183
+ const fileName = options.topic === undefined
184
+ ? GUIDE_FILES[options.backend]
185
+ : GUIDE_TOPIC_FILES[options.backend][options.topic];
186
+ const guidesDir = fileURLToPath(new URL(GUIDES_DIR_RELATIVE_URL, import.meta.url));
187
+ const guidePath = path.join(guidesDir, fileName);
188
+ // The allowlisted name cannot traverse, but keep the containment, symlink,
189
+ // regular-file, and bounded-size checks for defense in depth.
190
+ assertInside(guidesDir, guidePath, "UI QA guide");
191
+ assertNoSymlinkComponents(guidesDir, guidePath, "UI QA guide");
192
+ const stat = fs.statSync(guidePath);
193
+ if (!stat.isFile()) throw new UiQaError("FAILED", "UI QA guide must be a regular file");
194
+ if (stat.size > MAX_GUIDE_BYTES) {
195
+ throw new UiQaError("FAILED", `UI QA guide must be no larger than ${MAX_GUIDE_BYTES} bytes`);
196
+ }
197
+ process.stdout.write(fs.readFileSync(guidePath, "utf8"));
198
+ }
199
+
200
+ function parseGuideArgs(values) {
201
+ const options = {};
202
+ for (let index = 0; index < values.length; index += 2) {
203
+ const flag = values[index];
204
+ const value = values[index + 1];
205
+ if (!flag?.startsWith("--") || value === undefined) throw new UiQaError("FAILED", `invalid guide argument: ${flag ?? "(missing)"}`);
206
+ const key = flag.slice(2);
207
+ if (key !== "backend" && key !== "topic") throw new UiQaError("FAILED", `unknown guide option: ${flag}`);
208
+ if (options[key] !== undefined) throw new UiQaError("FAILED", `duplicate guide option: ${flag}`);
209
+ options[key] = value;
210
+ }
211
+ if (!options.backend) throw new UiQaError("FAILED", "--backend is required for guide");
212
+ if (!Object.hasOwn(GUIDE_FILES, options.backend)) {
213
+ throw new UiQaError("FAILED", `unknown guide backend: ${options.backend}`);
214
+ }
215
+ if (options.topic !== undefined) {
216
+ if (!Object.hasOwn(GUIDE_TOPIC_FILES[options.backend], options.topic)) {
217
+ throw new UiQaError("FAILED", `unknown guide topic for ${options.backend}: ${options.topic}`);
218
+ }
219
+ }
220
+ return options;
221
+ }
222
+
223
+ async function selectBackend(context) {
224
+ const detectedTargetKind = detectTargetKind(context.flow.target);
225
+ const probes = {
226
+ browser: await probeBrowserBackend({ ...context, shallow: detectedTargetKind !== "browser" }),
227
+ tui: await probeTuiBackend({ ...context, shallow: detectedTargetKind !== "tui" }),
228
+ desktop: await probeDesktopBackend({ ...context, shallow: detectedTargetKind !== "desktop" }),
229
+ };
230
+ const selected = probes[detectedTargetKind];
231
+ if (!selected || typeof selected !== "object") {
232
+ throw new UiQaError("FAILED", `backend probe returned no result for ${detectedTargetKind}`);
233
+ }
234
+ const candidateBackends = ["browser", "tui", "desktop"].map((backend) => ({
235
+ backend,
236
+ eligible: backend === detectedTargetKind,
237
+ available: Boolean(probes[backend]?.available),
238
+ supportedCapabilities: stringArray(probes[backend]?.supportedCapabilities),
239
+ missingCapabilities: stringArray(probes[backend]?.missingCapabilities),
240
+ ...(probes[backend]?.platformDriver ? { platformDriver: probes[backend].platformDriver } : {}),
241
+ why: backend === detectedTargetKind
242
+ ? (probes[backend]?.reason ?? `target descriptor matches the ${backend} backend`)
243
+ : `target descriptor does not match the ${backend} backend`,
244
+ }));
245
+ return {
246
+ detectedTargetKind,
247
+ candidateBackends,
248
+ selectedBackend: detectedTargetKind,
249
+ platformDriver: selected.platformDriver,
250
+ guide: typeof selected.guideTopic === "string"
251
+ ? { backend: detectedTargetKind, topic: selected.guideTopic }
252
+ : undefined,
253
+ supportedCapabilities: stringArray(selected.supportedCapabilities),
254
+ missingCapabilities: stringArray(selected.missingCapabilities),
255
+ whySelected: selected.reason ?? `target descriptor matches the ${detectedTargetKind} backend`,
256
+ available: Boolean(selected.available),
257
+ reason: selected.available ? undefined : selected.reason,
258
+ remediation: selected.remediation,
259
+ };
260
+ }
261
+
262
+ function publicSelection(selection) {
263
+ return {
264
+ detectedTargetKind: selection.detectedTargetKind,
265
+ candidateBackends: selection.candidateBackends,
266
+ selectedBackend: selection.selectedBackend,
267
+ ...(selection.platformDriver ? { platformDriver: selection.platformDriver } : {}),
268
+ ...(selection.guide ? { guide: selection.guide } : {}),
269
+ supportedCapabilities: selection.supportedCapabilities,
270
+ missingCapabilities: selection.missingCapabilities,
271
+ whySelected: selection.whySelected,
272
+ };
273
+ }
274
+
275
+ async function runSelectedBackend(backend, context) {
276
+ if (backend === "browser") return runBrowserBackend(context);
277
+ if (backend === "tui") return runTuiBackend(context);
278
+ if (backend === "desktop") return runDesktopBackend(context);
279
+ throw new UiQaError("FAILED", `unsupported backend: ${backend}`);
280
+ }
281
+
282
+ function detectTargetKind(target) {
283
+ if (!isObject(target)) throw new UiQaError("FAILED", "flow.target must be an object");
284
+ const matches = [];
285
+ if (typeof target.url === "string" || typeof target.baseUrl === "string") matches.push("browser");
286
+ if (isObject(target.command) && Array.isArray(target.command.argv)) matches.push("tui");
287
+ if (isObject(target.application)) matches.push("desktop");
288
+ if (matches.length !== 1) {
289
+ throw new UiQaError("BLOCKED", "target discovery requires exactly one of target.url/baseUrl, target.command.argv, or target.application", 2);
290
+ }
291
+ if (target.kind !== undefined && target.kind !== "auto" && target.kind !== matches[0]) {
292
+ throw new UiQaError("FAILED", `target.kind ${JSON.stringify(target.kind)} contradicts detected target kind ${matches[0]}`);
293
+ }
294
+ return matches[0];
295
+ }
296
+
297
+ function parseArgs(values, command) {
298
+ const result = {};
299
+ for (let index = 0; index < values.length; index += 2) {
300
+ const flag = values[index];
301
+ const value = values[index + 1];
302
+ if (!flag?.startsWith("--") || value === undefined) throw new UiQaError("FAILED", `invalid argument: ${flag ?? "(missing)"}`);
303
+ const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
304
+ const allowed = command === "run" ? ["flow", "runId", "runnerTimeoutMs"] : ["flow", "runnerTimeoutMs"];
305
+ if (!allowed.includes(key)) throw new UiQaError("FAILED", `unknown option: ${flag}`);
306
+ if (result[key] !== undefined) throw new UiQaError("FAILED", `duplicate option: ${flag}`);
307
+ result[key] = value;
308
+ }
309
+ if (!result.flow) throw new UiQaError("FAILED", "--flow is required");
310
+ return result;
311
+ }
312
+
313
+ function readFlow(flowPath, requireSteps) {
314
+ const stat = fs.statSync(flowPath);
315
+ if (!stat.isFile() || stat.size > MAX_FLOW_BYTES) {
316
+ throw new UiQaError("FAILED", "UI QA flow must be a regular JSONC file no larger than 16 MiB");
317
+ }
318
+ const errors = [];
319
+ const flow = parseJsonc(fs.readFileSync(flowPath, "utf8"), errors, { allowTrailingComma: true });
320
+ if (errors.length > 0) {
321
+ throw new UiQaError("FAILED", `invalid UI QA JSONC: ${printParseErrorCode(errors[0].error)}`);
322
+ }
323
+ if (!isObject(flow) || !isObject(flow.target)) throw new UiQaError("FAILED", "UI QA flow must define target");
324
+ if (flow.version !== undefined && flow.version !== 1) throw new UiQaError("FAILED", "UI QA flow version must be 1");
325
+ if (requireSteps && (!Array.isArray(flow.steps) || flow.steps.length < 1 || flow.steps.length > MAX_STEPS)) {
326
+ throw new UiQaError("FAILED", `UI QA flow must define between 1 and ${MAX_STEPS} steps`);
327
+ }
328
+ if (!requireSteps && flow.steps !== undefined && (!Array.isArray(flow.steps) || flow.steps.length > MAX_STEPS)) {
329
+ throw new UiQaError("FAILED", `UI QA flow steps must be an array with at most ${MAX_STEPS} entries`);
330
+ }
331
+ return flow;
332
+ }
333
+
334
+ function resolveAgentDirectory(projectRoot, value) {
335
+ if (typeof value !== "string" || value.length === 0) throw new UiQaError("FAILED", `${AGENT_DIR_ENV} is required`);
336
+ const subagentRoot = path.join(projectRoot, ".pi", "subagents");
337
+ const resolved = path.resolve(value);
338
+ if (!fs.existsSync(resolved)) throw new UiQaError("FAILED", "UI QA agent directory is missing");
339
+ const real = fs.realpathSync(resolved);
340
+ assertInside(subagentRoot, real, "UI QA agent directory");
341
+ assertNoSymlinkComponents(projectRoot, real, "UI QA agent directory");
342
+ if (!fs.statSync(real).isDirectory()) throw new UiQaError("FAILED", "UI QA agent directory must be a directory");
343
+ for (const [name, label] of [["prompt.md", "prompt"], ["project_cwd", "project metadata"], ["subagent_type", "type metadata"]]) {
344
+ const file = path.join(real, name);
345
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new UiQaError("FAILED", `UI QA ${label} is missing`);
346
+ assertNoSymlinkComponents(real, file, `UI QA ${label}`);
347
+ }
348
+ const recordedProject = fs.readFileSync(path.join(real, "project_cwd"), "utf8").trim();
349
+ if (!recordedProject || fs.realpathSync(recordedProject) !== projectRoot) throw new UiQaError("FAILED", "UI QA agent directory belongs to another project");
350
+ const type = fs.readFileSync(path.join(real, "subagent_type"), "utf8").trim();
351
+ if (type !== "ui-qa" && type !== "browser-qa") throw new UiQaError("FAILED", "UI QA runner requires a ui-qa/browser-qa agent directory");
352
+ const workspace = path.join(real, WORKSPACE_RELATIVE);
353
+ validatePrivateDirectory(real, workspace, "UI QA workspace");
354
+ validatePrivateDirectory(workspace, path.join(workspace, FLOW_RELATIVE), "UI QA flow workspace");
355
+ return real;
356
+ }
357
+
358
+ function resolveFlowPath(workspaceDir, value) {
359
+ if (typeof value !== "string" || !value) throw new UiQaError("FAILED", "flow path is missing");
360
+ const root = fs.realpathSync(path.join(workspaceDir, FLOW_RELATIVE));
361
+ const resolved = path.resolve(root, value);
362
+ assertInside(root, resolved, "UI QA flow");
363
+ if (!fs.existsSync(resolved)) throw new UiQaError("FAILED", "UI QA flow is missing");
364
+ assertNoSymlinkComponents(root, resolved, "UI QA flow");
365
+ const real = fs.realpathSync(resolved);
366
+ assertInside(root, real, "UI QA flow");
367
+ const stat = fs.statSync(real);
368
+ if (!stat.isFile()) throw new UiQaError("FAILED", "UI QA flow must be a regular file");
369
+ if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new UiQaError("FAILED", "UI QA flow must use private permissions (0600)");
370
+ return real;
371
+ }
372
+
373
+ function validatePrivateDirectory(root, target, label) {
374
+ assertInside(root, target, label);
375
+ if (!fs.existsSync(target)) throw new UiQaError("FAILED", `${label} is missing`);
376
+ assertNoSymlinkComponents(root, target, label);
377
+ const stat = fs.statSync(target);
378
+ if (!stat.isDirectory()) throw new UiQaError("FAILED", `${label} must be a directory`);
379
+ if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new UiQaError("FAILED", `${label} must use private permissions (0700)`);
380
+ }
381
+
382
+ function createExclusivePrivateDirectory(root, target) {
383
+ const resolvedRoot = fs.realpathSync(root);
384
+ assertInside(resolvedRoot, target, "evidence directory");
385
+ let current = resolvedRoot;
386
+ for (const part of path.relative(resolvedRoot, target).split(path.sep)) {
387
+ current = path.join(current, part);
388
+ if (fs.existsSync(current)) {
389
+ const stat = fs.lstatSync(current);
390
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new UiQaError("FAILED", "evidence path must contain only real directories");
391
+ if (current === target) throw new UiQaError("FAILED", "evidence directory already exists");
392
+ if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new UiQaError("FAILED", "evidence directories must use mode 0700");
393
+ } else fs.mkdirSync(current, { mode: 0o700 });
394
+ }
395
+ }
396
+
397
+ function createProgress(workspaceDir) {
398
+ const file = path.join(workspaceDir, "progress.jsonl");
399
+ let stat;
400
+ try { stat = fs.lstatSync(file); } catch (error) {
401
+ if (error?.code !== "ENOENT") throw error;
402
+ }
403
+ if (stat) {
404
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new UiQaError("FAILED", "UI QA progress path must be a regular file");
405
+ if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new UiQaError("FAILED", "UI QA progress file must use private permissions (0600)");
406
+ }
407
+ return (stage, details = {}) => {
408
+ if (fs.existsSync(file) && fs.statSync(file).size >= PROGRESS_MAX_BYTES) fs.writeFileSync(file, "", { mode: 0o600 });
409
+ fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), stage, ...details })}\n`, { mode: 0o600 });
410
+ };
411
+ }
412
+
413
+ function normalizeResult(value) {
414
+ const artifacts = isObject(value.artifacts) ? value.artifacts : {};
415
+ const normalizedValue = value.status === "BLOCKED" && !nonEmptyString(value.remediation)
416
+ ? { ...value, remediation: DEFAULT_BLOCKED_REMEDIATION }
417
+ : value;
418
+ const blockedHandoff = buildBlockedHandoff(normalizedValue);
419
+ return {
420
+ ...normalizedValue,
421
+ ...(blockedHandoff ? { blockedHandoff } : {}),
422
+ artifacts: {
423
+ screenshots: artifactArray(artifacts.screenshots),
424
+ videos: artifactArray(artifacts.videos),
425
+ traces: artifactArray(artifacts.traces),
426
+ terminalCaptures: artifactArray(artifacts.terminalCaptures),
427
+ accessibilitySnapshots: artifactArray(artifacts.accessibilitySnapshots),
428
+ observations: artifactArray(artifacts.observations),
429
+ downloads: artifactArray(artifacts.downloads),
430
+ },
431
+ assertions: Array.isArray(value.assertions) ? value.assertions : [],
432
+ observations: Array.isArray(value.observations) ? value.observations : [],
433
+ };
434
+ }
435
+
436
+ function buildBlockedHandoff(value) {
437
+ if (value.status !== "BLOCKED") return null;
438
+ const selection = isObject(value.selection) ? value.selection : {};
439
+ const candidates = Array.isArray(selection.candidateBackends) ? selection.candidateBackends : [];
440
+ const selectedCandidate = candidates.find((entry) => isObject(entry) && entry.backend === selection.selectedBackend);
441
+ const reason = nonEmptyString(value.reason)
442
+ ? value.reason
443
+ : (nonEmptyString(selection.whySelected)
444
+ ? selection.whySelected
445
+ : "UI QA is blocked by an unavailable required capability or environment prerequisite");
446
+ const remediation = nonEmptyString(value.remediation) ? value.remediation : DEFAULT_BLOCKED_REMEDIATION;
447
+ const platformDriver = nonEmptyString(selection.platformDriver)
448
+ ? selection.platformDriver
449
+ : (nonEmptyString(selectedCandidate?.platformDriver) ? selectedCandidate.platformDriver : undefined);
450
+ return {
451
+ ...(nonEmptyString(selection.selectedBackend) ? { backend: selection.selectedBackend } : {}),
452
+ ...(platformDriver ? { platformDriver } : {}),
453
+ missingCapabilities: stringArray(selection.missingCapabilities),
454
+ reason,
455
+ remediation,
456
+ manualActionRequired: true,
457
+ automaticRemediationAttempted: false,
458
+ };
459
+ }
460
+
461
+ function artifactArray(value) {
462
+ return Array.isArray(value) ? value.filter((entry) => isObject(entry) && typeof entry.path === "string") : [];
463
+ }
464
+
465
+ function artifactRecord(evidenceDir, value, kind) {
466
+ const resolved = path.resolve(evidenceDir, value);
467
+ assertInside(evidenceDir, resolved, `${kind} artifact`);
468
+ return { path: resolved, uri: pathToFileURL(resolved).href };
469
+ }
470
+
471
+ function writeJsonPrivate(file, value) {
472
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
473
+ if (process.platform !== "win32") fs.chmodSync(file, 0o600);
474
+ }
475
+
476
+ function writeStatus(value) {
477
+ process.stdout.write(`${JSON.stringify(value)}\n`);
478
+ }
479
+
480
+ function parseRunnerTimeout(value) {
481
+ if (value === undefined) return DEFAULT_RUNNER_TIMEOUT_MS;
482
+ const parsed = Number(value);
483
+ if (!Number.isFinite(parsed) || parsed < 100 || parsed > MAX_RUNNER_TIMEOUT_MS) {
484
+ throw new UiQaError("FAILED", `--runner-timeout-ms must be between 100 and ${MAX_RUNNER_TIMEOUT_MS}`);
485
+ }
486
+ return Math.round(parsed);
487
+ }
488
+
489
+ function safeRunId(value) {
490
+ if (typeof value !== "string" || !SAFE_NAME.test(value) || value === "." || value.includes("..")) {
491
+ throw new UiQaError("FAILED", "run id must contain only letters, digits, dot, underscore, or dash without dot segments");
492
+ }
493
+ return value;
494
+ }
495
+
496
+ function timestamp() {
497
+ return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
498
+ }
499
+
500
+ function relativePath(root, value) {
501
+ return (path.relative(root, value) || ".").split(path.sep).join("/");
502
+ }
503
+
504
+ function isInside(root, target) {
505
+ const relative = path.relative(root, target);
506
+ return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
507
+ }
508
+
509
+ function assertInside(root, target, label) {
510
+ if (!isInside(root, target)) throw new UiQaError("FAILED", `${label} must stay inside its owning workspace`);
511
+ }
512
+
513
+ function assertNoSymlinkComponents(root, target, label) {
514
+ let current = root;
515
+ for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
516
+ current = path.join(current, part);
517
+ if (fs.lstatSync(current).isSymbolicLink()) throw new UiQaError("FAILED", `${label} must not use symbolic links`);
518
+ }
519
+ }
520
+
521
+ function stringArray(value) {
522
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
523
+ }
524
+
525
+ function nonEmptyString(value) {
526
+ return typeof value === "string" && value.trim().length > 0;
527
+ }
528
+
529
+ function safeReason(error) {
530
+ return error instanceof Error ? error.message : String(error);
531
+ }
532
+
533
+ function isObject(value) {
534
+ return value !== null && typeof value === "object" && !Array.isArray(value);
535
+ }
@@ -0,0 +1,75 @@
1
+ ---
2
+ description: Use for real UI QA across browsers, terminal/TUI apps, and desktop GUIs - reproduce user-visible bugs and verify fixes with deterministic assertions and inspectable evidence.
3
+ icon: bug
4
+ models: [zai/glm-5.3-flash, openai-codex/gpt-5.6-luna]
5
+ thinking: low
6
+ timeoutMs: 300000
7
+ tools: [read, grep, bash]
8
+ ---
9
+
10
+ # UI QA
11
+
12
+ Test the actual user-facing surface requested by the task. Never substitute a
13
+ mock, fixture, repository test, or another surface. Source inspection may help
14
+ discover the launch/control contract but is not UI verification. If the target
15
+ is ambiguous, unreachable, or lacks a safe deterministic control path, return
16
+ `BLOCKED`.
17
+
18
+ ## Route progressively
19
+
20
+ Classify only the top-level surface: `browser`, `tui`, or `desktop`. Load exactly
21
+ that backend's base guide through the launcher-provided `PI_UI_QA_RUNNER`:
22
+
23
+ ```sh
24
+ node "$PI_UI_QA_RUNNER" guide --backend browser
25
+ node "$PI_UI_QA_RUNNER" guide --backend tui
26
+ node "$PI_UI_QA_RUNNER" guide --backend desktop
27
+ ```
28
+
29
+ The base guide is a router plus common flow contract. Load only the one detail
30
+ topic it directs you to. After `probe`, treat `selection.guide` as authoritative:
31
+ before `run`, the loaded detail topic must equal that `{backend, topic}`. If it
32
+ does not, load the returned topic and follow it. Never guess a provider or
33
+ platform driver from a project/app name, and never load unrelated guides.
34
+
35
+ ## Run through the unified runner
36
+
37
+ Write one declarative JSONC flow under
38
+ `$PI_SUBAGENT_AGENT_DIR/ui-qa/flows/` declaring exactly one target:
39
+ `target.url`/`target.baseUrl` for browser, `target.command.argv` for TUI, or
40
+ `target.application` for desktop. The selected backend/detail guide defines all
41
+ other fields and supported actions. Never bypass the unified runner with a
42
+ provider-specific controller.
43
+
44
+ Keep the flow private (`chmod 600 <flow.jsonc>` on POSIX). Run a bounded
45
+ capability preflight, confirm/load `selection.guide`, then execute the validated
46
+ flow once:
47
+
48
+ ```sh
49
+ node "$PI_UI_QA_RUNNER" probe --flow <flow.jsonc> --runner-timeout-ms 30000
50
+ node "$PI_UI_QA_RUNNER" run --flow <flow.jsonc> --run-id <safe-id> \
51
+ --runner-timeout-ms 60000
52
+ ```
53
+
54
+ The runner owns routing, bounded launch/control, evidence paths, and cleanup.
55
+ Every runner `BLOCKED` includes a parent-ready `blockedHandoff`; relay it
56
+ unchanged instead of installing dependencies, changing OS permissions, or
57
+ inventing remediation yourself.
58
+
59
+ ## Invariants
60
+
61
+ - Define the observable product-visible postcondition before interacting.
62
+ Deterministic assertions are the oracle; screenshots, videos, traces, and
63
+ terminal replays are supporting evidence only.
64
+ - Verification uses one focused real-UI run per requested variant. Explicit
65
+ exploratory QA uses at most three bounded hypothesis-driven rounds.
66
+ - Never weaken an assertion to manufacture a pass. Preserve failure evidence
67
+ and report expected versus observed behavior.
68
+ - Use only user-equivalent, runner-owned interactions. Never kill unrelated
69
+ processes, disable sandboxing, install automation packages, or edit product
70
+ source/tests/settings to make QA possible.
71
+ - Report every retained artifact on PASS and FAIL. Inspect representative PNG
72
+ evidence with `read` before claiming visual QA; if image reading is
73
+ unavailable, say so.
74
+ - Report `PASS`, `FAIL`, or `BLOCKED` with the concrete oracle, selected
75
+ backend/detail route, launch/control path, and retained evidence.