mindweave 2.5.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/README.md +81 -145
  2. package/dist/cli/App.js +579 -304
  3. package/dist/cli/App.js.map +1 -1
  4. package/dist/cli/analytics.js +27 -18
  5. package/dist/cli/analytics.js.map +1 -1
  6. package/dist/cli/attachments.js +34 -0
  7. package/dist/cli/attachments.js.map +1 -1
  8. package/dist/cli/bannerFit.js +70 -0
  9. package/dist/cli/bannerFit.js.map +1 -0
  10. package/dist/cli/bootstrap.js +41 -3
  11. package/dist/cli/bootstrap.js.map +1 -1
  12. package/dist/cli/commands.js +4 -1
  13. package/dist/cli/commands.js.map +1 -1
  14. package/dist/cli/components/ApprovalBox.js +3 -2
  15. package/dist/cli/components/ApprovalBox.js.map +1 -1
  16. package/dist/cli/components/BlockView.js +27 -6
  17. package/dist/cli/components/BlockView.js.map +1 -1
  18. package/dist/cli/components/FirstRunFrame.js +2 -1
  19. package/dist/cli/components/FirstRunFrame.js.map +1 -1
  20. package/dist/cli/components/KeyManager.js +5 -4
  21. package/dist/cli/components/KeyManager.js.map +1 -1
  22. package/dist/cli/components/KeySetup.js +29 -13
  23. package/dist/cli/components/KeySetup.js.map +1 -1
  24. package/dist/cli/components/MarathonBox.js +64 -0
  25. package/dist/cli/components/MarathonBox.js.map +1 -0
  26. package/dist/cli/components/McpMinitabs.js +8 -7
  27. package/dist/cli/components/McpMinitabs.js.map +1 -1
  28. package/dist/cli/components/Picker.js +4 -3
  29. package/dist/cli/components/Picker.js.map +1 -1
  30. package/dist/cli/components/PromptInput.js +47 -8
  31. package/dist/cli/components/PromptInput.js.map +1 -1
  32. package/dist/cli/components/PulseDot.js +42 -0
  33. package/dist/cli/components/PulseDot.js.map +1 -0
  34. package/dist/cli/components/SubagentView.js +9 -6
  35. package/dist/cli/components/SubagentView.js.map +1 -1
  36. package/dist/cli/components/TipLine.js +2 -1
  37. package/dist/cli/components/TipLine.js.map +1 -1
  38. package/dist/cli/components/ToolGroup.js +65 -49
  39. package/dist/cli/components/ToolGroup.js.map +1 -1
  40. package/dist/cli/components/ToolLine.js +20 -19
  41. package/dist/cli/components/ToolLine.js.map +1 -1
  42. package/dist/cli/components/TrustGate.js +2 -1
  43. package/dist/cli/components/TrustGate.js.map +1 -1
  44. package/dist/cli/components/minitabs.js +3 -2
  45. package/dist/cli/components/minitabs.js.map +1 -1
  46. package/dist/cli/desktopApp.js +45 -0
  47. package/dist/cli/desktopApp.js.map +1 -0
  48. package/dist/cli/help.js +1 -0
  49. package/dist/cli/help.js.map +1 -1
  50. package/dist/cli/inkOutputPatch.js +285 -0
  51. package/dist/cli/inkOutputPatch.js.map +1 -0
  52. package/dist/cli/inkSpeed.js +29 -0
  53. package/dist/cli/inkSpeed.js.map +1 -0
  54. package/dist/cli/inkSpeedLoader.js +47 -0
  55. package/dist/cli/inkSpeedLoader.js.map +1 -0
  56. package/dist/cli/inputText.js +30 -0
  57. package/dist/cli/inputText.js.map +1 -0
  58. package/dist/cli/keyManager.js +2 -1
  59. package/dist/cli/keyManager.js.map +1 -1
  60. package/dist/cli/keyPrefs.js +90 -0
  61. package/dist/cli/keyPrefs.js.map +1 -0
  62. package/dist/cli/keySetup.js +5 -2
  63. package/dist/cli/keySetup.js.map +1 -1
  64. package/dist/cli/marathonUi.js +113 -0
  65. package/dist/cli/marathonUi.js.map +1 -0
  66. package/dist/cli/markdown.js +63 -11
  67. package/dist/cli/markdown.js.map +1 -1
  68. package/dist/cli/modes.js +3 -3
  69. package/dist/cli/modes.js.map +1 -1
  70. package/dist/cli/prodEnv.js +43 -0
  71. package/dist/cli/prodEnv.js.map +1 -0
  72. package/dist/cli/revealPace.js +5 -5
  73. package/dist/cli/revealPace.js.map +1 -1
  74. package/dist/cli/revealQueue.js +120 -0
  75. package/dist/cli/revealQueue.js.map +1 -0
  76. package/dist/cli/theme.js +47 -0
  77. package/dist/cli/theme.js.map +1 -0
  78. package/dist/cli/toolDisplay.js +112 -18
  79. package/dist/cli/toolDisplay.js.map +1 -1
  80. package/dist/cli/transcript.js +1 -1
  81. package/dist/cli/transcript.js.map +1 -1
  82. package/dist/cli/virtualWindow.js +14 -2
  83. package/dist/cli/virtualWindow.js.map +1 -1
  84. package/dist/cli/wheelAccel.js +73 -0
  85. package/dist/cli/wheelAccel.js.map +1 -0
  86. package/dist/cli/widestLinePatch.js +48 -0
  87. package/dist/cli/widestLinePatch.js.map +1 -0
  88. package/dist/cli/workingVerb.js +88 -28
  89. package/dist/cli/workingVerb.js.map +1 -1
  90. package/dist/cli/wrap.js +1 -1
  91. package/dist/cli/wrap.js.map +1 -1
  92. package/dist/core/contextSettings.js +84 -0
  93. package/dist/core/contextSettings.js.map +1 -0
  94. package/dist/core/limitsView.js +40 -0
  95. package/dist/core/limitsView.js.map +1 -0
  96. package/dist/core/mcpServers.js +164 -0
  97. package/dist/core/mcpServers.js.map +1 -0
  98. package/dist/core/permissions.js +209 -0
  99. package/dist/core/permissions.js.map +1 -0
  100. package/dist/core/providerKeys.js +187 -0
  101. package/dist/core/providerKeys.js.map +1 -0
  102. package/dist/core/rulesSkills.js +142 -0
  103. package/dist/core/rulesSkills.js.map +1 -0
  104. package/dist/core/spendView.js +90 -0
  105. package/dist/core/spendView.js.map +1 -0
  106. package/dist/core/turnRunner.js +759 -0
  107. package/dist/core/turnRunner.js.map +1 -0
  108. package/dist/drivers/anthropic/client.js +101 -26
  109. package/dist/drivers/anthropic/client.js.map +1 -1
  110. package/dist/drivers/anthropic/manifest.js +43 -13
  111. package/dist/drivers/anthropic/manifest.js.map +1 -1
  112. package/dist/drivers/cerebras/manifest.js +13 -3
  113. package/dist/drivers/cerebras/manifest.js.map +1 -1
  114. package/dist/drivers/deepseek/manifest.js +24 -34
  115. package/dist/drivers/deepseek/manifest.js.map +1 -1
  116. package/dist/drivers/glm/manifest.js +21 -6
  117. package/dist/drivers/glm/manifest.js.map +1 -1
  118. package/dist/drivers/groq/manifest.js +5 -2
  119. package/dist/drivers/groq/manifest.js.map +1 -1
  120. package/dist/drivers/kimi/manifest.js +13 -10
  121. package/dist/drivers/kimi/manifest.js.map +1 -1
  122. package/dist/drivers/meta/manifest.js +21 -16
  123. package/dist/drivers/meta/manifest.js.map +1 -1
  124. package/dist/drivers/minimax/manifest.js +15 -12
  125. package/dist/drivers/minimax/manifest.js.map +1 -1
  126. package/dist/drivers/ollama/catalog.js +71 -0
  127. package/dist/drivers/ollama/catalog.js.map +1 -0
  128. package/dist/drivers/ollama/client.js +164 -0
  129. package/dist/drivers/ollama/client.js.map +1 -0
  130. package/dist/drivers/ollama/endpoint.js +42 -0
  131. package/dist/drivers/ollama/endpoint.js.map +1 -0
  132. package/dist/drivers/ollama/index.js +5 -0
  133. package/dist/drivers/ollama/index.js.map +1 -0
  134. package/dist/drivers/ollama/manifest.js +64 -0
  135. package/dist/drivers/ollama/manifest.js.map +1 -0
  136. package/dist/drivers/openai/client.js +10 -4
  137. package/dist/drivers/openai/client.js.map +1 -1
  138. package/dist/drivers/openai/manifest.js +69 -18
  139. package/dist/drivers/openai/manifest.js.map +1 -1
  140. package/dist/drivers/qwen/manifest.js +19 -6
  141. package/dist/drivers/qwen/manifest.js.map +1 -1
  142. package/dist/drivers/registry.js +11 -0
  143. package/dist/drivers/registry.js.map +1 -1
  144. package/dist/drivers/retryPolicy.js +26 -1
  145. package/dist/drivers/retryPolicy.js.map +1 -1
  146. package/dist/drivers/xai/client.js +12 -10
  147. package/dist/drivers/xai/client.js.map +1 -1
  148. package/dist/drivers/xai/manifest.js +62 -25
  149. package/dist/drivers/xai/manifest.js.map +1 -1
  150. package/dist/dynamo/auxModel.js +54 -0
  151. package/dist/dynamo/auxModel.js.map +1 -0
  152. package/dist/dynamo/engine.js +407 -99
  153. package/dist/dynamo/engine.js.map +1 -1
  154. package/dist/dynamo/guard.js +10 -0
  155. package/dist/dynamo/guard.js.map +1 -1
  156. package/dist/dynamo/liveMeter.js +25 -6
  157. package/dist/dynamo/liveMeter.js.map +1 -1
  158. package/dist/dynamo/marathon.js +409 -0
  159. package/dist/dynamo/marathon.js.map +1 -0
  160. package/dist/dynamo/marathonPrompt.js +45 -0
  161. package/dist/dynamo/marathonPrompt.js.map +1 -0
  162. package/dist/dynamo/model.js +6 -2
  163. package/dist/dynamo/model.js.map +1 -1
  164. package/dist/dynamo/toolBatches.js +58 -0
  165. package/dist/dynamo/toolBatches.js.map +1 -0
  166. package/dist/dynamo/usageLimits.js +446 -0
  167. package/dist/dynamo/usageLimits.js.map +1 -0
  168. package/dist/governor/freshness.js +10 -7
  169. package/dist/governor/freshness.js.map +1 -1
  170. package/dist/governor/index.js +76 -9
  171. package/dist/governor/index.js.map +1 -1
  172. package/dist/governor/write.js +43 -14
  173. package/dist/governor/write.js.map +1 -1
  174. package/dist/index.js +18 -151
  175. package/dist/index.js.map +1 -1
  176. package/dist/main.js +162 -0
  177. package/dist/main.js.map +1 -0
  178. package/dist/mcp/manager.js +18 -0
  179. package/dist/mcp/manager.js.map +1 -1
  180. package/dist/memory/allMetas.js +69 -0
  181. package/dist/memory/allMetas.js.map +1 -0
  182. package/dist/memory/compaction.js +70 -15
  183. package/dist/memory/compaction.js.map +1 -1
  184. package/dist/memory/earlier.js +15 -0
  185. package/dist/memory/earlier.js.map +1 -0
  186. package/dist/memory/profile.js +93 -0
  187. package/dist/memory/profile.js.map +1 -0
  188. package/dist/memory/rewind.js +262 -0
  189. package/dist/memory/rewind.js.map +1 -0
  190. package/dist/memory/session.js +42 -10
  191. package/dist/memory/session.js.map +1 -1
  192. package/dist/memory/sessionMemory.js +48 -13
  193. package/dist/memory/sessionMemory.js.map +1 -1
  194. package/dist/memory/sessionMemoryCompact.js +1 -1
  195. package/dist/memory/sessionMemoryCompact.js.map +1 -1
  196. package/dist/memory/sessionTitle.js +50 -0
  197. package/dist/memory/sessionTitle.js.map +1 -0
  198. package/dist/memory/store.js +42 -1
  199. package/dist/memory/store.js.map +1 -1
  200. package/dist/memory/types.js.map +1 -1
  201. package/dist/tools/askUser.js +14 -0
  202. package/dist/tools/askUser.js.map +1 -1
  203. package/dist/tools/backgroundShells.js +208 -5
  204. package/dist/tools/backgroundShells.js.map +1 -1
  205. package/dist/tools/browser.js +176 -0
  206. package/dist/tools/browser.js.map +1 -0
  207. package/dist/tools/cdp.js +165 -0
  208. package/dist/tools/cdp.js.map +1 -0
  209. package/dist/tools/checkpoints.js +208 -5
  210. package/dist/tools/checkpoints.js.map +1 -1
  211. package/dist/tools/deferredNative.js +2 -0
  212. package/dist/tools/deferredNative.js.map +1 -1
  213. package/dist/tools/detail.js +9 -6
  214. package/dist/tools/detail.js.map +1 -1
  215. package/dist/tools/edit.js +2 -1
  216. package/dist/tools/edit.js.map +1 -1
  217. package/dist/tools/exitPlan.js +4 -0
  218. package/dist/tools/exitPlan.js.map +1 -1
  219. package/dist/tools/hiddenDesktop.js +273 -0
  220. package/dist/tools/hiddenDesktop.js.map +1 -0
  221. package/dist/tools/listeningPorts.js +134 -0
  222. package/dist/tools/listeningPorts.js.map +1 -0
  223. package/dist/tools/mcpSearch.js +8 -0
  224. package/dist/tools/mcpSearch.js.map +1 -1
  225. package/dist/tools/mindweaveStatus.js +1 -1
  226. package/dist/tools/mindweaveStatus.js.map +1 -1
  227. package/dist/tools/registry.js +3 -0
  228. package/dist/tools/registry.js.map +1 -1
  229. package/dist/tools/replaceSymbol.js +2 -1
  230. package/dist/tools/replaceSymbol.js.map +1 -1
  231. package/dist/tools/runCommand.js +113 -9
  232. package/dist/tools/runCommand.js.map +1 -1
  233. package/dist/tools/screenshot.js +4 -6
  234. package/dist/tools/screenshot.js.map +1 -1
  235. package/dist/tools/screenshotWin.js +26 -9
  236. package/dist/tools/screenshotWin.js.map +1 -1
  237. package/dist/tools/stateCheckpoint.js +128 -0
  238. package/dist/tools/stateCheckpoint.js.map +1 -0
  239. package/dist/tools/todo.js +6 -0
  240. package/dist/tools/todo.js.map +1 -1
  241. package/dist/tools/ui.js +1295 -0
  242. package/dist/tools/ui.js.map +1 -0
  243. package/dist/tools/uiHide.js +356 -0
  244. package/dist/tools/uiHide.js.map +1 -0
  245. package/dist/tools/uiPage.js +861 -0
  246. package/dist/tools/uiPage.js.map +1 -0
  247. package/dist/tools/uiWin.js +506 -0
  248. package/dist/tools/uiWin.js.map +1 -0
  249. package/dist/tools/webFetch.js +32 -25
  250. package/dist/tools/webFetch.js.map +1 -1
  251. package/dist/tools/webSearch.js +12 -0
  252. package/dist/tools/webSearch.js.map +1 -1
  253. package/dist/tools/writeFile.js +2 -1
  254. package/dist/tools/writeFile.js.map +1 -1
  255. package/package.json +78 -75
@@ -0,0 +1,1295 @@
1
+ /**
2
+ * ui.ts — let the agent USE an app: see its controls, press them, type into them.
3
+ *
4
+ * `screenshot` answers "does it look right". This answers the next question, the one a
5
+ * picture cannot: "does it work". An agent that built a library view and can only look
6
+ * at the home screen has checked the home screen. With this it can open the view, pick
7
+ * an item, fill the search box, press Enter, and look at what that did.
8
+ *
9
+ * ## Two routes, one loop
10
+ *
11
+ * - The PAGE route (uiPage.ts) drives web content over the DevTools protocol: a page
12
+ * opened by address in a hidden browser (a dev server, a built site), or an Electron /
13
+ * Tauri app started with a debugging port. It can do everything a person can in a
14
+ * page: real clicks at the control, real keys, hover, going back. It also reports the
15
+ * page's own errors, and it refuses to "click" a button something else is covering.
16
+ * - The WINDOW route (uiWin.ts) works on any window through Windows' accessibility
17
+ * layer, for native apps. It presses, toggles, selects, fills and scrolls, but it
18
+ * has no keys or hover, because those would need the real keyboard and mouse.
19
+ *
20
+ * Both hand back the same thing, so the model learns one loop: a numbered list of what
21
+ * can be acted on, the text on screen, and a picture. The numbers belong to that list
22
+ * only, and behind each is an id the route can find the control by again, so a control
23
+ * that disappeared in the meantime is reported as gone instead of pressing whatever took
24
+ * its place.
25
+ *
26
+ * ## Never the user's mouse or keyboard
27
+ *
28
+ * Neither route touches them. The window route acts through control patterns; the page
29
+ * route sends input to the page itself. The user can keep working while the agent tests
30
+ * a window behind theirs, or a page with no window at all.
31
+ *
32
+ * ## A control with no name
33
+ *
34
+ * Screen readers, and this tool, know a control by its accessible name. An icon button
35
+ * with none is listed as "no name": still pressable by its number, but it is also an
36
+ * accessibility bug. When the app is the one being built, that is worth saying, because
37
+ * fixing it is one attribute and makes the next pass read clearly.
38
+ *
39
+ * ## Consent
40
+ *
41
+ * A mutating tool: Sentinel asks before each action, plan mode withholds it, an
42
+ * auto-accept session runs it like everything else. The window route photographs the
43
+ * desktop, so like `screenshot` it is refused where there is nobody to ask (a sub-agent).
44
+ * The page route only ever sees the page, so a sub-agent may use it to verify a web app.
45
+ * Password fields are never typed into. Text from a page that is not on this machine is
46
+ * framed as untrusted, like every other web result.
47
+ */
48
+ import { mkdtemp, readFile, stat } from "node:fs/promises";
49
+ import { tmpdir } from "node:os";
50
+ import { join } from "node:path";
51
+ import { describeImage, isRejection } from "../memory/images.js";
52
+ import { captureWindow, listWindows } from "./screenshotWin.js";
53
+ import { ambiguousMessage, listTitles, pickWindow, safeName } from "./screenshot.js";
54
+ import { actOn, readControls } from "./uiWin.js";
55
+ import { isLocalUrl, PageSession, shortUrl } from "./uiPage.js";
56
+ import { listTargets, pageTargets } from "./cdp.js";
57
+ import { launchBrowser } from "./browser.js";
58
+ import { hideAppWindows, restoreAppWindows } from "./uiHide.js";
59
+ import { HIDDEN_MARK } from "./hiddenDesktop.js";
60
+ import { frameExternal } from "./untrusted.js";
61
+ const ACTIONS = ["look", "click", "type", "key", "scroll", "hover", "back", "wait", "resize", "inspect", "close"];
62
+ /** What only the page route can do; the window route has no keyboard or pointer, and cannot read a page's styles. */
63
+ const PAGE_ONLY = ["key", "hover", "back", "wait", "resize", "inspect"];
64
+ /** The most steps one call may run: enough for a form or a walk through a view, few enough that a wrong guess is cheap. */
65
+ const MAX_STEPS = 25;
66
+ /** How long a wait for some text may take, by default and at most. */
67
+ const DEFAULT_WAIT_MS = 5_000;
68
+ const MAX_WAIT_MS = 30_000;
69
+ const DIRECTIONS = ["down", "up", "left", "right"];
70
+ /** How long to let the app react before reading it again, unless the call says. */
71
+ const DEFAULT_SETTLE_MS = 500;
72
+ const MAX_SETTLE_MS = 10_000;
73
+ /** A hidden browser nobody has used for this long is closed. */
74
+ const IDLE_CLOSE_MS = 10 * 60_000;
75
+ /** How long the page keeps streaming after a step, so what the step set moving (a menu
76
+ * opening, a spinner, a transition) is in the recording too. */
77
+ const CAST_TAIL_MS = 1500;
78
+ let liveCounter = 0;
79
+ /** A name for one test of one app. */
80
+ function newLiveId() {
81
+ return `live-${Date.now().toString(36)}-${(++liveCounter).toString(36)}`;
82
+ }
83
+ /** Told once, when an app's window is moved away, so the model does not report the app
84
+ * as missing and knows how to leave it visible when the user wants that. */
85
+ const HIDE_NOTE = "You started this app, so its window was moved off the user's screen while you test it; it is still running " +
86
+ "and drawing, and goes back where it was when you close it. If the user asked to see it, call again with show: true.";
87
+ /** Told once, for an app started on the hidden desktop (hiddenDesktop.ts). */
88
+ const HIDDEN_DESKTOP_NOTE = "This app runs on a hidden desktop, so it never appears on the user's screen while you test it. " +
89
+ "It cannot be moved onto their screen: if the user asks to see it, stop it and start the same command again with hidden: false.";
90
+ /** Asked to show an app that is on the hidden desktop, which cannot be done in place. */
91
+ const CANNOT_SHOW_NOTE = "This app runs on a hidden desktop and a window cannot be moved from there onto the user's screen, so it is still " +
92
+ "not visible to them. To show it, stop it and start the same command again with hidden: false (it starts fresh).";
93
+ /** Per session: keyed by the session's tool context, so two sessions never share one. */
94
+ const states = new WeakMap();
95
+ /**
96
+ * Debugging ports an app was reachable on, and when it went away (0 while it is up).
97
+ *
98
+ * What tells "still building" from "was closed": a port that was up and then went away
99
+ * was closed (by the user, or it quit), and waiting for it to come back would wait for
100
+ * something that will not happen. Unless a background command started after it closed,
101
+ * which is the agent restarting it: that one is building again and is waited for.
102
+ */
103
+ const portHistory = new Map();
104
+ /** What the model is told when the app it was testing is gone and it did not close it. */
105
+ function appClosedMessage(title) {
106
+ return (`The app ${title ? `"${title}" ` : ""}was closed while you were testing it, and not by you: most likely the ` +
107
+ `user closed it, or it quit. Tell the user in one short line that it was closed and what you had verified ` +
108
+ `so far, then carry on with the rest of the task. Do not start it again unless the user asks.`);
109
+ }
110
+ /** Chromium stops drawing a window it thinks nobody can see; these keep an app drawing
111
+ * while it is kept off-screen, so it can still be watched and recorded. */
112
+ const KEEP_DRAWING = "--disable-features=CalculateNativeWinOcclusion --disable-backgrounding-occluded-windows --disable-renderer-backgrounding";
113
+ /** How to start an app so the page route can reach it. Said where it is needed. */
114
+ const PORT_RECIPES = `Electron: add ${"`"}--remote-debugging-port=9222 ${KEEP_DRAWING}${"`"} to its start command (e.g. ${"`"}npx electron . --remote-debugging-port=9222 ${KEEP_DRAWING}${"`"}). ` +
115
+ `Tauri or any WebView2 app on Windows: set the environment variable ` +
116
+ `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="--remote-debugging-port=9222 ${KEEP_DRAWING}" before starting it. ` +
117
+ "Start it in the background, then call ui with port 9222 straight away: it waits while the app is still building.";
118
+ /** UIA's control type names, in the words a person would use. */
119
+ const KIND_WORDS = {
120
+ tabitem: "tab",
121
+ listitem: "item",
122
+ treeitem: "tree item",
123
+ dataitem: "row",
124
+ menuitem: "menu item",
125
+ splitbutton: "button",
126
+ edit: "text box",
127
+ combobox: "dropdown",
128
+ hyperlink: "link",
129
+ checkbox: "checkbox",
130
+ radiobutton: "option",
131
+ headeritem: "column header",
132
+ document: "page",
133
+ };
134
+ /** One control as the model reads it: `[4] button "Save" (disabled)`. Pure. */
135
+ export function controlLine(n, c) {
136
+ const kind = KIND_WORDS[c.kind] ?? c.kind;
137
+ const name = c.name ? ` "${c.name}"` : " (no name)";
138
+ const value = c.value && c.value !== c.name ? ` = "${c.value}"` : "";
139
+ const state = c.state.filter((s) => s !== "password");
140
+ const flags = [...state, ...(c.state.includes("password") ? ["password: never typed into"] : [])];
141
+ const only = c.actions.length === 1 && c.actions[0] !== "click" ? ` [${c.actions[0]}]` : "";
142
+ return `[${n}] ${kind}${name}${value}${flags.length ? ` (${flags.join(", ")})` : ""}${only}`;
143
+ }
144
+ /** Just what the control is, for saying what was done to it: `button "Save"`. Pure. */
145
+ export function controlName(c) {
146
+ return `${KIND_WORDS[c.kind] ?? c.kind}${c.name ? ` "${c.name}"` : " (no name)"}`;
147
+ }
148
+ /** The whole numbered list, grouped by window when a dialog or popup is open. Pure. */
149
+ export function controlList(snap) {
150
+ if (snap.controls.length === 0) {
151
+ return ("No controls could be read here. Some apps expose nothing to accessibility " +
152
+ "(games, canvas drawing, a few custom frameworks); the picture is all there is to go on.");
153
+ }
154
+ const lines = [];
155
+ let lastWindow = -1;
156
+ const many = snap.windows.length > 1;
157
+ snap.controls.forEach((c, i) => {
158
+ if (many && c.window !== lastWindow) {
159
+ lastWindow = c.window;
160
+ lines.push(`In "${snap.windows[c.window]?.title || "untitled window"}":`);
161
+ }
162
+ lines.push(`${many ? " " : ""}${controlLine(i + 1, c)}`);
163
+ });
164
+ if (snap.more > 0)
165
+ lines.push(`… and ${snap.more} more not listed. Scroll, or open the part you need, to reach them.`);
166
+ return lines.join("\n");
167
+ }
168
+ /**
169
+ * The words on screen that are not controls: a status line, a heading, an error (pure).
170
+ *
171
+ * The picture shows them, but not every model can see a picture, and "did pressing Go
172
+ * work" is usually answered by a line of text rather than a button. Deduplicated, since
173
+ * a control's label is often repeated as its own text element.
174
+ */
175
+ export function screenText(snap) {
176
+ const named = new Set(snap.controls.map((c) => c.name));
177
+ const seen = new Set();
178
+ const lines = snap.texts
179
+ .map((t) => t.text)
180
+ .filter((t) => !named.has(t) && !seen.has(t) && seen.add(t));
181
+ return lines.length ? `Text on screen:\n${lines.map((t) => ` ${t}`).join("\n")}` : "";
182
+ }
183
+ /**
184
+ * What to say about controls with no name (pure). Empty when there are none.
185
+ *
186
+ * Said as a fact with its fix, not as a task: the model decides whether this app is its
187
+ * own to change.
188
+ */
189
+ export function unnamedNote(controls) {
190
+ const unnamed = controls.filter((c) => !c.name && c.actions.includes("click")).length;
191
+ if (unnamed === 0)
192
+ return "";
193
+ return (`${unnamed} control${unnamed === 1 ? " has" : "s have"} no name, so ${unnamed === 1 ? "it is" : "they are"} ` +
194
+ `known only by number. In an app you are building that is an accessibility bug: give each one a ` +
195
+ `name (aria-label or title on the web, AutomationProperties.Name in XAML, AccessibleName in ` +
196
+ `WinForms, or visible text), and it will read by name on the next look.`);
197
+ }
198
+ /**
199
+ * The control a name means, in the latest list (pure). An exact name wins, then a name that
200
+ * starts with it, then one that contains it; when several are equally good the one in view
201
+ * wins, and when it is still not one control the answer lists them, so the model picks by number.
202
+ */
203
+ export function findControl(controls, raw) {
204
+ const want = raw.trim().toLowerCase();
205
+ if (!want)
206
+ return { error: "the name is empty." };
207
+ const tiers = [
208
+ (c) => c.name.trim().toLowerCase() === want,
209
+ (c) => c.name.trim().toLowerCase().startsWith(want),
210
+ (c) => c.name.trim().toLowerCase().includes(want),
211
+ ];
212
+ for (const test of tiers) {
213
+ let hits = controls.map((c, i) => ({ c, n: i + 1 })).filter((h) => test(h.c));
214
+ if (hits.length > 1) {
215
+ const visible = hits.filter((h) => !h.c.state.includes("out of view"));
216
+ if (visible.length >= 1)
217
+ hits = visible;
218
+ }
219
+ if (hits.length === 1)
220
+ return { n: hits[0].n };
221
+ if (hits.length > 1) {
222
+ return { error: `more than one control matches "${raw}": ${hits.slice(0, 6).map((h) => controlLine(h.n, h.c)).join("; ")}. Use the number of the one you mean.` };
223
+ }
224
+ }
225
+ return { error: `no control in the latest list is named "${raw}". Look again, or use a number from the list.` };
226
+ }
227
+ /** An address as typed ("localhost:5173") made into one the browser opens. Pure. */
228
+ export function normalizeUrl(raw) {
229
+ const s = raw.trim();
230
+ if (!s)
231
+ return null;
232
+ const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(s) && !/^(localhost|[\d.]+):\d/i.test(s) ? s : `http://${s}`;
233
+ try {
234
+ const u = new URL(withScheme);
235
+ return ["http:", "https:", "file:"].includes(u.protocol) ? u.href : null;
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ /** Check the arguments and turn them into one request, or say what is wrong. Pure. */
242
+ export function parseUiArgs(args, inStep = false) {
243
+ if (Array.isArray(args.steps) && !inStep) {
244
+ if (args.steps.length === 0)
245
+ return { ok: false, error: "`steps` is empty: list at least one step, or leave it out." };
246
+ if (args.steps.length > MAX_STEPS)
247
+ return { ok: false, error: `\`steps\` has ${args.steps.length} steps; ${MAX_STEPS} is the most in one call. Split it.` };
248
+ const steps = [];
249
+ for (let i = 0; i < args.steps.length; i++) {
250
+ const raw = args.steps[i];
251
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
252
+ return { ok: false, error: `step ${i + 1} must be an object like {"action": "click", "name": "Save"}.` };
253
+ const inner = raw;
254
+ if (inner.url !== undefined || inner.port !== undefined || inner.window !== undefined || inner.steps !== undefined) {
255
+ return { ok: false, error: `step ${i + 1} cannot open a page or nest steps: put \`url\`, \`port\` or \`window\` on the call itself.` };
256
+ }
257
+ if (inner.action === "look" || inner.action === "close") {
258
+ return { ok: false, error: `step ${i + 1}: \`${String(inner.action)}\` is not a step. Every call already ends with a look.` };
259
+ }
260
+ const one = parseUiArgs(inner, true);
261
+ if (!one.ok)
262
+ return { ok: false, error: `step ${i + 1}: ${one.error}` };
263
+ steps.push(one);
264
+ }
265
+ const head = parseUiArgs({ ...args, action: "look", steps: undefined }, true);
266
+ if (!head.ok)
267
+ return head;
268
+ return { ...head, steps };
269
+ }
270
+ const action = args.action;
271
+ if (typeof action !== "string" || !ACTIONS.includes(action)) {
272
+ return { ok: false, error: `\`action\` must be one of: ${ACTIONS.join(", ")}.` };
273
+ }
274
+ const window = typeof args.window === "string" && args.window.trim() ? args.window.trim() : undefined;
275
+ let url;
276
+ if (typeof args.url === "string" && args.url.trim()) {
277
+ const u = normalizeUrl(args.url);
278
+ if (!u)
279
+ return { ok: false, error: "`url` must be a web address (http, https) or a file:// path." };
280
+ url = u;
281
+ }
282
+ let port;
283
+ if (args.port !== undefined && args.port !== null && args.port !== "") {
284
+ const p = typeof args.port === "number" ? args.port : Number(args.port);
285
+ if (!Number.isInteger(p) || p < 1 || p > 65535)
286
+ return { ok: false, error: "`port` must be a port number, e.g. 9222." };
287
+ port = p;
288
+ }
289
+ if (url && port !== undefined)
290
+ return { ok: false, error: "Pass `url` or `port`, not both." };
291
+ if (url && window)
292
+ return { ok: false, error: "Pass `url` or `window`, not both: `url` opens a page, `window` uses an open window." };
293
+ const rawTarget = args.target;
294
+ const target = typeof rawTarget === "number" ? rawTarget : typeof rawTarget === "string" && /^\d+$/.test(rawTarget.trim()) ? Number(rawTarget) : undefined;
295
+ if (target !== undefined && (!Number.isInteger(target) || target < 1)) {
296
+ return { ok: false, error: "`target` is a control's number from the latest list, starting at 1." };
297
+ }
298
+ const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : undefined;
299
+ if ((action === "click" || action === "type" || action === "hover" || action === "inspect") && target === undefined && name === undefined) {
300
+ return { ok: false, error: `\`${action}\` needs \`target\` (the number of a control from the latest look) or \`name\` (its name).` };
301
+ }
302
+ const text = typeof args.text === "string" ? args.text : "";
303
+ if (action === "type" && typeof args.text !== "string")
304
+ return { ok: false, error: "`type` needs `text`." };
305
+ const key = typeof args.key === "string" && args.key.trim() ? args.key.trim() : undefined;
306
+ if (action === "key" && !key)
307
+ return { ok: false, error: "`key` needs `key`, e.g. \"Enter\", \"Escape\" or \"Ctrl+A\"." };
308
+ const dir = typeof args.direction === "string" ? args.direction : "down";
309
+ if (!DIRECTIONS.includes(dir)) {
310
+ return { ok: false, error: `\`direction\` must be one of: ${DIRECTIONS.join(", ")}.` };
311
+ }
312
+ const gone = typeof args.gone === "string" && args.gone.trim() ? args.gone : undefined;
313
+ if (action === "wait" && !(text.trim() || gone)) {
314
+ return { ok: false, error: "`wait` needs `text` (what should appear) or `gone` (what should disappear)." };
315
+ }
316
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? Math.round(v) : typeof v === "string" && /^\d+$/.test(v.trim()) ? Number(v) : undefined);
317
+ const width = num(args.width);
318
+ const height = num(args.height);
319
+ if (action === "resize") {
320
+ const reset = args.width === 0 && args.height === 0;
321
+ if (!reset && (width === undefined || height === undefined || width < 200 || height < 200 || width > 4000 || height > 4000)) {
322
+ return { ok: false, error: "`resize` needs `width` and `height` in pixels (200 to 4000), or both 0 to give the window's own size back. Phone: 390 by 844 with `mobile`." };
323
+ }
324
+ }
325
+ const wait = typeof args.wait_ms === "number" && Number.isFinite(args.wait_ms) ? args.wait_ms : action === "wait" ? DEFAULT_WAIT_MS : DEFAULT_SETTLE_MS;
326
+ return {
327
+ ok: true,
328
+ action: action,
329
+ window,
330
+ url,
331
+ port,
332
+ target,
333
+ name,
334
+ text,
335
+ gone,
336
+ key,
337
+ direction: dir,
338
+ settleMs: Math.max(0, Math.min(MAX_SETTLE_MS, Math.round(wait))),
339
+ waitMs: Math.max(200, Math.min(MAX_WAIT_MS, Math.round(wait))),
340
+ width,
341
+ height,
342
+ mobile: args.mobile === true,
343
+ full: args.full === true,
344
+ annotate: args.annotate === true,
345
+ show: args.show === true,
346
+ };
347
+ }
348
+ export const ui = {
349
+ name: "ui",
350
+ deferred: true,
351
+ readOnly: false,
352
+ keywords: ["click", "press", "button", "app", "window", "gui", "interact", "navigate", "type", "desktop", "automation", "browser", "page", "test", "electron", "tauri"],
353
+ description: "Use an app the way a person would: see its controls, press buttons, switch views, fill forms, press keys, " +
354
+ "scroll. Use it to check that what you built actually WORKS and that it LOOKS right, not only that it starts: " +
355
+ "open the view, submit the form, and look at what happened. The picture is part of the result: study it every " +
356
+ "time, not only the list. After each change, say what looks wrong in it (misaligned, clipped or overlapping " +
357
+ "things, wrong colours or sizes, a control that looks dead or different from its neighbours) and compare it " +
358
+ "with the design rules or the rest of the app, before you call it done. Start with `look`, which picks the app:\n" +
359
+ "- `url`: a web page or dev server (e.g. http://localhost:5173), opened in a hidden browser. Nothing " +
360
+ "appears on the user's screen.\n" +
361
+ "- `port`: an Electron or Tauri app started with a debugging port. " + PORT_RECIPES + "\n" +
362
+ "- `window`: any open window, by part of its title (Windows only), through its accessibility interface. " +
363
+ "It cannot press keys, hover or go back; prefer `port` for Electron/Tauri apps.\n" +
364
+ "Every call returns a numbered list of the controls, the text on screen, any errors and warnings the page " +
365
+ "reported, and a picture. Then act on a control by its number from the LATEST list, or by its `name`: " +
366
+ "`click`; `type` (replaces the text, or picks an option in a dropdown); `key` (Enter, Tab, Escape, " +
367
+ "ArrowDown, Ctrl+A; into the control or wherever focus is); `hover`; `scroll`; `back`.\n" +
368
+ "Do MANY things in ONE call with `steps`: a list like [{action:\"click\",name:\"Add Game\"},{action:\"type\"," +
369
+ "name:\"Title\",text:\"Halo\"},{action:\"click\",name:\"Save\"}]. They run in order, each control found by name " +
370
+ "again just before its step (so a menu an earlier step opened can be used), it stops at the first step that " +
371
+ "fails, and you get ONE result: the final list and picture. Prefer this to one call per click: every call " +
372
+ "costs you a full round trip.\n" +
373
+ "Also: `wait` (text: until it appears; gone: until it disappears; it returns the moment it does, so use " +
374
+ "it instead of guessing a delay); `resize` (width and height: test a narrow window or a phone, mobile: true " +
375
+ "for touch; 0 and 0 gives it back); `inspect` (a control's size, colours, type, spacing and what is wrong with " +
376
+ "it: too small to press, text cut off, low contrast); `look` with `target` is a close-up of that one control, " +
377
+ "with `full: true` the whole page, not only what is in view, with `annotate: true` the list numbers drawn over the controls.\n" +
378
+ "When you have finished testing, call `close`: the user watches the test live until then. Later calls reuse " +
379
+ "the same app, so leave `url`, `port` and `window` out. Controls marked \"out of view\" can still be " +
380
+ "clicked. Controls marked (covered) have something on top of them at that moment (a dialog, an overlay): a click on one is refused and says what covers it, since a " +
381
+ "person could not click it either. The user's mouse and keyboard are never used, and password fields are " +
382
+ "never typed into. An app you start to test never appears on the user's screen: run_command puts it on a " +
383
+ "hidden desktop, automatically for the debugging-port commands above; for any other app you start to test " +
384
+ "(a native one for `window`), pass `hidden: true` to run_command. Pass `show: true` here only when the " +
385
+ "user asked to see the app.",
386
+ parameters: {
387
+ type: "object",
388
+ additionalProperties: false,
389
+ required: [],
390
+ properties: {
391
+ action: { type: "string", enum: ACTIONS, description: "What to do. Leave it out when you pass `steps`." },
392
+ steps: {
393
+ type: "array",
394
+ maxItems: MAX_STEPS,
395
+ description: "Several steps to run in order in this one call (click, type, key, hover, scroll, back, wait, resize, inspect). " +
396
+ "Each is {action, target or name, text, key, direction, wait_ms, gone, width, height, mobile}. " +
397
+ "Stops at the first one that fails.",
398
+ items: {
399
+ type: "object",
400
+ additionalProperties: false,
401
+ required: ["action"],
402
+ properties: {
403
+ action: { type: "string", enum: ACTIONS.filter((a) => a !== "look" && a !== "close") },
404
+ target: { type: "integer", description: "The control's number from the latest list." },
405
+ name: { type: "string", description: "The control's name, instead of its number. Read fresh before the step." },
406
+ text: { type: "string", description: "type: the text or option. wait: the text to wait for." },
407
+ gone: { type: "string", description: "wait: text that must disappear." },
408
+ key: { type: "string" },
409
+ direction: { type: "string", enum: [...DIRECTIONS] },
410
+ wait_ms: { type: "integer" },
411
+ width: { type: "integer" },
412
+ height: { type: "integer" },
413
+ mobile: { type: "boolean" },
414
+ },
415
+ },
416
+ },
417
+ name: { type: "string", description: "The control's name (or part of it), instead of `target`. Read fresh first, so it also finds what an earlier step opened." },
418
+ gone: { type: "string", description: "wait: text that must no longer be on the page." },
419
+ width: { type: "integer", description: "resize: viewport width in pixels (200 to 4000); 0 with height 0 gives the window's own size back." },
420
+ height: { type: "integer", description: "resize: viewport height in pixels." },
421
+ mobile: { type: "boolean", description: "resize: phone-style (touch, mobile layout)." },
422
+ full: { type: "boolean", description: "look: a picture of the whole page, not only what is in view." },
423
+ annotate: { type: "boolean", description: "look: draw each control's list number over it in the picture, so what you see and the list line up. Costs nothing extra; leave it off when judging how the page really looks." },
424
+ url: { type: "string", description: "look: open this page (a dev server, a site, a file://)." },
425
+ port: { type: "integer", description: "look: attach to the app behind this local debugging port." },
426
+ window: {
427
+ type: "string",
428
+ description: "look: part of a window's title (window route); with `port`, part of the page title to pick among several. " +
429
+ "An app with a custom title bar is listed by its process name.",
430
+ },
431
+ target: { type: "integer", description: "The control's number from the latest list." },
432
+ text: { type: "string", description: "For type: the text to put in (or the option to pick). For wait: the text to wait for." },
433
+ key: { type: "string", description: "For key: e.g. \"Enter\", \"Escape\", \"Tab\", \"Ctrl+A\"." },
434
+ direction: { type: "string", enum: [...DIRECTIONS], description: "For scroll. Default down." },
435
+ wait_ms: {
436
+ type: "integer",
437
+ description: `The most to let the app react before looking again (default ${DEFAULT_SETTLE_MS}); it goes on sooner once the page has stopped changing. For wait: how long to wait for the text (default ${DEFAULT_WAIT_MS / 1000}s, at most ${MAX_WAIT_MS / 1000}s).`,
438
+ },
439
+ show: {
440
+ type: "boolean",
441
+ description: "Leave the app's window on the user's screen. Only when the user asked to see or use the app themselves; " +
442
+ "for testing and debugging leave it out. An app on the hidden desktop cannot be shown this way; the call says so.",
443
+ },
444
+ },
445
+ },
446
+ async execute(args, ctx, call) {
447
+ const req = parseUiArgs(args);
448
+ if (!req.ok)
449
+ return { output: `Error: ${req.error}`, isError: true, summary: "invalid ui call", quiet: true };
450
+ const state = states.get(ctx);
451
+ if (req.action === "close") {
452
+ if (!state)
453
+ return { output: "Nothing was open.", summary: "nothing open" };
454
+ states.delete(ctx);
455
+ if (state.route === "page") {
456
+ if (state.idle)
457
+ clearTimeout(state.idle);
458
+ state.closing = true;
459
+ if (state.page.closed) {
460
+ // Already gone before we got here: the user (or the app) closed it.
461
+ return { output: appClosedMessage(state.title), summary: `${state.title || "the app"} was already closed` };
462
+ }
463
+ await state.page.close();
464
+ }
465
+ await restoreAppWindows(state.hidden ?? []);
466
+ return { output: `Closed ${state.title || "it"}.`, summary: `closed ${state.title || "the app"}` };
467
+ }
468
+ const pageRoute = req.url !== undefined || req.port !== undefined || (!req.window && state?.route === "page");
469
+ const startedAt = Date.now();
470
+ try {
471
+ return pageRoute ? await runPage(req, ctx, state, startedAt, call) : await runWindow(req, ctx, state, startedAt, call);
472
+ }
473
+ catch (error) {
474
+ if (ctx.abortSignal?.aborted)
475
+ return fail("Stopped.");
476
+ return fail(message(error));
477
+ }
478
+ },
479
+ };
480
+ // ── the window route ─────────────────────────────────────────────────────────
481
+ async function runWindow(req, ctx, prior, startedAt, call) {
482
+ if (process.platform !== "win32") {
483
+ return {
484
+ output: `Using windows by title is Windows-only, and this is ${process.platform}. For a web page or dev server ` +
485
+ `pass \`url\`; for an Electron or Tauri app pass \`port\`. ${PORT_RECIPES}`,
486
+ summary: "window route unsupported here",
487
+ };
488
+ }
489
+ const pageOnly = PAGE_ONLY.includes(req.action) ? req.action : req.steps?.find((x) => PAGE_ONLY.includes(x.action))?.action;
490
+ if (pageOnly) {
491
+ return fail(`\`${pageOnly}\` needs the page route: the window route works through accessibility and has no keyboard ` +
492
+ `or pointer to use. For a web page pass \`url\`; for an Electron or Tauri app pass \`port\`. ${PORT_RECIPES}`);
493
+ }
494
+ // Same rule as a screenshot: every call ends in a picture of the user's desktop, so a
495
+ // context with nobody to ask (a sub-agent) does not get to take one.
496
+ if (!ctx.requestApproval) {
497
+ return {
498
+ output: "Cannot use desktop windows from here: it photographs the user's screen and there is nobody to ask in " +
499
+ "this context. A web page can still be tested with `url`; otherwise report what should be checked.",
500
+ summary: "cannot use windows here",
501
+ };
502
+ }
503
+ // Moving from a page to a window: the page and its browser are finished with.
504
+ if (prior?.route === "page") {
505
+ states.delete(ctx);
506
+ if (prior.idle)
507
+ clearTimeout(prior.idle);
508
+ await prior.page.close();
509
+ await restoreAppWindows(prior.hidden ?? []);
510
+ }
511
+ let state = prior?.route === "window" ? prior : undefined;
512
+ // ── which window ──
513
+ let target;
514
+ let windows;
515
+ try {
516
+ windows = await listWindows(ctx.abortSignal);
517
+ }
518
+ catch (error) {
519
+ return fail(`Could not list open windows: ${message(error)}`);
520
+ }
521
+ if (req.window) {
522
+ const pick = pickWindow(req.window, windows);
523
+ if (pick.kind === "none") {
524
+ return fail(`No open window's title contains "${req.window}". ${listTitles(pick.candidates)}\n` +
525
+ `If you just launched the app, give it a moment and call again. An app with a custom title bar ` +
526
+ `is listed by its process name.`);
527
+ }
528
+ if (pick.kind === "ambiguous")
529
+ return fail(ambiguousMessage(req.window, pick.candidates));
530
+ target = { handle: pick.window.handle, title: pick.window.title };
531
+ // A different window: the old numbers mean nothing here.
532
+ if (state && state.handle !== target.handle)
533
+ state = undefined;
534
+ }
535
+ else if (state) {
536
+ const still = windows.find((w) => w.handle === state.handle);
537
+ target = { handle: state.handle, title: still?.title ?? state.title };
538
+ }
539
+ else {
540
+ return fail(`Name what to use: \`window\` for an open window, \`url\` for a web page, or \`port\` for an Electron/Tauri app. ${listTitles(windows)}`);
541
+ }
542
+ // ── act, or just read ──
543
+ let snap;
544
+ let did = "";
545
+ let targetLabel;
546
+ let failWhy;
547
+ const batch = req.steps !== undefined;
548
+ // A batch starts with a read, so its steps have a list to find controls in.
549
+ const queue = req.steps ? [{ ...req, action: "look", steps: undefined }, ...req.steps] : [req];
550
+ const log = [];
551
+ let controlsNow = state?.controls ?? [];
552
+ try {
553
+ for (let i = 0; i < queue.length; i++) {
554
+ const r = queue[i];
555
+ if (r.action === "look") {
556
+ snap = await readControls(target.handle, ctx.abortSignal);
557
+ controlsNow = snap.controls;
558
+ continue;
559
+ }
560
+ if (!state && !batch) {
561
+ return fail(`Look at the window first: call ui with action "look" and window "${target.title}".`);
562
+ }
563
+ let n = r.target;
564
+ if (r.name !== undefined) {
565
+ const found = findControl(controlsNow, r.name);
566
+ if ("error" in found) {
567
+ failWhy = found.error;
568
+ did = `Could not ${r.action} "${r.name}": ${found.error}`;
569
+ }
570
+ else
571
+ n = found.n;
572
+ }
573
+ let line;
574
+ let ok = true;
575
+ let label;
576
+ if (failWhy) {
577
+ line = did;
578
+ ok = false;
579
+ }
580
+ else {
581
+ // A scroll with no target scrolls the first thing that can.
582
+ const num = n ?? controlsNow.findIndex((c) => c.actions.includes("scroll")) + 1;
583
+ if (num === 0)
584
+ return fail("Nothing in the latest list scrolls. Pass `target` with a control inside the area to scroll.");
585
+ const control = controlsNow[num - 1];
586
+ if (!control) {
587
+ return fail(`There is no control [${num}] in the latest list (it has ${controlsNow.length}). Look again if the window changed.`);
588
+ }
589
+ if (r.action === "type" && control.state.includes("password")) {
590
+ return fail(`[${num}] is a password field. Never type into one: ask the user to enter it themselves.`);
591
+ }
592
+ const verb = r.action === "scroll" ? `scroll-${r.direction}` : r.action;
593
+ snap = await actOn(target.handle, control.id, verb, r.text, r.settleMs, ctx.abortSignal);
594
+ controlsNow = snap.controls;
595
+ label = `[${num}] ${controlName(control)}`;
596
+ targetLabel = controlName(control);
597
+ if (snap.acted && !snap.acted.ok) {
598
+ const why = snap.acted.text === "GONE" ? "it is gone: the window changed since the last look" : snap.acted.text;
599
+ failWhy = why;
600
+ ok = false;
601
+ line = `Could not ${r.action} ${label}: ${why}.`;
602
+ }
603
+ else {
604
+ line = describeAction(r.action, label, r.text, r.direction, snap.acted?.text);
605
+ }
606
+ }
607
+ if (batch) {
608
+ log.push({
609
+ line: `${log.length + 1}. ${line}`,
610
+ step: { action: r.action, ...(label ? { target: label.replace(/^\[\d+\] /, "") } : {}), ...(stepInput(r) ? { input: stepInput(r) } : {}), ok, ...(!ok && failWhy ? { why: failWhy } : {}) },
611
+ });
612
+ call?.progress(log.map((l) => l.line).join("\n"));
613
+ }
614
+ else
615
+ did = line;
616
+ if (!ok) {
617
+ if (batch)
618
+ log.push({ line: `Stopped at step ${log.length}; ${queue.length - i - 1} later step${queue.length - i - 1 === 1 ? " was" : "s were"} not run.`, step: { action: "stop", ok: false } });
619
+ break;
620
+ }
621
+ if (snap.error)
622
+ break;
623
+ }
624
+ if (batch) {
625
+ const ran = log.filter((l) => l.step.action !== "stop" && l.step.ok).length;
626
+ did = `Ran ${ran} of ${req.steps.length} steps:\n${log.map((l) => l.line).join("\n")}`;
627
+ if (failWhy)
628
+ did = `Could not finish: ${did}`;
629
+ }
630
+ }
631
+ catch (error) {
632
+ if (ctx.abortSignal?.aborted)
633
+ return fail("Stopped.");
634
+ return fail(`Could not read "${target.title}": ${message(error)}`);
635
+ }
636
+ if (snap.error) {
637
+ states.delete(ctx);
638
+ // Pressing Close, OK or Cancel is SUPPOSED to make the window go away: that is the
639
+ // action working, not failing.
640
+ if (did && !did.startsWith("Could not")) {
641
+ return { output: `${did} The window "${target.title}" closed.`, summary: `${stripNumbers(did)}; the window closed` };
642
+ }
643
+ return fail(`${did ? `${did} ` : ""}${appClosedMessage(target.title)}`);
644
+ }
645
+ const live = state?.live ?? newLiveId();
646
+ const before = state?.texts ?? [];
647
+ let hidden = state?.hidden;
648
+ let hideNote = "";
649
+ if (req.show && target.handle.startsWith(HIDDEN_MARK)) {
650
+ hideNote = CANNOT_SHOW_NOTE;
651
+ }
652
+ else if (req.show && hidden?.length) {
653
+ await restoreAppWindows(hidden);
654
+ hidden = undefined;
655
+ }
656
+ else if (!req.show && !state) {
657
+ const found = await hideAppWindows({ handle: target.handle });
658
+ hidden = found.moved;
659
+ hideNote = found.moved.length ? HIDE_NOTE : found.hiddenDesktop ? HIDDEN_DESKTOP_NOTE : "";
660
+ }
661
+ states.set(ctx, { route: "window", handle: target.handle, title: target.title, controls: snap.controls, live, texts: snap.texts.map((x) => x.text), hidden });
662
+ // ── the picture: the window on top, which is an open dialog when there is one ──
663
+ const shotOf = snap.top ?? { handle: target.handle, title: target.title };
664
+ const shot = await picture(shotOf.title || target.title, (path) => captureWindow(shotOf.handle, path, ctx.abortSignal));
665
+ // A window cannot be streamed the way a page can, so its live view is this picture:
666
+ // one frame per step.
667
+ if (ctx.onLive && shot.image) {
668
+ try {
669
+ const data = (await readFile(shot.image.path)).toString("base64");
670
+ const [w, h] = shot.size.split("x").map(Number);
671
+ ctx.onLive({ kind: "frame", live, ts: Date.now(), data, mime: "image/png", width: w ?? 0, height: h ?? 0 });
672
+ }
673
+ catch {
674
+ // Not shown live; the step's own picture is still in its result.
675
+ }
676
+ }
677
+ const dialog = snap.top && snap.top.handle !== target.handle ? ` A window "${snap.top.title}" is open on top of it.` : "";
678
+ return render({
679
+ did,
680
+ heading: `"${target.title}": ${countControls(snap)}.${dialog} Numbers below refer to this list only.`,
681
+ title: target.title,
682
+ snap,
683
+ shot,
684
+ extra: hideNote,
685
+ step: {
686
+ live,
687
+ action: batch ? "steps" : req.action,
688
+ target: batch ? `${req.steps.length} steps` : targetLabel,
689
+ input: batch ? undefined : stepInput(req),
690
+ app: target.title,
691
+ startedAt,
692
+ failWhy,
693
+ before,
694
+ ...(batch ? { steps: log.map((l) => l.step).filter((x) => x.action !== "stop") } : {}),
695
+ },
696
+ });
697
+ }
698
+ // ── the page route ──────────────────────────────────────────────────────────
699
+ async function runPage(req, ctx, prior, startedAt, call) {
700
+ let state = prior?.route === "page" && !prior.page.closed ? prior : undefined;
701
+ if (prior?.route === "page" && prior.page.closed) {
702
+ states.delete(ctx);
703
+ // The app went away between calls and this call does not open another: the model
704
+ // is acting on an app that is not there. Say so plainly instead of "nothing is open".
705
+ if (req.url === undefined && req.port === undefined && !prior.closing)
706
+ return fail(appClosedMessage(prior.title));
707
+ }
708
+ let othersNote = "";
709
+ let did = "";
710
+ // ── connect: a debugging port, or a hidden browser for an address ──
711
+ if (req.port !== undefined && (!state || state.page.port !== req.port || req.window)) {
712
+ const waited = await waitForApp(req.port, ctx, call);
713
+ if (waited)
714
+ return fail(`${waited} ${PORT_RECIPES}`);
715
+ try {
716
+ const { session, others } = await PageSession.attach(req.port, req.window);
717
+ if (state)
718
+ await closeState(ctx, state);
719
+ state = { route: "page", page: session, title: session.target.title, url: session.target.url, controls: [], live: newLiveId(), texts: [] };
720
+ watchForClose(ctx, state, req.port);
721
+ const found = await hideAppWindows({ port: req.port }, !req.show);
722
+ state.hidden = found.moved;
723
+ state.hiddenDesktop = found.hiddenDesktop;
724
+ othersNote = found.moved.length ? HIDE_NOTE : found.hiddenDesktop ? (req.show ? CANNOT_SHOW_NOTE : HIDDEN_DESKTOP_NOTE) : "";
725
+ if (others.length) {
726
+ othersNote =
727
+ (othersNote ? `${othersNote}\n\n` : "") +
728
+ `Other pages behind port ${req.port}: ${others.map((t) => `"${t.title || shortUrl(t.url)}"`).join(", ")}. ` +
729
+ `Pass \`window\` with part of a title, together with \`port\`, to use one of them.`;
730
+ }
731
+ }
732
+ catch (error) {
733
+ return fail(`Could not reach an app on port ${req.port}: ${message(error)}. ${PORT_RECIPES}`);
734
+ }
735
+ }
736
+ if (req.url !== undefined) {
737
+ try {
738
+ if (!state) {
739
+ const browser = await launchBrowser();
740
+ try {
741
+ const { session } = await PageSession.attach(browser.port, undefined, browser);
742
+ state = { route: "page", page: session, title: "", url: "about:blank", controls: [], live: newLiveId(), texts: [] };
743
+ }
744
+ catch (error) {
745
+ await browser.close();
746
+ throw error;
747
+ }
748
+ }
749
+ await state.page.navigate(req.url);
750
+ state.controls = []; // a new page: the old numbers mean nothing here
751
+ state.listKey = undefined;
752
+ state.lastShot = undefined;
753
+ did = `Opened ${req.url}.`;
754
+ }
755
+ catch (error) {
756
+ if (state)
757
+ states.set(ctx, state);
758
+ return fail(`Could not open ${req.url}: ${message(error)}`);
759
+ }
760
+ }
761
+ if (!state) {
762
+ return fail("Nothing is open yet. Pass `url` to open a web page or dev server, or `port` to use an Electron/Tauri app. " + PORT_RECIPES);
763
+ }
764
+ // Asked to show it: put it back on screen if it was moved. One on the hidden desktop
765
+ // cannot be, and the model is told so rather than left believing it is visible.
766
+ if (req.show && state.hidden?.length) {
767
+ await restoreAppWindows(state.hidden);
768
+ state.hidden = undefined;
769
+ }
770
+ else if (req.show && state.hiddenDesktop && !othersNote.includes(CANNOT_SHOW_NOTE)) {
771
+ othersNote = othersNote ? `${othersNote}\n\n${CANNOT_SHOW_NOTE}` : CANNOT_SHOW_NOTE;
772
+ }
773
+ states.set(ctx, state);
774
+ keepAlive(ctx, state);
775
+ const page = () => state.page;
776
+ const live = state.live;
777
+ // Stream the page while this step happens, for a front end that shows it live.
778
+ const onLive = ctx.onLive;
779
+ const stream = onLive
780
+ ? (frame) => onLive({ kind: "frame", live, ts: frame.ts, data: frame.data, mime: "image/jpeg", width: frame.width, height: frame.height })
781
+ : null;
782
+ if (state.castStop)
783
+ clearTimeout(state.castStop);
784
+ if (stream)
785
+ await page().startCast(stream);
786
+ const cur = state;
787
+ let targetLabel;
788
+ let failWhy;
789
+ /** Text a step produced for the model to read (an inspection), in order. */
790
+ const notes = [];
791
+ /** What the page reported during steps that were read along the way. */
792
+ const acc = { errors: [], warnings: [], dialogs: [] };
793
+ const absorb = (s) => {
794
+ acc.errors.push(...(s.errors ?? []));
795
+ acc.warnings.push(...(s.warnings ?? []));
796
+ acc.dialogs.push(...(s.dialogs ?? []));
797
+ };
798
+ // ── act ──
799
+ const verbWord = { click: "click", type: "type into", hover: "hover over", key: "press a key in", scroll: "scroll", inspect: "inspect" };
800
+ /** One step on the page: what was done in words, and whether it failed. */
801
+ const runStep = async (r) => {
802
+ if (r.action === "look")
803
+ return { did: "", failed: false };
804
+ if (r.action === "back") {
805
+ const res = await page().back(r.settleMs);
806
+ if (!res.startsWith("OK")) {
807
+ const why = res.replace(/^ERR\s*/, "");
808
+ return { did: `Could not go back: ${why}.`, failed: true, why };
809
+ }
810
+ return { did: "Went back.", failed: false };
811
+ }
812
+ if (r.action === "wait") {
813
+ const res = await page().waitFor({ text: r.text || undefined, gone: r.gone, ms: r.waitMs });
814
+ if (!res.startsWith("OK")) {
815
+ const why = res.replace(/^ERR\s*/, "");
816
+ return { did: `Could not wait: ${why}.`, failed: true, why };
817
+ }
818
+ return { did: `Waited: ${res.replace(/^OK\s*/, "")}.`, failed: false };
819
+ }
820
+ if (r.action === "resize") {
821
+ const res = await page().resize(r.width ?? 0, r.height ?? 0, r.mobile);
822
+ return { did: `${res.replace(/^OK\s*/, "").replace(/^./, (c) => c.toUpperCase())}.`, failed: false };
823
+ }
824
+ // A control by its name: read the page again first, since an earlier step of this same
825
+ // call may have opened the menu or the view it is in.
826
+ let n = r.target;
827
+ if (r.name !== undefined) {
828
+ const fresh = await page().read();
829
+ absorb(fresh);
830
+ cur.controls = fresh.controls;
831
+ const found = findControl(cur.controls, r.name);
832
+ if ("error" in found)
833
+ return { did: `Could not ${verbWord[r.action] ?? r.action} "${r.name}": ${found.error}`, failed: true, why: found.error };
834
+ n = found.n;
835
+ }
836
+ if (n !== undefined && cur.controls.length === 0) {
837
+ const why = "Look at the page first: the numbers come from the latest look.";
838
+ return { did: why, failed: true, why };
839
+ }
840
+ const control = n !== undefined ? cur.controls[n - 1] : undefined;
841
+ if (n !== undefined && !control) {
842
+ const why = `There is no control [${n}] in the latest list (it has ${cur.controls.length}). Look again if the page changed.`;
843
+ return { did: why, failed: true, why };
844
+ }
845
+ if (r.action === "type" && control?.state.includes("password")) {
846
+ const why = `[${n}] is a password field. Never type into one: ask the user to enter it themselves.`;
847
+ return { did: why, failed: true, why };
848
+ }
849
+ const label = control ? `[${n}] ${controlName(control)}` : "";
850
+ targetLabel = control ? controlName(control) : undefined;
851
+ if (r.action === "inspect") {
852
+ const res = await page().inspect(control.id);
853
+ if (res.startsWith("ERR")) {
854
+ const raw = res.replace(/^ERR\s*/, "");
855
+ const why = raw === "GONE" ? "it is gone: the page changed since the last look" : raw;
856
+ return { did: `Could not inspect ${label}: ${why}.`, failed: true, why, label };
857
+ }
858
+ notes.push(`Inspection of ${label}:\n${res.replace(/^OK\s*/, "").split("\n").map((l) => ` ${l}`).join("\n")}`);
859
+ return { did: `Inspected ${label}.`, failed: false, label };
860
+ }
861
+ const tabsBefore = r.action === "click" || r.action === "key" ? await page().pageIds() : null;
862
+ const res = r.action === "key"
863
+ ? await page().pressKey(r.key, control?.id ?? null, r.settleMs)
864
+ : await page().act(control?.id ?? "", r.action === "scroll" ? `scroll-${r.direction}` : r.action, r.text, r.settleMs);
865
+ let did;
866
+ let failed = false;
867
+ let why;
868
+ if (res.startsWith("ERR")) {
869
+ const raw = res.replace(/^ERR\s*/, "");
870
+ why = raw === "GONE" ? "it is gone: the page changed since the last look" : raw;
871
+ failed = true;
872
+ did = `Could not ${verbWord[r.action]} ${label || "the page"}: ${why}.`;
873
+ }
874
+ else {
875
+ did = describeAction(r.action, label, r.text, r.direction, res.replace(/^OK\s*/, ""), r.key);
876
+ }
877
+ // A link that opened a new tab: carry on in the tab, as a person would.
878
+ if (tabsBefore && !page().closed) {
879
+ const fresh = await page().newPages(tabsBefore);
880
+ if (fresh.length) {
881
+ cur.switching = true;
882
+ let next;
883
+ try {
884
+ next = await page().switchTo(fresh[fresh.length - 1]);
885
+ }
886
+ finally {
887
+ cur.switching = false;
888
+ }
889
+ cur.page = next;
890
+ watchForClose(ctx, cur, next.port);
891
+ if (stream)
892
+ await next.startCast(stream);
893
+ did += ` It opened a new page (${shortUrl(next.target.url)}), which is now the one in use.`;
894
+ }
895
+ }
896
+ return { did, failed, why, label };
897
+ };
898
+ const batch = req.steps !== undefined;
899
+ const queue = req.steps ?? [req];
900
+ const log = [];
901
+ did = "";
902
+ if (batch && cur.controls.length === 0 && queue.some((s) => s.target !== undefined)) {
903
+ return fail("Look at the page first: the numbers come from the latest look. Or name the control with `name`.");
904
+ }
905
+ for (let i = 0; i < queue.length; i++) {
906
+ const r = queue[i];
907
+ const res = await runStep(r);
908
+ if (batch) {
909
+ log.push({
910
+ line: `${i + 1}. ${res.did || "Looked."}`,
911
+ step: { action: r.action, ...(res.label ? { target: res.label.replace(/^\[\d+\] /, "") } : r.name ? { target: `"${r.name}"` } : {}), ...(stepInput(r) ? { input: stepInput(r) } : {}), ok: !res.failed, ...(res.failed && res.why ? { why: res.why } : {}) },
912
+ });
913
+ // What has been done so far, so a long run shows itself one step after another.
914
+ call?.progress(log.map((l) => l.line).join("\n"));
915
+ }
916
+ else {
917
+ did = res.failed && !res.did.startsWith("Could not") ? `Could not do that: ${res.did}` : res.did;
918
+ }
919
+ if (res.failed) {
920
+ failWhy = res.why;
921
+ if (batch)
922
+ log.push({ line: `Stopped at step ${i + 1}; ${queue.length - i - 1} later step${queue.length - i - 1 === 1 ? " was" : "s were"} not run.`, step: { action: "stop", ok: false } });
923
+ break;
924
+ }
925
+ if (page().closed)
926
+ break;
927
+ }
928
+ if (batch) {
929
+ const ran = log.filter((l) => l.step.action !== "stop" && l.step.ok).length;
930
+ did = `Ran ${ran} of ${queue.length} steps:\n${log.map((l) => l.line).join("\n")}`;
931
+ if (failWhy)
932
+ did = `Could not finish: ${did}`;
933
+ }
934
+ if (page().closed) {
935
+ states.delete(ctx);
936
+ if (did && !did.startsWith("Could not"))
937
+ return { output: `${did} The page closed.`, summary: `${stripNumbers(did).split("\n")[0]}; the page closed` };
938
+ return fail(`${did ? `${did} ` : ""}The page is gone: the app may have quit.`);
939
+ }
940
+ // ── read it again, and take a picture ──
941
+ const snap = await page().read();
942
+ if (acc.errors.length || acc.warnings.length || acc.dialogs.length) {
943
+ snap.errors = [...acc.errors, ...(snap.errors ?? [])].slice(0, 12);
944
+ snap.warnings = [...acc.warnings, ...(snap.warnings ?? [])].filter((w, i, all) => all.indexOf(w) === i).slice(0, 6);
945
+ snap.dialogs = [...acc.dialogs, ...(snap.dialogs ?? [])];
946
+ }
947
+ // A look at one control (by the list the model read), before the list is replaced.
948
+ const lookNum = req.name !== undefined ? (() => { const f = findControl(snap.controls, req.name); return "n" in f ? f.n : undefined; })() : req.target;
949
+ const lookAt = req.action === "look" && !batch && lookNum !== undefined ? (req.name !== undefined ? snap.controls : cur.controls)[lookNum - 1] : undefined;
950
+ const before = cur.texts;
951
+ cur.texts = snap.texts.map((x) => x.text);
952
+ cur.controls = snap.controls;
953
+ cur.title = snap.page?.title || shortUrl(snap.page?.url ?? "");
954
+ cur.url = snap.page?.url ?? cur.url;
955
+ const annotate = req.annotate ? snap.controls.slice(0, 120).map((c, i) => ({ n: i + 1, id: c.id })) : undefined;
956
+ let shot = await picture(cur.title || "page", (path) => page().screenshot(path, { controlId: lookAt?.id, full: req.full, annotate }));
957
+ // The same list and the same picture as a moment ago are not worth sending twice: a typed
958
+ // letter or a hover that changed nothing costs a line, not the whole screen again.
959
+ const listKey = snap.controls.map((c) => `${c.kind}|${c.name}|${c.value}|${c.state.join(",")}`).join("\n");
960
+ const plainLook = req.action === "look" && !batch;
961
+ // A list is sent whole at least every few results: an old result may have been trimmed from the model's context.
962
+ const listSame = !plainLook && !failWhy && cur.listKey === listKey && snap.controls.length > 0 && (cur.sinceList ?? 0) < 3;
963
+ cur.sinceList = listSame ? (cur.sinceList ?? 0) + 1 : 0;
964
+ const fingerprint = page().lastShot;
965
+ if (!plainLook && !failWhy && !req.full && !req.annotate && cur.lastShot && cur.lastShot === fingerprint && shot.image)
966
+ shot = { size: shot.size, same: true };
967
+ cur.listKey = listKey;
968
+ cur.lastShot = fingerprint;
969
+ const info = snap.page;
970
+ const where = info && info.scrollMax > 0
971
+ ? ` Scrolled ${info.scrollY} of ${info.scrollMax}px${info.scrollY < info.scrollMax - 2 ? "; there is more below" : ""}.`
972
+ : "";
973
+ const size = snap.emulated ? ` The viewport is set to ${snap.emulated.width}x${snap.emulated.height}${snap.emulated.mobile ? " (phone-style)" : ""}; resize to 0x0 to give it back.` : "";
974
+ const out = render({
975
+ did,
976
+ heading: `Page "${cur.title}" (${shortUrl(cur.url)}): ${countControls(snap)}.${where}${size} Numbers below refer to this list only.`,
977
+ title: cur.title,
978
+ snap,
979
+ shot,
980
+ listSame,
981
+ external: isLocalUrl(cur.url) ? undefined : cur.url,
982
+ extra: [othersNote, ...notes].filter(Boolean).join("\n\n"),
983
+ step: {
984
+ live,
985
+ action: batch ? "steps" : req.action,
986
+ target: batch ? `${queue.length} steps` : targetLabel,
987
+ input: batch ? undefined : stepInput(req),
988
+ app: `${cur.title || "page"} · ${shortUrl(cur.url)}`,
989
+ startedAt,
990
+ failWhy,
991
+ before,
992
+ ...(batch ? { steps: log.map((l) => l.step).filter((s) => s.action !== "stop") } : {}),
993
+ },
994
+ });
995
+ // Keep streaming a moment longer so what the step set moving is caught, then stop:
996
+ // the live view runs while the agent is acting, not while it is thinking.
997
+ const settled = cur;
998
+ settled.castStop = setTimeout(() => void settled.page.stopCast(), CAST_TAIL_MS);
999
+ settled.castStop.unref?.();
1000
+ return out;
1001
+ }
1002
+ /** How long to wait for an app that is still starting (a first build can take minutes). */
1003
+ const PORT_WAIT_MS = 180_000;
1004
+ /**
1005
+ * Wait for an app the agent is starting to open its debugging port.
1006
+ *
1007
+ * The background command that launches an app announces itself as ready when its dev
1008
+ * server comes up, which for a compiled app (Tauri) is well before the app has finished
1009
+ * building and opened the window that owns the port. Failing at that moment left the
1010
+ * model saying "I'll wait for it to come up" and ending its turn, waiting for a wake-up
1011
+ * that had already been spent. So while a background command is still running, this
1012
+ * waits here instead, saying how long, and gives up only if the command stops or time
1013
+ * runs out. With nothing running there is nothing to wait for, and it answers at once.
1014
+ *
1015
+ * Returns why it gave up, or null when there is a page to attach to.
1016
+ */
1017
+ async function waitForApp(port, ctx, call) {
1018
+ const started = Date.now();
1019
+ let lastWhy = "";
1020
+ for (;;) {
1021
+ try {
1022
+ if (pageTargets(await listTargets(port)).length)
1023
+ return null;
1024
+ lastWhy = `port ${port} is open but has no page yet`;
1025
+ }
1026
+ catch (error) {
1027
+ lastWhy = message(error);
1028
+ }
1029
+ // It was up before and went away: closed, not building. Unless something was started
1030
+ // since, which is the agent restarting it, and that is worth waiting for.
1031
+ const was = portHistory.get(port);
1032
+ if (was && was.closedAt > 0) {
1033
+ const restarted = (ctx.backgroundShells?.running() ?? []).some((sh) => sh.startedAt > was.closedAt);
1034
+ if (!restarted)
1035
+ return appClosedMessage(was.title);
1036
+ }
1037
+ const building = (ctx.backgroundShells?.runningCount() ?? 0) > 0;
1038
+ if (!building) {
1039
+ return (`Could not reach an app on port ${port}: ${lastWhy}, and no background command is running that could be starting it.`);
1040
+ }
1041
+ const waited = Date.now() - started;
1042
+ if (waited >= PORT_WAIT_MS) {
1043
+ return (`Could not reach an app on port ${port} after waiting ${Math.round(waited / 1000)}s: ${lastWhy}. ` +
1044
+ `Check the background command's output with shells: the build may have failed, or the port may be different.`);
1045
+ }
1046
+ if (ctx.abortSignal?.aborted)
1047
+ return "Stopped.";
1048
+ call?.progress(`Waiting for the app on port ${port} to come up (it may still be building)… ${Math.round(waited / 1000)}s`);
1049
+ await new Promise((r) => setTimeout(r, 1000));
1050
+ }
1051
+ }
1052
+ /**
1053
+ * Notice the app going away on its own (the user closed it, or it quit): the live view
1054
+ * is told at once, and the port is remembered as closed so nothing waits for it.
1055
+ */
1056
+ function watchForClose(ctx, state, port) {
1057
+ portHistory.set(port, { closedAt: 0, title: state.title });
1058
+ const onLive = ctx.onLive;
1059
+ const conn = state.page.conn;
1060
+ conn.onClose(() => {
1061
+ // Switching to a tab the app opened closes the old connection on purpose.
1062
+ if (state.switching || state.page.conn !== conn)
1063
+ return;
1064
+ if (state.closing) {
1065
+ portHistory.delete(port); // we closed it; restarting it later is ordinary
1066
+ return;
1067
+ }
1068
+ portHistory.set(port, { closedAt: Date.now(), title: state.title });
1069
+ if (state.castStop)
1070
+ clearTimeout(state.castStop);
1071
+ onLive?.({ kind: "closed", live: state.live });
1072
+ });
1073
+ }
1074
+ /**
1075
+ * After a compaction: what the model should know is still open, or null.
1076
+ *
1077
+ * The summary may not say an app is open, and the control list the numbers point into
1078
+ * is gone from the model's context while this tool still holds it, so a `click 7` after
1079
+ * a compaction would press whatever 7 was an hour ago. The numbers are reset here, so
1080
+ * the next action is refused until the model looks again, and it is told so.
1081
+ */
1082
+ export function uiLiveState(ctx) {
1083
+ const state = states.get(ctx);
1084
+ if (!state)
1085
+ return null;
1086
+ if (state.route === "page" && state.page.closed)
1087
+ return null;
1088
+ state.controls = [];
1089
+ if (state.route === "page") {
1090
+ state.listKey = undefined; // the model no longer has the list, so it is sent whole again
1091
+ state.lastShot = undefined;
1092
+ }
1093
+ const where = state.route === "page"
1094
+ ? `${state.title ? `"${state.title}" ` : ""}(${shortUrl(state.url)})`
1095
+ : `the window "${state.title}"`;
1096
+ return (`The ui tool still has ${where} open, as you left it. Its control numbers from before the compaction ` +
1097
+ `are gone: call ui with action "look" before acting on it.`);
1098
+ }
1099
+ /**
1100
+ * Stop everything this session's `ui` has going (Esc): the page and any hidden browser,
1101
+ * with the app's window put back where it was. Never throws.
1102
+ */
1103
+ export async function stopUi(ctx) {
1104
+ const state = states.get(ctx);
1105
+ if (!state)
1106
+ return;
1107
+ states.delete(ctx);
1108
+ try {
1109
+ if (state.route === "page") {
1110
+ if (state.idle)
1111
+ clearTimeout(state.idle);
1112
+ if (state.castStop)
1113
+ clearTimeout(state.castStop);
1114
+ state.closing = true;
1115
+ await state.page.close();
1116
+ }
1117
+ await restoreAppWindows(state.hidden ?? []);
1118
+ }
1119
+ catch {
1120
+ // Stopping must not fail; whatever is left is closed by the app going away.
1121
+ }
1122
+ }
1123
+ /** Close a page session and forget it. */
1124
+ async function closeState(ctx, state) {
1125
+ state.closing = true;
1126
+ if (state.idle)
1127
+ clearTimeout(state.idle);
1128
+ if (states.get(ctx) === state)
1129
+ states.delete(ctx);
1130
+ await state.page.close();
1131
+ await restoreAppWindows(state.hidden ?? []);
1132
+ }
1133
+ /** A hidden browser left alone for a while is closed, so a session that moved on does not
1134
+ * leave one running until Mindweave exits. */
1135
+ function keepAlive(ctx, state) {
1136
+ if (state.idle)
1137
+ clearTimeout(state.idle);
1138
+ state.idle = setTimeout(() => void closeState(ctx, state), IDLE_CLOSE_MS);
1139
+ state.idle.unref?.();
1140
+ }
1141
+ // ── shared ───────────────────────────────────────────────────────────────────
1142
+ /** Take a picture into a temp file and check it the way every image is checked. */
1143
+ async function picture(title, capture) {
1144
+ try {
1145
+ const dir = await mkdtemp(join(tmpdir(), "mindweave-shot-"));
1146
+ const path = join(dir, `${safeName(title)}.png`);
1147
+ const s = await capture(path);
1148
+ const ref = await describeImage(path, (await stat(path)).size);
1149
+ if (!isRejection(ref))
1150
+ return { image: ref, size: `${s.width}x${s.height}` };
1151
+ }
1152
+ catch {
1153
+ // The list still says what is there; a missing picture is not a failed action.
1154
+ }
1155
+ return { size: "" };
1156
+ }
1157
+ function countControls(snap) {
1158
+ return `${snap.controls.length} control${snap.controls.length === 1 ? "" : "s"}`;
1159
+ }
1160
+ /** What the page reported by itself: its errors and any dialogs. Pure. */
1161
+ export function pageReports(snap) {
1162
+ const parts = [];
1163
+ if (snap.errors?.length) {
1164
+ parts.push(`The page reported ${snap.errors.length} error${snap.errors.length === 1 ? "" : "s"} since the last step:\n` +
1165
+ snap.errors.map((e) => ` - ${e}`).join("\n"));
1166
+ }
1167
+ if (snap.warnings?.length) {
1168
+ parts.push(`The page logged ${snap.warnings.length} warning${snap.warnings.length === 1 ? "" : "s"}:\n` + snap.warnings.map((w) => ` - ${w}`).join("\n"));
1169
+ }
1170
+ if (snap.dialogs?.length)
1171
+ parts.push(snap.dialogs.map((d) => `A ${d}.`).join("\n"));
1172
+ return parts.join("\n\n");
1173
+ }
1174
+ /** The one result shape both routes give back. */
1175
+ function render(o) {
1176
+ const failed = o.did.startsWith("Could not");
1177
+ const fullList = controlList(o.snap);
1178
+ const list = o.listSame ? `The controls are the same as before: ${o.snap.controls.length}, numbered as in the last list.` : fullList;
1179
+ const words = screenText(o.snap);
1180
+ const content = [list, words].filter(Boolean).join("\n\n");
1181
+ const body = o.external
1182
+ ? frameExternal({ tag: "web_page", attrs: { url: o.external }, what: "a web page opened with the ui tool" }, content)
1183
+ : content;
1184
+ const reports = pageReports(o.snap);
1185
+ const output = [
1186
+ o.did,
1187
+ o.heading,
1188
+ body,
1189
+ reports,
1190
+ unnamedNote(o.snap.controls),
1191
+ o.extra ?? "",
1192
+ o.shot.image ? "A picture of it follows this result." : o.shot.same ? "The picture is identical to the last one: nothing on screen changed." : "No picture could be taken of it this time.",
1193
+ ].filter(Boolean).join("\n\n");
1194
+ const errs = o.snap.errors?.length ?? 0;
1195
+ return {
1196
+ output: failed ? `Error: ${output}` : output,
1197
+ isError: failed || undefined,
1198
+ // The user's row reads without the list numbers, which only mean something to the model.
1199
+ summary: (o.step.steps ? batchSummary(o.step.steps, failed) : o.did ? stripNumbers(o.did) : `Looked at ${o.title} (${countControls(o.snap)}${o.shot.size ? `, ${o.shot.size}` : ""})`) +
1200
+ (errs ? ` · ${errs} page error${errs === 1 ? "" : "s"}` : ""),
1201
+ detail: [o.step.steps ? o.did : "", fullList, reports].filter(Boolean).join("\n\n"),
1202
+ images: o.shot.image ? [o.shot.image] : undefined,
1203
+ ui: stepDisplay(o.step, o.snap, failed),
1204
+ };
1205
+ }
1206
+ /** One line for a batch on the user's row: "4 steps: click Open menu, type Name, …". Pure. */
1207
+ export function batchSummary(steps, failed) {
1208
+ const words = steps.map((x) => `${x.action}${x.target ? ` ${x.target.replace(/^[a-z ]+ /, "")}` : ""}`);
1209
+ const ok = steps.filter((x) => x.ok).length;
1210
+ const head = failed ? `${ok} of ${steps.length} steps` : `${steps.length} step${steps.length === 1 ? "" : "s"}`;
1211
+ const body = words.join(", ");
1212
+ return `${head}: ${body.length > 90 ? `${body.slice(0, 89)}…` : body}`;
1213
+ }
1214
+ /** What a step used, for its line in the test: the text typed, the key, the address. */
1215
+ function stepInput(req) {
1216
+ if (req.action === "type")
1217
+ return req.text;
1218
+ if (req.action === "key")
1219
+ return req.key;
1220
+ if (req.action === "scroll")
1221
+ return req.direction;
1222
+ if (req.action === "wait")
1223
+ return req.text || req.gone;
1224
+ if (req.action === "resize")
1225
+ return req.width && req.height ? `${req.width}×${req.height}` : "back to normal";
1226
+ if (req.url)
1227
+ return req.url;
1228
+ if (req.port !== undefined)
1229
+ return `port ${req.port}`;
1230
+ return undefined;
1231
+ }
1232
+ /**
1233
+ * One step as the test view draws it (pure): passed or failed, and the one line that
1234
+ * says why. A failed action says what stopped it; a page error during the step fails it
1235
+ * too, since the click "worked" but the app broke; otherwise the line is what the screen
1236
+ * newly says, which is how a person would know the step did something.
1237
+ */
1238
+ export function stepDisplay(step, snap, failed) {
1239
+ const errors = snap.errors;
1240
+ const dialogs = snap.dialogs;
1241
+ const had = new Set(step.before);
1242
+ const names = new Set(snap.controls.map((c) => c.name));
1243
+ const news = snap.texts.map((x) => x.text).find((t) => !had.has(t) && !names.has(t));
1244
+ const why = failed
1245
+ ? step.failWhy
1246
+ : errors?.length
1247
+ ? errors[0]
1248
+ : news ?? (step.action === "look" ? `${countControls(snap)} on screen` : undefined);
1249
+ return {
1250
+ live: step.live,
1251
+ action: step.action,
1252
+ ...(step.target ? { target: step.target } : {}),
1253
+ ...(step.input ? { input: step.input.length > 80 ? `${step.input.slice(0, 79)}…` : step.input } : {}),
1254
+ ok: !failed && !errors?.length,
1255
+ ...(why ? { why } : {}),
1256
+ ...(step.target?.endsWith("(no name)") ? { warn: "This control has no accessible name" } : {}),
1257
+ ...(errors?.length ? { errors } : {}),
1258
+ ...(dialogs?.length ? { dialogs } : {}),
1259
+ ...(step.steps?.length ? { steps: step.steps } : {}),
1260
+ app: step.app,
1261
+ startedAt: step.startedAt,
1262
+ endedAt: Date.now(),
1263
+ };
1264
+ }
1265
+ /** A sentence for the user's row: the model's list numbers mean nothing there. Pure. */
1266
+ function stripNumbers(did) {
1267
+ return did.replace(/\[\d+\] /g, "").replace(/\.$/, "");
1268
+ }
1269
+ /** "Clicked [4] button "Go"." in the words of what actually happened. Pure. */
1270
+ export function describeAction(action, label, text, direction, how, key) {
1271
+ if (action === "key")
1272
+ return `Pressed ${key ?? "the key"}${label ? ` in ${label}` : ""}.`;
1273
+ if (action === "hover")
1274
+ return `Hovering over ${label}.`;
1275
+ if (action === "type") {
1276
+ if (how?.startsWith("picked "))
1277
+ return `Picked ${how.slice("picked ".length)} in ${label}.`;
1278
+ return `Typed "${text.length > 60 ? `${text.slice(0, 59)}…` : text}" into ${label}.`;
1279
+ }
1280
+ if (action === "scroll") {
1281
+ if (how === "brought into view")
1282
+ return `Brought ${label} into view.`;
1283
+ return label ? `Scrolled ${direction} in ${label}.` : `Scrolled ${direction}.`;
1284
+ }
1285
+ // What the control did: a toggle toggled, a tab was selected, a dropdown opened.
1286
+ const verb = { pressed: "Clicked", toggled: "Toggled", selected: "Selected", expanded: "Opened", collapsed: "Closed" };
1287
+ return `${(how && verb[how]) || "Clicked"} ${label}.`;
1288
+ }
1289
+ function message(error) {
1290
+ return error instanceof Error ? error.message : String(error);
1291
+ }
1292
+ function fail(text) {
1293
+ return { output: `Error: ${text}`, isError: true, summary: text.slice(0, 80) };
1294
+ }
1295
+ //# sourceMappingURL=ui.js.map