@frontera-sdk/cli 1.52.38 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -0,0 +1,413 @@
1
+ import type { BrowserContext } from 'playwright-core'
2
+
3
+ import { acquireChromium } from '../headless'
4
+ import { parseKey } from './keys'
5
+ import { DEVICE_SIZES, HeadlessTab, isProjectAddress, networkText, siteOf, type DeviceWidth } from './headless-tab'
6
+ import { parseWaitInput, WAIT_MAX_MS, type Wait } from './wait'
7
+
8
+ /**
9
+ * The `browser_*` tools without Forge: a headless browser this MCP server
10
+ * process owns. One Chromium context for the process (so a Studio sign-in
11
+ * holds across tabs), tabs that belong to the session that opened them, and
12
+ * the answers Forge's Browser gives, word for word where it can: a snapshot
13
+ * with refs that keep naming their element until the page goes to another
14
+ * document, waits, every action, console, network and screenshots.
15
+ *
16
+ * What differs is that nobody watches. Reading and navigating are free on
17
+ * any site, as in Forge. What Forge asks the person about before doing it
18
+ * outside the project (a click that is not a link, submitting a form, Enter
19
+ * or Space, running script, OK on a dialog, on an address that is not this
20
+ * machine's) is refused here in words: a headless browser has nobody to ask.
21
+ * Never, anywhere: typing into a password field.
22
+ */
23
+
24
+ type Content = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
25
+ export interface HeadlessAnswer {
26
+ content: Content[]
27
+ isError?: true
28
+ /** Where a screenshot belongs, as Forge says it: the tab, its address and its project's folder. */
29
+ meta?: { tabId: string; url: string; root: string }
30
+ }
31
+
32
+ /** `browser_evaluate`'s answer, at most this many characters of JSON. */
33
+ export const EVALUATE_CAP = 20_000
34
+ /** The tabs close, and Chromium with them, after this long without a call. */
35
+ export const SESSION_IDLE_MS = 15 * 60_000
36
+ /** At most this many tabs: the oldest closes when one more opens. */
37
+ const MAX_TABS = 12
38
+
39
+ const text = (t: string): HeadlessAnswer => ({ content: [{ type: 'text', text: t }] })
40
+ const refuse = (t: string): HeadlessAnswer => ({ isError: true, content: [{ type: 'text', text: t }] })
41
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
42
+ const seconds = (ms: number) => `${(ms / 1000).toFixed(1)} s`
43
+
44
+ const NOBODY_WATCHES = 'needs the person\'s yes, and nobody watches a headless browser'
45
+
46
+ function dialogNote(tab: HeadlessTab): string {
47
+ const d = tab.dialog!
48
+ const kind = d.type === 'beforeunload' ? 'leave-page' : d.type
49
+ return `The page opened a ${kind} dialog${d.message ? `: ${JSON.stringify(d.message)}` : ''}${d.type === 'prompt' && d.defaultPrompt ? ` (default ${JSON.stringify(d.defaultPrompt)})` : ''}. The page waits until it is answered: call browser_handle_dialog with accept true or false.`
50
+ }
51
+
52
+ /** Tools that read, or answer the dialog: everything else waits for the page, which a dialog holds. */
53
+ const DIALOG_SAFE = new Set(['browser_handle_dialog', 'browser_console', 'browser_network', 'browser_screenshot', 'browser_tabs'])
54
+
55
+ export class HeadlessBrowserSession {
56
+ private tabs = new Map<string, HeadlessTab>()
57
+ /** Per project folder: the tab the session chose with browser_tabs select. */
58
+ private chosen = new Map<string, string>()
59
+ private context: Promise<{ context: BrowserContext; release: () => void }> | null = null
60
+ private idle: ReturnType<typeof setTimeout> | null = null
61
+
62
+ constructor(private readonly idleMs = SESSION_IDLE_MS) {}
63
+
64
+ private touch(): void {
65
+ if (this.idle) clearTimeout(this.idle)
66
+ this.idle = setTimeout(() => void this.close(), this.idleMs)
67
+ this.idle.unref?.()
68
+ }
69
+
70
+ private async browserContext(): Promise<BrowserContext> {
71
+ if (!this.context) {
72
+ const opening = (async () => {
73
+ const { browser, release } = await acquireChromium()
74
+ try {
75
+ const context = await browser.newContext({ viewport: { width: DEVICE_SIZES.desktop.width, height: DEVICE_SIZES.desktop.height } })
76
+ context.on('close', () => {
77
+ if (this.context === opening) {
78
+ this.context = null
79
+ this.tabs.clear()
80
+ release()
81
+ }
82
+ })
83
+ return { context, release }
84
+ } catch (err) {
85
+ release()
86
+ throw err
87
+ }
88
+ })()
89
+ this.context = opening
90
+ opening.catch(() => {
91
+ if (this.context === opening) this.context = null
92
+ })
93
+ }
94
+ return (await this.context).context
95
+ }
96
+
97
+ /** Close every tab and give Chromium back. */
98
+ async close(): Promise<void> {
99
+ if (this.idle) clearTimeout(this.idle)
100
+ this.idle = null
101
+ const opening = this.context
102
+ this.context = null
103
+ this.tabs.clear()
104
+ this.chosen.clear()
105
+ if (!opening) return
106
+ const held = await opening.catch(() => null)
107
+ if (!held) return
108
+ await held.context.close().catch(() => {})
109
+ held.release()
110
+ }
111
+
112
+ private async open(root: string): Promise<HeadlessTab> {
113
+ const context = await this.browserContext()
114
+ const mine = [...this.tabs.values()].filter((t) => t.alive)
115
+ if (mine.length >= MAX_TABS) {
116
+ const oldest = mine.sort((a, b) => a.agentAt - b.agentAt)[0]!
117
+ this.tabs.delete(oldest.id)
118
+ await oldest.close()
119
+ }
120
+ const tab = await HeadlessTab.open(await context.newPage(), root)
121
+ this.tabs.set(tab.id, tab)
122
+ tab.page.on('close', () => this.tabs.delete(tab.id))
123
+ return tab
124
+ }
125
+
126
+ /** The tab named, the one chosen, or the one acted in last, of this project. */
127
+ private tabFor(root: string, input: Record<string, unknown>): HeadlessTab | HeadlessAnswer {
128
+ const id = str(input.tabId)
129
+ if (id) {
130
+ const tab = this.tabs.get(id)
131
+ return tab && tab.alive && tab.root === root ? tab : refuse(`No tab "${id}" in this project; browser_tabs lists them.`)
132
+ }
133
+ const pick = this.chosen.get(root)
134
+ const picked = pick ? this.tabs.get(pick) : undefined
135
+ if (picked?.alive) return picked
136
+ const mine = [...this.tabs.values()].filter((t) => t.alive && t.root === root).sort((a, b) => b.agentAt - a.agentAt)
137
+ return mine[0] ?? refuse('You have no tab open: browser_open opens one.')
138
+ }
139
+
140
+ private async snapshotText(tab: HeadlessTab, lead: () => string, wait: Wait | null = null): Promise<HeadlessAnswer> {
141
+ await tab.settle(4_000, 350)
142
+ let waited = ''
143
+ let met = true
144
+ if (wait) {
145
+ const r = await tab.waitFor(wait.condition, wait.timeoutMs)
146
+ met = r.met
147
+ waited = r.error ? `\n${r.error}` : r.met ? `\nWaited ${seconds(r.waitedMs)} for ${wait.what}.` : `\nWaited ${seconds(r.waitedMs)} for ${wait.what}; it did not happen. This is the page now.`
148
+ }
149
+ if (tab.dialog) return text(`${lead()}${waited}\n\n${dialogNote(tab)}`)
150
+ const snap = await tab.takeSnapshot()
151
+ const answer = `${lead()}${waited}\n\n${snap.text}`
152
+ return met ? text(answer) : refuse(answer)
153
+ }
154
+
155
+ private async nodeFor(tab: HeadlessTab, ref: string | undefined): Promise<number | HeadlessAnswer> {
156
+ if (!ref) return refuse('ref is required: take a browser_snapshot and use an element\'s [ref=…].')
157
+ if (!tab.snapshot) return refuse('Take a browser_snapshot first: refs come from it.')
158
+ if (tab.navigatedAt > tab.snapshotAt) {
159
+ const snap = await tab.takeSnapshot()
160
+ return refuse(`The page went to another document after your snapshot, so look again before acting. Here is the page now; act on these refs.\n\n${snap.text}`)
161
+ }
162
+ return tab.nodeForRef(ref) ?? refuse(`No element [ref=${ref}] on this page: take a new browser_snapshot.`)
163
+ }
164
+
165
+ /**
166
+ * Acting outside the project needs a yes nobody can give here: the
167
+ * refusal, or null to go ahead. An element in a frame (another site
168
+ * embedded in the page) is acted on in that frame's site.
169
+ */
170
+ private permitted(tab: HeadlessTab, action: string, node?: number): HeadlessAnswer | null {
171
+ const frame = node !== undefined ? tab.frameAddressOf(node) : null
172
+ const url = [tab.url, frame].find((u): u is string => !!u && !isProjectAddress(u))
173
+ if (!url) return null
174
+ return refuse(`To ${action} on ${siteOf(url)} ${NOBODY_WATCHES}. Reading and navigating there are fine. Open this project in Forge to do it with the person watching, or ask them in the terminal.`)
175
+ }
176
+
177
+ private elementName(tab: HeadlessTab, input: Record<string, unknown>, fallback: string): string {
178
+ const said = str(input.element)
179
+ if (said) return said
180
+ const ref = str(input.ref)
181
+ const label = ref ? tab.snapshot?.labels.get(ref) : undefined
182
+ return label ? `${label.role} "${label.name}"` : fallback
183
+ }
184
+
185
+ /** Run one `browser_*` tool for the project at `root`. */
186
+ async run(tool: string, input: Record<string, unknown>, root: string): Promise<HeadlessAnswer> {
187
+ this.touch()
188
+ try {
189
+ return await this.dispatch(tool, input, root)
190
+ } finally {
191
+ this.touch()
192
+ }
193
+ }
194
+
195
+ private async dispatch(tool: string, input: Record<string, unknown>, root: string): Promise<HeadlessAnswer> {
196
+ if (tool === 'browser_open') {
197
+ const url = str(input.url)
198
+ if (!url) return refuse('url is required.')
199
+ const wait = parseWaitInput(input.waitFor as Record<string, unknown> | undefined, null)
200
+ if (wait && 'error' in wait) return refuse(wait.error)
201
+ const tab = await this.open(root)
202
+ tab.addSecret(str(input.redact))
203
+ tab.noteSecretsIn(url)
204
+ const shown = tab.redact(url)
205
+ const error = await tab.load(url)
206
+ if (error) return refuse(`${shown} did not load: ${error}`)
207
+ return this.snapshotText(tab, () => `Opened ${tab.url && tab.url !== 'about:blank' ? tab.url : shown} in tab ${tab.id}.`, wait)
208
+ }
209
+
210
+ if (tool === 'browser_tabs') {
211
+ const action = str(input.action) ?? 'list'
212
+ if (action === 'list') {
213
+ const current = this.tabFor(root, {})
214
+ const tabs = await Promise.all(
215
+ [...this.tabs.values()]
216
+ .filter((t) => t.alive && t.root === root)
217
+ .map(async (t) => ({ tabId: t.id, title: await t.title(), url: t.url, owner: 'you', active: current instanceof HeadlessTab && current.id === t.id, ...(t.dialog ? { dialog: true } : {}) })),
218
+ )
219
+ return text(JSON.stringify({ tabs }, null, 2))
220
+ }
221
+ const tab = this.tabFor(root, input)
222
+ if (!(tab instanceof HeadlessTab)) return tab
223
+ if (action === 'select') {
224
+ this.chosen.set(root, tab.id)
225
+ tab.agentAt = Date.now()
226
+ return this.snapshotText(tab, () => `Your current tab is ${tab.id}.`)
227
+ }
228
+ if (action === 'close') {
229
+ this.tabs.delete(tab.id)
230
+ if (this.chosen.get(root) === tab.id) this.chosen.delete(root)
231
+ await tab.close()
232
+ return text(`Closed ${tab.id}.`)
233
+ }
234
+ return refuse(`Unknown action "${action}": use list, select or close.`)
235
+ }
236
+
237
+ const tab = this.tabFor(root, input)
238
+ if (!(tab instanceof HeadlessTab)) return tab
239
+ if (tab.dialog && !DIALOG_SAFE.has(tool)) return refuse(dialogNote(tab))
240
+ if (tool !== 'browser_console' && tool !== 'browser_network') tab.agentAt = Date.now()
241
+
242
+ switch (tool) {
243
+ case 'browser_navigate': {
244
+ const action = str(input.action) as 'back' | 'forward' | 'reload' | undefined
245
+ const url = str(input.url)
246
+ if (!action && !url) return refuse('Pass url, or action back, forward or reload.')
247
+ if (action && !['back', 'forward', 'reload'].includes(action)) return refuse(`Unknown action "${action}": use back, forward or reload.`)
248
+ const wait = parseWaitInput(input.waitFor as Record<string, unknown> | undefined, null)
249
+ if (wait && 'error' in wait) return refuse(wait.error)
250
+ if (url) {
251
+ tab.addSecret(str(input.redact))
252
+ tab.noteSecretsIn(url)
253
+ const error = await tab.load(url)
254
+ if (error) return refuse(`${tab.redact(url)} did not load: ${error}`)
255
+ } else {
256
+ const error = await tab.go(action!)
257
+ if (error) return refuse(error.startsWith('There is no page') ? error : `${action} did not load: ${error}`)
258
+ }
259
+ return this.snapshotText(tab, () => `Now at ${tab.url}.`, wait)
260
+ }
261
+ case 'browser_snapshot': {
262
+ const wait = parseWaitInput(input.waitFor as Record<string, unknown> | undefined, (ref) => tab.nodeForRef(ref))
263
+ if (wait && 'error' in wait) return refuse(wait.error)
264
+ return this.snapshotText(tab, () => `Tab ${tab.id}.`, wait)
265
+ }
266
+ case 'browser_wait_for': {
267
+ const time = Number(input.time)
268
+ if (Number.isFinite(time) && time > 0 && input.text === undefined && input.textGone === undefined && !input.selector && !input.ref && input.networkIdle !== true) {
269
+ const ms = Math.min(time * 1000, WAIT_MAX_MS)
270
+ await new Promise((r) => setTimeout(r, ms))
271
+ return this.snapshotText(tab, () => `Waited ${seconds(ms)}.`)
272
+ }
273
+ const wait = parseWaitInput(input, (ref) => tab.nodeForRef(ref))
274
+ if (!wait) return refuse('Say what to wait for: text, textGone, selector or ref (with state visible or hidden), networkIdle, or time in seconds.')
275
+ if ('error' in wait) return refuse(wait.error)
276
+ return this.snapshotText(tab, () => `Tab ${tab.id}.`, wait)
277
+ }
278
+ case 'browser_click': {
279
+ const node = await this.nodeFor(tab, str(input.ref))
280
+ if (typeof node !== 'number') return node
281
+ const label = tab.snapshot?.labels.get(str(input.ref)!)
282
+ const element = this.elementName(tab, input, 'the element')
283
+ // Following a link is navigating, which is free; any other click outside the project is acting.
284
+ if (label?.role !== 'link') {
285
+ const no = this.permitted(tab, `click ${element}`, node)
286
+ if (no) return no
287
+ }
288
+ await tab.click(node, { double: input.doubleClick === true, button: (str(input.button) as 'left' | 'right' | 'middle' | undefined) ?? 'left' })
289
+ return this.snapshotText(tab, () => `Clicked ${element}.`)
290
+ }
291
+ case 'browser_type': {
292
+ const node = await this.nodeFor(tab, str(input.ref))
293
+ if (typeof node !== 'number') return node
294
+ if (await tab.isPassword(node)) return refuse('That is a password field. Never type a password: ask the person to sign in, then take a new snapshot.')
295
+ const element = this.elementName(tab, input, 'the field')
296
+ if (input.submit === true) {
297
+ const no = this.permitted(tab, 'submit the form', node)
298
+ if (no) return no
299
+ }
300
+ await tab.type(node, typeof input.text === 'string' ? input.text : '', { clear: input.clear !== false, submit: input.submit === true })
301
+ return this.snapshotText(tab, () => `Typed into ${element}${input.submit === true ? ' and pressed Enter' : ''}.`)
302
+ }
303
+ case 'browser_press_key': {
304
+ const press = parseKey(typeof input.key === 'string' ? input.key : '')
305
+ if ('error' in press) return refuse(press.error)
306
+ let node: number | undefined
307
+ if (str(input.ref)) {
308
+ const found = await this.nodeFor(tab, str(input.ref))
309
+ if (typeof found !== 'number') return found
310
+ if (press.text && (await tab.isPassword(found))) return refuse('That is a password field. Never type a password: ask the person to sign in.')
311
+ node = found
312
+ }
313
+ const keyName = str(input.key)!
314
+ // Enter and Space submit forms and press buttons: outside the project, that is acting.
315
+ if (press.key === 'Enter' || press.key === ' ') {
316
+ const no = this.permitted(tab, `press ${keyName}`, node)
317
+ if (no) return no
318
+ }
319
+ const where = node !== undefined ? ` in ${this.elementName(tab, input, 'the element')}` : ''
320
+ await tab.pressKey(press, node)
321
+ return this.snapshotText(tab, () => `Pressed ${keyName}${where}.`)
322
+ }
323
+ case 'browser_select_option': {
324
+ const node = await this.nodeFor(tab, str(input.ref))
325
+ if (typeof node !== 'number') return node
326
+ const values = Array.isArray(input.values) ? input.values.filter((v): v is string => typeof v === 'string') : typeof input.value === 'string' ? [input.value] : []
327
+ if (!values.length) return refuse('values is required: the options to choose, by value or by label.')
328
+ const element = this.elementName(tab, input, 'the list')
329
+ const chosen = await tab.selectOptions(node, values)
330
+ if ('error' in chosen) return refuse(`Could not choose in ${element}: ${chosen.error}.`)
331
+ return this.snapshotText(tab, () => `Chose ${chosen.values.map((v) => JSON.stringify(v)).join(', ')} in ${element}.`)
332
+ }
333
+ case 'browser_hover': {
334
+ const node = await this.nodeFor(tab, str(input.ref))
335
+ if (typeof node !== 'number') return node
336
+ const element = this.elementName(tab, input, 'the element')
337
+ await tab.hover(node)
338
+ return this.snapshotText(tab, () => `Hovering over ${element}.`)
339
+ }
340
+ case 'browser_evaluate': {
341
+ const expression = str(input.expression) ?? str(input.function)
342
+ if (!expression) return refuse('expression is required: a JavaScript expression, or a function such as () => document.title.')
343
+ // With a ref, the script runs in the frame that element is in (an App embedded in Studio), given the element.
344
+ let node: number | undefined
345
+ if (str(input.ref)) {
346
+ const found = await this.nodeFor(tab, str(input.ref))
347
+ if (typeof found !== 'number') return found
348
+ node = found
349
+ }
350
+ const no = this.permitted(tab, 'run script in the page', node)
351
+ if (no) return no
352
+ const result = await tab.evaluateInPage(expression, EVALUATE_CAP, 10_000, node)
353
+ if (tab.dialog) return refuse(`The script opened a dialog. ${dialogNote(tab)}`)
354
+ if ('error' in result) return refuse(`The script failed: ${result.error}`)
355
+ const clipped = result.length > result.json.length ? ` (the first ${EVALUATE_CAP.toLocaleString('en')} of ${result.length.toLocaleString('en')} characters)` : ''
356
+ return text(`Result, ${result.type}${clipped}:\n${tab.redact(result.json)}`)
357
+ }
358
+ case 'browser_set_viewport': {
359
+ const device = str(input.device)
360
+ const width = Number(input.width)
361
+ const height = Number(input.height)
362
+ if (Number.isFinite(width) && width > 0) {
363
+ if (width < 240 || width > 3840) return refuse('width must be between 240 and 3840.')
364
+ if (Number.isFinite(height) && height > 0 && (height < 240 || height > 4320)) return refuse('height must be between 240 and 4320.')
365
+ await tab.setSize({ width: Math.round(width), height: Number.isFinite(height) && height > 0 ? Math.round(height) : null })
366
+ } else if (device && device in DEVICE_SIZES) {
367
+ await tab.setSize({ device: device as DeviceWidth })
368
+ } else return refuse('Pass device (desktop, tablet or phone), or width (and height) in pixels.')
369
+ await tab.settle(2_000, 200)
370
+ const now = await tab.viewportWidth()
371
+ const label = tab.viewport ? `${tab.viewport.width}${tab.viewport.height ? `×${tab.viewport.height}` : ''} px` : DEVICE_SIZES[tab.device].label
372
+ return text(`Tab ${tab.id} lays out at ${label}; the page is ${now} px wide now.`)
373
+ }
374
+ case 'browser_handle_dialog': {
375
+ const dialog = tab.dialog
376
+ if (!dialog) return refuse('No dialog is open in this tab.')
377
+ const accept = input.accept !== false
378
+ if (accept && dialog.type !== 'alert') {
379
+ const no = this.permitted(tab, `answer OK to "${dialog.message.slice(0, 120)}"`)
380
+ if (no) return no
381
+ }
382
+ try {
383
+ await tab.answerDialog(accept, typeof input.promptText === 'string' ? input.promptText : undefined)
384
+ } catch (err) {
385
+ return refuse(`The dialog could not be answered (${(err as Error).message}).`)
386
+ }
387
+ return this.snapshotText(tab, () => `${accept ? 'Accepted' : 'Dismissed'} the ${dialog.type} ${JSON.stringify(dialog.message.slice(0, 200))}.`)
388
+ }
389
+ case 'browser_screenshot': {
390
+ if (!tab.dialog) await tab.settle(3_000, 300)
391
+ const data = await tab.screenshot({ fullPage: input.fullPage === true })
392
+ return { content: [{ type: 'text', text: `Tab ${tab.id}: ${tab.url}` }, { type: 'image', data, mimeType: 'image/png' }], meta: { tabId: tab.id, url: tab.url, root: tab.root } }
393
+ }
394
+ case 'browser_console': {
395
+ const entries = tab.console.filter((c) => input.onlyErrors !== true || c.level === 'error' || c.level === 'warning').slice(-200)
396
+ return text(entries.length ? entries.map((c) => `[${c.level}] ${c.text}${c.source ? ` (${c.source})` : ''}`).join('\n') : 'No console messages.')
397
+ }
398
+ case 'browser_network': {
399
+ return text(networkText([...tab.network.values()], input.onlyFailed === true))
400
+ }
401
+ default:
402
+ return refuse(`Unknown browser tool "${tool}".`)
403
+ }
404
+ }
405
+ }
406
+
407
+ let processSession: HeadlessBrowserSession | null = null
408
+
409
+ /** The MCP server process's one headless browser session. */
410
+ export function headlessSession(): HeadlessBrowserSession {
411
+ processSession ??= new HeadlessBrowserSession()
412
+ return processSession
413
+ }