@moretea-labs/forge 1.6.0 → 1.7.1

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 (469) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +39 -0
  3. package/CLAUDE.md +4 -0
  4. package/README.md +2 -2
  5. package/README.zh-CN.md +1 -1
  6. package/SKILL.md +2 -2
  7. package/THIRD_PARTY_NOTICES.md +1 -1
  8. package/agents/openai.yaml +1 -1
  9. package/assets/hooks/hook-input.sh +11 -8
  10. package/assets/hooks/lib/session-state.sh +10 -7
  11. package/assets/hooks/lib/workflow-state.sh +13 -177
  12. package/assets/hooks/post-tool-observer.sh +8 -2
  13. package/assets/hooks/prompt-guard.sh +5 -81
  14. package/assets/hooks/session-start-context.sh +56 -1
  15. package/assets/partials/05-workflow.partial.md +1 -1
  16. package/assets/partials-agents/02-operating-mode.partial.md +2 -0
  17. package/assets/partials-agents/03-orchestration.partial.md +1 -1
  18. package/assets/plugin-registry.v1.json +3 -3
  19. package/assets/reference-configs/document-generation.md +1 -1
  20. package/assets/reference-configs/harness-overview.md +1 -1
  21. package/assets/reference-configs/sprint-contracts.md +3 -3
  22. package/assets/skill-version.json +2 -2
  23. package/assets/templates/contract.template.md +2 -7
  24. package/assets/templates/helpers/capture-plan.sh +2 -2
  25. package/assets/templates/helpers/check-architecture-sync.sh +17 -0
  26. package/assets/templates/helpers/check-task-workflow.sh +4 -4
  27. package/assets/templates/helpers/contract-worktree.sh +1 -21
  28. package/assets/templates/helpers/ensure-task-workflow.sh +11 -36
  29. package/assets/templates/helpers/inspect-project-state.ts +8 -6
  30. package/assets/templates/helpers/migrate-project-template.sh +22 -45
  31. package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
  32. package/assets/templates/helpers/new-plan.sh +1 -1
  33. package/assets/templates/helpers/plan-to-todo.sh +12 -37
  34. package/assets/templates/helpers/ship-worktrees.sh +2 -29
  35. package/assets/templates/helpers/verify-contract.sh +1 -111
  36. package/assets/templates/helpers/verify-sprint.sh +3 -180
  37. package/assets/templates/helpers/workflow-contract.ts +5 -1
  38. package/assets/templates/plan.template.md +1 -1
  39. package/assets/templates/review.template.md +9 -34
  40. package/assets/workflow-contract.v1.json +10 -19
  41. package/docs/README.md +10 -10
  42. package/docs/architecture/CURRENT.md +178 -0
  43. package/docs/architecture/EVOLUTION.md +42 -0
  44. package/docs/architecture/current/README.md +4 -156
  45. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
  46. package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
  47. package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
  48. package/docs/architecture/history/README.md +7 -0
  49. package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
  50. package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
  51. package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
  52. package/docs/architecture/history.md +3 -34
  53. package/docs/architecture/index.md +15 -98
  54. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
  55. package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
  56. package/docs/architecture/versions/1.6.md +38 -0
  57. package/docs/architecture/versions/1.7.md +41 -0
  58. package/docs/forge-chatgpt-browser-engine.md +2 -2
  59. package/docs/forge-chatgpt-mcp-setup.md +4 -28
  60. package/docs/forge-chatgpt-supervised-automation.md +3 -3
  61. package/docs/forge-github-issue-launcher.md +1 -1
  62. package/docs/forge-plugin-management.md +1 -1
  63. package/docs/forge-runtime-self-healing-loop.md +1 -1
  64. package/docs/operations/controller-browser-plugin.md +26 -6
  65. package/docs/operations/controller-performance-and-502.md +143 -0
  66. package/docs/operations/controller-reliability-runbook.md +1 -1
  67. package/docs/operations/features.md +2 -2
  68. package/docs/operations/github-repository.md +1 -1
  69. package/docs/operations/homebrew.md +2 -2
  70. package/docs/operations/mcp-tool-exposure.md +5 -3
  71. package/docs/wiki/Architecture.md +6 -6
  72. package/docs/wiki/Implementation.md +2 -2
  73. package/docs/wiki/Integrations.md +2 -2
  74. package/docs/wiki/Work-Lifecycle.md +1 -1
  75. package/package.json +33 -11
  76. package/scripts/activate-source-baseline.ts +17 -13
  77. package/scripts/architecture-event.ts +2 -820
  78. package/scripts/architecture-queue.sh +2 -645
  79. package/scripts/archive-architecture-request.sh +2 -242
  80. package/scripts/archive-workflow.sh +1 -291
  81. package/scripts/benchmark-background-check-overlap.ts +228 -0
  82. package/scripts/benchmark-mcp-host.sh +117 -0
  83. package/scripts/benchmark-mcp-transport.ts +170 -0
  84. package/scripts/benchmark-quality-harness.ts +146 -0
  85. package/scripts/benchmark-route-session-concurrency.ts +207 -6
  86. package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
  87. package/scripts/benchmark-typescript-navigation.ts +186 -0
  88. package/scripts/benchmark-work-efficiency.ts +334 -0
  89. package/scripts/capability-config.ts +2 -392
  90. package/scripts/capability-resolver.ts +2 -503
  91. package/scripts/capture-plan.sh +1 -332
  92. package/scripts/check-agent-tooling.sh +2 -1558
  93. package/scripts/check-architecture-sync.sh +2 -371
  94. package/scripts/check-brain-manifest.sh +2 -253
  95. package/scripts/check-context-files.sh +2 -47
  96. package/scripts/check-deploy-sql-order.sh +2 -115
  97. package/scripts/check-mcp-compatibility.ts +14 -0
  98. package/scripts/check-runtime-architecture.mjs +349 -79
  99. package/scripts/check-skill-version.ts +3 -191
  100. package/scripts/check-source-duplication.mjs +61 -0
  101. package/scripts/check-stable-baseline.ts +92 -0
  102. package/scripts/check-task-sync.sh +2 -63
  103. package/scripts/check-task-workflow.sh +2 -1000
  104. package/scripts/codex-handoff-resume.sh +2 -267
  105. package/scripts/context-contract-sync.sh +2 -514
  106. package/scripts/contract-run.ts +2 -463
  107. package/scripts/contract-worktree.sh +1 -722
  108. package/scripts/ensure-task-workflow.sh +1 -1225
  109. package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
  110. package/scripts/inspect-project-state.ts +4 -279
  111. package/scripts/install-standalone-recovery.ts +24 -0
  112. package/scripts/lib/project-init-lib.sh +22 -46
  113. package/scripts/lib/run-forge-helper.sh +19 -0
  114. package/scripts/lib/run-forge-helper.ts +26 -0
  115. package/scripts/migrate-project-template.sh +1 -1189
  116. package/scripts/migrate-workflow-docs.ts +4 -412
  117. package/scripts/new-plan.sh +1 -180
  118. package/scripts/new-spec.sh +1 -47
  119. package/scripts/new-sprint.sh +1 -46
  120. package/scripts/plan-to-todo.sh +1 -796
  121. package/scripts/prepare-codex-handoff.sh +2 -201
  122. package/scripts/prepare-handoff.sh +1 -78
  123. package/scripts/refresh-current-status.sh +2 -467
  124. package/scripts/run-governed-gate.ts +5 -1
  125. package/scripts/select-agent-context-blocks.sh +2 -64
  126. package/scripts/ship-worktrees.sh +1 -558
  127. package/scripts/smoke-ios-physical-device.ts +4 -2
  128. package/scripts/smoke-runtime-recovery.ts +1 -35
  129. package/scripts/smoke-schedule-engine.ts +1 -20
  130. package/scripts/sprint-backlog.sh +1 -834
  131. package/scripts/stage-runtime-release.ts +2 -1
  132. package/scripts/summarize-failures.sh +2 -123
  133. package/scripts/switch-plan.sh +1 -150
  134. package/scripts/sync-brain-docs.sh +2 -379
  135. package/scripts/sync-generated-authority.mjs +175 -0
  136. package/scripts/verify-contract.sh +2 -703
  137. package/scripts/verify-forge-runtime.sh +0 -1
  138. package/scripts/verify-sprint.sh +1 -677
  139. package/scripts/workflow-contract.ts +4 -205
  140. package/scripts/workstream-sync.sh +2 -261
  141. package/src/cli/agent-jobs/executor-health.ts +1 -1
  142. package/src/cli/commands/init-hook.ts +3 -3
  143. package/src/cli/commands/mcp.ts +9 -5
  144. package/src/cli/commands/plugin.ts +12 -3
  145. package/src/cli/commands/recovery.ts +25 -3
  146. package/src/cli/commands/runtime.ts +55 -3
  147. package/src/cli/commands/security.ts +12 -1
  148. package/src/cli/commands/setup-profile.ts +31 -21
  149. package/src/cli/controller/check-runner.ts +113 -5
  150. package/src/cli/controller/composite-result.ts +1 -1
  151. package/src/cli/controller/context/focus.ts +51 -0
  152. package/src/cli/controller/context/instruction-context.ts +114 -0
  153. package/src/cli/controller/context/known-paths.ts +76 -0
  154. package/src/cli/controller/context/query-planning.ts +110 -0
  155. package/src/cli/controller/context/rule-resolution.ts +532 -0
  156. package/src/cli/controller/context/source-materializer.ts +278 -0
  157. package/src/cli/controller/context/structural.ts +16 -0
  158. package/src/cli/controller/context/types.ts +286 -0
  159. package/src/cli/controller/context-pack.ts +828 -487
  160. package/src/cli/controller/launch-agents.ts +77 -2
  161. package/src/cli/controller/operational-plan.ts +2 -2
  162. package/src/cli/controller/work-mode.ts +6 -4
  163. package/src/cli/editing/edit-session.ts +60 -7
  164. package/src/cli/github/contracts.ts +16 -0
  165. package/src/cli/github/github.ts +135 -16
  166. package/src/cli/github/session.ts +2 -1
  167. package/src/cli/installer/managed-entries.ts +15 -1
  168. package/src/cli/local-bridge/console-automations.ts +54 -78
  169. package/src/cli/local-bridge/console-view-models.ts +1 -35
  170. package/src/cli/local-bridge/facade-api.ts +37 -264
  171. package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
  172. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +23 -13
  173. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
  174. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +6 -4
  175. package/src/cli/local-bridge/frontend/src/lib/api.ts +1 -6
  176. package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
  177. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -2
  178. package/src/cli/local-bridge/frontend/src/styles.css +1 -1
  179. package/src/cli/local-bridge/frontend/src/types.ts +5 -8
  180. package/src/cli/local-bridge/job-store.ts +1 -490
  181. package/src/cli/local-bridge/server.ts +2 -515
  182. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  183. package/src/cli/local-bridge/ui-dist/app.js +3703 -3757
  184. package/src/cli/mcp/auth.ts +5 -5
  185. package/src/cli/mcp/instructions.ts +3 -2
  186. package/src/cli/mcp/legacy-tool-service.ts +10 -222
  187. package/src/cli/mcp/multi-repository.ts +16 -5
  188. package/src/cli/mcp/oauth.ts +175 -26
  189. package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
  190. package/src/cli/mcp/repository-tools.ts +282 -146
  191. package/src/cli/mcp/server.ts +586 -116
  192. package/src/cli/mcp/tool-capability-cache.ts +1 -1
  193. package/src/cli/mcp/tool-contract.ts +14 -0
  194. package/src/cli/mcp/toolset-selection.ts +1 -1
  195. package/src/cli/mcp/toolset.ts +44 -7
  196. package/src/cli/mcp/transports/http.ts +124 -14
  197. package/src/cli/mcp/transports/session-registry.ts +43 -5
  198. package/src/cli/mcp/types.ts +3 -3
  199. package/src/cli/repositories/command-classifier.ts +103 -21
  200. package/src/cli/repositories/command-executor.ts +78 -526
  201. package/src/cli/repositories/command-process.ts +108 -0
  202. package/src/cli/repositories/command-scope.ts +99 -41
  203. package/src/cli/repositories/controller-home.ts +7 -8
  204. package/src/cli/repositories/git-command-executor.ts +2 -0
  205. package/src/cli/repositories/locks.ts +53 -16
  206. package/src/cli/repositories/repository-snapshot.ts +124 -0
  207. package/src/cli/repositories/selected-path-actions.ts +17 -9
  208. package/src/cli/repositories/structured-git.ts +118 -6
  209. package/src/cli/repository/inspector.ts +563 -65
  210. package/src/cli/repository/session-cache.ts +58 -17
  211. package/src/cli/v81-entry.ts +1 -4
  212. package/src/effects/fs-transaction.ts +85 -17
  213. package/src/runtime/context/codegraph-read-provider.ts +233 -2
  214. package/src/runtime/context/codegraph-sidecar.cjs +74 -40
  215. package/src/runtime/context/generic-lsp-provider.ts +355 -0
  216. package/src/runtime/context/lsp-client.ts +283 -0
  217. package/src/runtime/context/semantic-navigation-contract.ts +61 -0
  218. package/src/runtime/context/semantic-navigation.ts +223 -0
  219. package/src/runtime/context/swift-navigation.ts +565 -0
  220. package/src/runtime/context/typescript-navigation.ts +226 -0
  221. package/src/runtime/control-plane/dispatch-priority.ts +28 -0
  222. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +48 -16
  223. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
  224. package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
  225. package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
  226. package/src/runtime/control-plane/execution/session-store.ts +18 -0
  227. package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
  228. package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
  229. package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
  230. package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
  231. package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
  232. package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
  233. package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
  234. package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
  235. package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
  236. package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
  237. package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
  238. package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
  239. package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
  240. package/src/runtime/control-plane/execution/work-task-receipt.ts +22 -18
  241. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +156 -11
  242. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +22 -14
  243. package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
  244. package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
  245. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +90 -9
  246. package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
  247. package/src/runtime/control-plane/facade/controller-round-relay.ts +1118 -0
  248. package/src/runtime/control-plane/facade/controller-session-store.ts +302 -12
  249. package/src/runtime/control-plane/facade/goal-workloop-access.ts +17 -1
  250. package/src/runtime/control-plane/facade/goal-workloop.ts +928 -190
  251. package/src/runtime/control-plane/facade/index.ts +3 -0
  252. package/src/runtime/control-plane/facade/plan-contract-store.ts +275 -42
  253. package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
  254. package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
  255. package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
  256. package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
  257. package/src/runtime/control-plane/facade/types.ts +90 -10
  258. package/src/runtime/control-plane/facade/work-contract-store.ts +157 -261
  259. package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
  260. package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
  261. package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
  262. package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
  263. package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
  264. package/src/runtime/control-plane/global-scheduler/maintenance.ts +152 -0
  265. package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
  266. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
  267. package/src/runtime/control-plane/global-scheduler/scheduler.ts +278 -543
  268. package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
  269. package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
  270. package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
  271. package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
  272. package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
  273. package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
  274. package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
  275. package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
  276. package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
  277. package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
  278. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +63 -0
  279. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +365 -117
  280. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +89 -0
  281. package/src/runtime/control-plane/launcher/thin-launcher.ts +244 -11
  282. package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
  283. package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
  284. package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
  285. package/src/runtime/control-plane/release-retention.ts +62 -2
  286. package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
  287. package/src/runtime/control-plane/routing/route-policy.ts +24 -21
  288. package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
  289. package/src/runtime/control-plane/runtime-status-client.ts +0 -2
  290. package/src/runtime/diagnostics/mcp-timing.ts +13 -0
  291. package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
  292. package/src/runtime/diagnostics/performance.ts +24 -1
  293. package/src/runtime/evidence/event-ledger.ts +1 -1
  294. package/src/runtime/evidence/process-check-execution.ts +18 -0
  295. package/src/runtime/execution/jobs/child-reference.ts +6 -2
  296. package/src/runtime/execution/jobs/store.ts +58 -4
  297. package/src/runtime/execution/jobs/types.ts +1 -1
  298. package/src/runtime/execution/managed-workspace.ts +203 -1
  299. package/src/runtime/execution/process-runtime/check-facade.ts +51 -4
  300. package/src/runtime/execution/process-runtime/check-result.ts +88 -0
  301. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
  302. package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
  303. package/src/runtime/execution/process-runtime/command-facade.ts +367 -40
  304. package/src/runtime/execution/process-runtime/gc.ts +38 -6
  305. package/src/runtime/execution/process-runtime/lightweight-managed.ts +1201 -0
  306. package/src/runtime/execution/process-runtime/persisted-check.ts +51 -5
  307. package/src/runtime/execution/process-runtime/resource-claims.ts +10 -6
  308. package/src/runtime/execution/process-runtime/runtime.ts +53 -3
  309. package/src/runtime/execution/process-runtime/store.ts +1 -0
  310. package/src/runtime/execution/process-runtime/types.ts +4 -12
  311. package/src/runtime/execution/thin-harness/async-process.ts +12 -2
  312. package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
  313. package/src/runtime/execution/work-activity.ts +21 -0
  314. package/src/runtime/execution/workers/executor.ts +18 -61
  315. package/src/runtime/gateway/mcp/execution-tools.ts +35 -1852
  316. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
  317. package/src/runtime/gateway/mcp/process-tools.ts +83 -37
  318. package/src/runtime/gateway/mcp/router.ts +186 -84
  319. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +565 -0
  320. package/src/runtime/gateway/mcp/runtime-tools.ts +2378 -1947
  321. package/src/runtime/plugins/app-store-connect-adapter.ts +216 -47
  322. package/src/runtime/plugins/browser-adapter.ts +2662 -829
  323. package/src/runtime/plugins/browser-automation-service.ts +10 -0
  324. package/src/runtime/plugins/browser-handoff-host.ts +2 -25
  325. package/src/runtime/plugins/browser-handoff.ts +110 -5
  326. package/src/runtime/plugins/browser-macos-bridge.ts +701 -63
  327. package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
  328. package/src/runtime/plugins/browser-native-session-service.ts +222 -0
  329. package/src/runtime/plugins/browser-node-bridge.ts +6 -34
  330. package/src/runtime/plugins/browser-provider-registry.ts +109 -0
  331. package/src/runtime/plugins/browser-runtime-contract.ts +154 -0
  332. package/src/runtime/plugins/browser-runtime.ts +193 -0
  333. package/src/runtime/plugins/browser-session-authority.ts +332 -0
  334. package/src/runtime/plugins/browser-session-store.ts +121 -0
  335. package/src/runtime/plugins/browser-session-types.ts +98 -0
  336. package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
  337. package/src/runtime/plugins/desktop-operator-registration.ts +10 -4
  338. package/src/runtime/plugins/external-adapter.ts +358 -3
  339. package/src/runtime/plugins/external-unix-socket.ts +7 -1
  340. package/src/runtime/plugins/first-party-registry.ts +2 -0
  341. package/src/runtime/plugins/github-adapter.ts +125 -0
  342. package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
  343. package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
  344. package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
  345. package/src/runtime/plugins/ios-adapter.ts +106 -16
  346. package/src/runtime/plugins/ios-agent-device.ts +234 -64
  347. package/src/runtime/plugins/ios-physical-device.ts +1143 -392
  348. package/src/runtime/plugins/lightweight-action.ts +153 -0
  349. package/src/runtime/plugins/local-system-adapter.ts +123 -6
  350. package/src/runtime/plugins/macos-capability-broker.ts +47 -9
  351. package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
  352. package/src/runtime/plugins/plugin-management-adapter.ts +49 -8
  353. package/src/runtime/plugins/resend-adapter.ts +1 -1
  354. package/src/runtime/plugins/store.ts +330 -33
  355. package/src/runtime/plugins/types.ts +38 -3
  356. package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
  357. package/src/runtime/projections/git-status-sampler.ts +16 -16
  358. package/src/runtime/projections/materialized-view.ts +28 -10
  359. package/src/runtime/recovery/maintenance-executor.ts +426 -25
  360. package/src/runtime/root/mcp-transport.ts +180 -10
  361. package/src/runtime/root/package-connector-service.ts +230 -10
  362. package/src/runtime/root/package-runtime-release.ts +143 -17
  363. package/src/runtime/root/package-runtime-service.ts +461 -22
  364. package/src/runtime/root/release-manifest.ts +144 -18
  365. package/src/runtime/root/release-materialize.ts +125 -32
  366. package/src/runtime/root/release-store.ts +48 -11
  367. package/src/runtime/root/runtime.ts +23 -5
  368. package/src/runtime/root/service.ts +100 -29
  369. package/src/runtime/root/types.ts +12 -0
  370. package/src/runtime/safe-tooling/ios-development.ts +321 -63
  371. package/src/runtime/shared/trusted-executable.ts +62 -0
  372. package/src/runtime/shared/trusted-node-executable.ts +48 -0
  373. package/src/runtime/standalone-recovery/core.ts +1079 -240
  374. package/src/runtime/standalone-recovery/entry.ts +100 -24
  375. package/src/runtime/standalone-recovery/installer.ts +20 -4
  376. package/src/runtime/standalone-recovery/service-labels.ts +2 -0
  377. package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
  378. package/src/runtime/workflow/schedules/browser-probe.ts +81 -3
  379. package/src/runtime/workflow/schedules/engine.ts +364 -202
  380. package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
  381. package/src/runtime/workflow/schedules/settlement.ts +64 -17
  382. package/src/runtime/workflow/schedules/store.ts +209 -19
  383. package/src/runtime/workflow/schedules/types.ts +2 -4
  384. package/src/runtime/workflow/schedules/work-continuation.ts +26 -13
  385. package/src/testing/test-governance.ts +8 -40
  386. package/assets/reference-configs/heartbeat-triage.md +0 -57
  387. package/assets/templates/helpers/harness-trace-grade.sh +0 -150
  388. package/assets/templates/helpers/heartbeat-triage.sh +0 -361
  389. package/assets/templates/helpers/maintenance-triage.sh +0 -167
  390. package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
  391. package/docs/architecture/current/architecture-invariants.md +0 -331
  392. package/docs/architecture/current/automation-and-schedule-engine.md +0 -459
  393. package/docs/architecture/current/codegraph-context-provider.md +0 -238
  394. package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
  395. package/docs/architecture/current/controller-repository-decoupling.md +0 -148
  396. package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -373
  397. package/docs/architecture/current/entity-model.md +0 -612
  398. package/docs/architecture/current/external-plugin-governance.md +0 -396
  399. package/docs/architecture/current/failure-recovery.md +0 -447
  400. package/docs/architecture/current/governance.md +0 -213
  401. package/docs/architecture/current/human-interaction-plane.md +0 -87
  402. package/docs/architecture/current/implementation-status.md +0 -128
  403. package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
  404. package/docs/architecture/current/legacy-governance.md +0 -58
  405. package/docs/architecture/current/local-workspace-target-grants.md +0 -70
  406. package/docs/architecture/current/migration-roadmap.md +0 -105
  407. package/docs/architecture/current/multi-repository-execution.md +0 -371
  408. package/docs/architecture/current/operations-runbook.md +0 -45
  409. package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
  410. package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
  411. package/docs/architecture/current/repository-access-modes.md +0 -102
  412. package/docs/architecture/current/route-policy.md +0 -15
  413. package/docs/architecture/current/route-session-concurrency.md +0 -85
  414. package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
  415. package/docs/architecture/current/runtime-directory-map.md +0 -49
  416. package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
  417. package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
  418. package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
  419. package/docs/architecture/current/system-overview.md +0 -169
  420. package/docs/architecture/current/target-requirements-traceability.md +0 -202
  421. package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
  422. package/docs/architecture/current/thin-harness-v1.md +0 -321
  423. package/docs/architecture/current/verification-and-release-gates.md +0 -391
  424. package/docs/forge-autonomous-goal-loop.md +0 -9
  425. package/docs/forge-provider-configuration.md +0 -7
  426. package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -93
  427. package/docs/operations/gmail-assistant-routines.md +0 -186
  428. package/docs/operations/personal-assistant-operator-guide.md +0 -61
  429. package/docs/operations/provider-configuration.md +0 -31
  430. package/scripts/harness-trace-grade.sh +0 -150
  431. package/scripts/heartbeat-triage.sh +0 -361
  432. package/scripts/maintenance-triage.sh +0 -167
  433. package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
  434. package/src/cli/repositories/goal-registry.ts +0 -239
  435. package/src/runtime/assistant/action-proposals.ts +0 -279
  436. package/src/runtime/assistant/intent.ts +0 -395
  437. package/src/runtime/assistant/model-provider.ts +0 -460
  438. package/src/runtime/assistant/openapi.ts +0 -223
  439. package/src/runtime/assistant/policy.ts +0 -89
  440. package/src/runtime/assistant/readiness.ts +0 -258
  441. package/src/runtime/assistant/routine-runtime.ts +0 -477
  442. package/src/runtime/assistant/schedule-binding.ts +0 -171
  443. package/src/runtime/assistant/standing-grants.ts +0 -375
  444. package/src/runtime/assistant/store.ts +0 -243
  445. package/src/runtime/assistant/types.ts +0 -135
  446. package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
  447. package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
  448. package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
  449. package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
  450. package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
  451. package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
  452. package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
  453. package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
  454. package/src/runtime/control-plane/goal-loop/index.ts +0 -13
  455. package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
  456. package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
  457. package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
  458. package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
  459. package/src/runtime/control-plane/goal-loop/types.ts +0 -378
  460. package/src/runtime/control-plane/startup-recovery.ts +0 -198
  461. package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
  462. package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
  463. package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
  464. package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
  465. package/src/runtime/workflow/findings/store.ts +0 -204
  466. package/src/runtime/workflow/findings/types.ts +0 -58
  467. package/src/runtime/workflow/portfolio/engine.ts +0 -20
  468. package/src/runtime/workflow/portfolio/store.ts +0 -185
  469. package/src/runtime/workflow/portfolio/types.ts +0 -29
@@ -1,26 +1,50 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
2
  import { createRequire } from 'module';
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
4
- import { basename, dirname, join, relative, resolve } from 'path';
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'fs';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
5
5
  import type {
6
- AssistantPluginActionDescriptor,
7
6
  AssistantPluginActionExecutionInput,
8
- AssistantPluginCapability,
7
+ AssistantPluginAuthorizationContext,
9
8
  AssistantPluginHealth,
10
9
  AssistantPluginManifest,
11
- AssistantPluginPermissionScope,
12
10
  } from './types';
13
11
  import { AssistantPluginError, toAssistantPluginError } from './errors';
12
+ import {
13
+ browserActions,
14
+ browserCapabilities,
15
+ browserPermissions,
16
+ } from './browser-manifest-surface';
14
17
  import { executeBrowserActionThroughNode, shouldUseBrowserNodeBridge } from './browser-node-bridge';
18
+ import { BrowserProviderUnavailableBeforeActionError } from './browser-provider-registry';
19
+ import { MAX_BROWSER_CDP_ENDPOINT_CANDIDATES } from './browser-runtime-contract';
20
+ import {
21
+ ALL_BROWSER_PROVIDER_CAPABILITIES,
22
+ browserActionCanReplayAfterDispatch as canReplayBrowserActionAfterDispatch,
23
+ executeBrowserRuntimeAction,
24
+ invalidateBrowserRuntime,
25
+ } from './browser-runtime';
26
+ import { getExternalPluginAdapter } from './external-adapter';
27
+ import {
28
+ currentBrowserSessionAuthorityContext,
29
+ DEFAULT_BROWSER_SESSION_LIST_LIMIT,
30
+ MAX_BROWSER_SESSION_LIST_LIMIT,
31
+ withBrowserSessionAuthorityContext,
32
+ } from './browser-session-authority';
15
33
  import {
16
34
  closeMacOsBrowserOwnedTab,
17
35
  createMacOsBrowserOwnedPage,
18
36
  reattachMacOsBrowserOwnedPage,
19
37
  discoverMacOsBrowserAttachment,
38
+ invalidateMacOsBrowserPageHandle,
39
+ invalidateMacOsBrowserPageHandles,
40
+ macOsBrowserPageHandleStale,
20
41
  getMacOsBrowserAttachObservation,
42
+ listMacOsBrowserTabs,
43
+ readMacOsBrowserOwnedTabMetadata,
21
44
  macOsActiveBrowserAttachSupported,
22
45
  macOsBrowserJavaScriptAutomationDisabled,
23
46
  type MacOsBrowserAttachAttempt,
47
+ type MacOsBrowserCreateTabEvidence,
24
48
  type MacOsBrowserProduct,
25
49
  type MacOsBrowserTabRef,
26
50
  } from './browser-macos-bridge';
@@ -29,31 +53,61 @@ import {
29
53
  assertBrowserSessionAvailable,
30
54
  cancelBrowserHandoff,
31
55
  getBrowserHandoff,
56
+ isRuntimeManagedBrowserHandoff,
32
57
  listBrowserHandoffs,
33
- resumeBrowserHandoff,
58
+ resolveRuntimeManagedBrowserHandoff,
59
+ resumeBrowserHandoffAndWait,
34
60
  startBrowserHandoff,
61
+ startRuntimeManagedBrowserHandoff,
35
62
  } from './browser-handoff';
63
+ import {
64
+ browserStateDir,
65
+ findBrowserSession,
66
+ listSavedBrowserSessions,
67
+ removeBrowserSession,
68
+ requireBrowserSession,
69
+ saveBrowserSession,
70
+ } from './browser-session-store';
71
+ import {
72
+ closeTrackedNativeOwnedSession,
73
+ inspectNativeOwnedSessions,
74
+ nativeTabInventoryUnsupported,
75
+ nativeOwnedAliasSessionIds,
76
+ } from './browser-native-session-service';
77
+ export { browserActionCanReplayAfterDispatch } from './browser-runtime';
78
+ import type {
79
+ BrowserCdpAttachFallback,
80
+ BrowserConnectionFallback,
81
+ BrowserMode,
82
+ BrowserProfileMode,
83
+ BrowserSessionResumeDiagnostic,
84
+ BrowserSessionState,
85
+ BrowserSessionConnectionState,
86
+ BrowserSessionInventoryItem,
87
+ BrowserTabMatchReason,
88
+ BrowserTabResumeState,
89
+ CdpAttachAttempt,
90
+ } from './browser-session-types';
36
91
 
37
92
  const BROWSER_PLUGIN_ID = 'browser';
38
93
  const CONFIG_ROOT = '.forge/plugins';
39
- const STATE_ROOT = '.forge/browser';
40
94
  const DEFAULT_TIMEOUT_MS = 30_000;
41
95
  const DEFAULT_MAX_TEXT_CHARS = 20_000;
42
96
  const DEFAULT_POST_ACTION_WAIT_MS = 750;
43
97
  const DEFAULT_CDP_DISCOVERY_TIMEOUT_MS = 1_500;
44
98
  const MAX_CDP_DISCOVERY_TIMEOUT_MS = 5_000;
45
- const MAX_CDP_ENDPOINT_CANDIDATES = 5;
46
99
 
47
100
  type WaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
48
101
  type WaitForSelectorState = 'attached' | 'detached' | 'visible' | 'hidden';
49
- type BrowserMode = 'attach_preferred' | 'managed_persistent' | 'isolated';
50
- type BrowserProfileMode = 'repo_local' | 'custom';
51
102
  type BrowserChannel = 'chromium' | 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary';
52
- type BrowserCdpAttachFallback = 'managed_persistent' | 'fail_closed';
53
103
  type BrowserNativeAttachMode = 'auto' | 'disabled';
54
104
 
105
+ const CURRENT_BROWSER_CONFIG_SCHEMA_VERSION = 2 as const;
106
+ const DEFAULT_USER_BROWSER_CHANNEL: BrowserChannel = 'chrome';
107
+ const DEFAULT_USER_NATIVE_BROWSER_CANDIDATES: MacOsBrowserProduct[] = ['chrome'];
108
+
55
109
  interface BrowserPluginConfig {
56
- schemaVersion: 1;
110
+ schemaVersion: 2;
57
111
  enabled: boolean;
58
112
  provider: 'playwright';
59
113
  browserMode?: BrowserMode;
@@ -71,41 +125,23 @@ interface BrowserPluginConfig {
71
125
  defaultTimeoutMs?: number;
72
126
  }
73
127
 
74
- interface BrowserTabResumeState {
75
- key: string;
76
- index: number;
77
- url: string;
78
- title?: string;
79
- matchedBy: BrowserTabMatchReason;
80
- inventoryCount: number;
81
- capturedAt: string;
82
- ownership?: 'plugin_owned' | 'user_owned';
83
- ownerToken?: string;
84
- windowId?: string;
85
- tabId?: string;
86
- }
87
-
88
- interface BrowserSessionConnectionState {
89
- mode: BrowserMode;
90
- activeMode: BrowserMode;
91
- provider: 'playwright-cdp' | 'playwright-persistent-context' | 'macos-apple-events';
92
- endpoint?: string;
93
- browserVersion?: string;
94
- browserProduct?: MacOsBrowserProduct;
95
- fallback?: BrowserConnectionFallback;
96
- tab?: BrowserTabResumeState;
97
- sessionResume?: BrowserSessionResumeDiagnostic;
98
- }
128
+ type PersistedBrowserPluginConfig = Partial<Omit<BrowserPluginConfig, 'schemaVersion'>> & {
129
+ schemaVersion?: 1 | 2;
130
+ };
99
131
 
100
- interface BrowserSessionState {
101
- schemaVersion: 1;
102
- sessionId: string;
103
- url: string;
104
- title?: string;
105
- createdAt: string;
106
- updatedAt: string;
107
- browser?: BrowserSessionConnectionState;
108
- }
132
+ type BrowserSessionInventory = {
133
+ sessions: BrowserSessionInventoryItem[];
134
+ scannedSessionCount: number;
135
+ savedSessionCount: number;
136
+ liveManagedSessionCount: number;
137
+ liveNativeSessionCount: number;
138
+ unverifiedSessionCount: number;
139
+ deadManagedSessionCount: number;
140
+ deadNativeSessionCount: number;
141
+ closedInvalidNativeSessionCount: number;
142
+ failedNativeCleanupCount: number;
143
+ prunedSessionCount: number;
144
+ };
109
145
 
110
146
  interface BrowserActionTarget {
111
147
  sessionId: string;
@@ -138,6 +174,20 @@ type BrowserLike = {
138
174
  disconnect?(): Promise<void> | void;
139
175
  };
140
176
 
177
+ type FrameLike = {
178
+ url(): string;
179
+ name(): string;
180
+ evaluate<T>(expression: string | ((...args: unknown[]) => unknown), arg?: unknown): Promise<T>;
181
+ frameElement?(): Promise<{ boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null> }>;
182
+ };
183
+
184
+ type BrowserDownloadLike = {
185
+ suggestedFilename(): string;
186
+ saveAs(path: string): Promise<void>;
187
+ failure?(): Promise<string | null>;
188
+ delete?(): Promise<void>;
189
+ };
190
+
141
191
  type PageLike = {
142
192
  goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
143
193
  reload?(options?: Record<string, unknown>): Promise<unknown>;
@@ -160,10 +210,24 @@ type PageLike = {
160
210
  uncheck?(selector: string, options?: Record<string, unknown>): Promise<void>;
161
211
  waitForSelector(selector: string, options?: Record<string, unknown>): Promise<unknown>;
162
212
  waitForLoadState?(state?: string, options?: Record<string, unknown>): Promise<void>;
213
+ waitForEvent?(event: 'download', options?: Record<string, unknown>): Promise<BrowserDownloadLike>;
214
+ frames?(): FrameLike[];
215
+ mainFrame?(): FrameLike;
163
216
  locator?(selector: string): { screenshot(options?: Record<string, unknown>): Promise<Buffer> };
164
217
  on?(event: string, handler: (...args: unknown[]) => void): void;
165
218
  bringToFront?(): Promise<void>;
166
- keyboard?: { press(key: string): Promise<void> };
219
+ foregroundState?(): Promise<{ frontmost: boolean; active: boolean }>;
220
+ trustedInput?(input: Record<string, unknown>): Promise<void>;
221
+ mouse?: {
222
+ click(x: number, y: number, options?: { button?: 'left' | 'middle' | 'right'; clickCount?: number }): Promise<void>;
223
+ move(x: number, y: number, options?: { steps?: number }): Promise<void>;
224
+ wheel(deltaX: number, deltaY: number): Promise<void>;
225
+ down(options?: { button?: 'left' | 'middle' | 'right' }): Promise<void>;
226
+ up(options?: { button?: 'left' | 'middle' | 'right' }): Promise<void>;
227
+ };
228
+ keyboard?: { press(key: string): Promise<void>; insertText?(text: string): Promise<void> };
229
+ setInputFiles?: (selector: string, files: string | string[]) => Promise<void>;
230
+ close?(): Promise<void>;
167
231
  };
168
232
 
169
233
  type PlaywrightRuntime = {
@@ -175,29 +239,73 @@ type PlaywrightRuntime = {
175
239
 
176
240
  interface BrowserPluginRuntimeHooks {
177
241
  now(): string;
178
- moduleAvailable(name: string): boolean;
179
- loadPlaywright(): PlaywrightRuntime;
242
+ moduleAvailable(name: string, repoRoot?: string): boolean;
243
+ loadPlaywright(repoRoot?: string): PlaywrightRuntime;
180
244
  fetchJson(url: string, timeoutMs: number): Promise<unknown>;
245
+ activateNativeBrowserApplication(input: AssistantPluginActionExecutionInput, product: MacOsBrowserProduct): Promise<void>;
246
+ }
247
+
248
+ const NATIVE_BROWSER_BUNDLE_IDS: Record<MacOsBrowserProduct, string> = {
249
+ chrome: 'com.google.Chrome',
250
+ vivaldi: 'com.vivaldi.Vivaldi',
251
+ };
252
+
253
+ async function activateNativeBrowserApplicationViaDesktopOperator(
254
+ input: AssistantPluginActionExecutionInput,
255
+ product: MacOsBrowserProduct,
256
+ ): Promise<void> {
257
+ const desktopOperator = getExternalPluginAdapter(input.controllerHome, 'desktop_operator');
258
+ if (!desktopOperator) {
259
+ throw new AssistantPluginError(
260
+ 'PLUGIN_BROWSER_NATIVE_FOREGROUND_ACTIVATOR_UNAVAILABLE',
261
+ 'Native browser foreground activation requires the registered Forge Desktop Operator.',
262
+ { retryable: true, details: { browserProduct: product } },
263
+ );
264
+ }
265
+ await desktopOperator.executeAction({
266
+ controllerHome: input.controllerHome,
267
+ repoId: input.repoId,
268
+ repoRoot: input.repoRoot,
269
+ pluginId: 'desktop_operator',
270
+ actionId: 'desktop_session_open',
271
+ requestId: `${input.requestId}:native-browser-foreground:${product}`,
272
+ args: { bundle_id: NATIVE_BROWSER_BUNDLE_IDS[product], launch: false, activate: true },
273
+ origin: input.origin,
274
+ jobId: input.jobId,
275
+ timeoutMs: input.timeoutMs,
276
+ signal: input.signal,
277
+ deadlineAtMs: input.deadlineAtMs,
278
+ });
181
279
  }
182
280
 
183
281
  const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
184
282
  now: () => new Date().toISOString(),
185
- moduleAvailable: (name: string) => {
186
- try {
187
- createRequire(import.meta.url).resolve(name);
188
- return true;
189
- } catch {
190
- return false;
283
+ moduleAvailable: (name: string, repoRoot?: string) => {
284
+ const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
285
+ .filter((value): value is string => Boolean(value));
286
+ for (const anchor of anchors) {
287
+ try {
288
+ createRequire(anchor).resolve(name);
289
+ return true;
290
+ } catch {
291
+ // Try the next trusted dependency anchor.
292
+ }
191
293
  }
294
+ return false;
192
295
  },
193
- loadPlaywright: () => {
194
- try {
195
- return createRequire(import.meta.url)('playwright') as PlaywrightRuntime;
196
- } catch {
197
- throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin requires playwright. Run bun install before using browser actions.', {
198
- retryable: false,
199
- });
296
+ loadPlaywright: (repoRoot?: string) => {
297
+ const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
298
+ .filter((value): value is string => Boolean(value));
299
+ for (const anchor of anchors) {
300
+ try {
301
+ return createRequire(anchor)('playwright') as PlaywrightRuntime;
302
+ } catch {
303
+ // Try the next trusted dependency anchor.
304
+ }
200
305
  }
306
+ throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin requires playwright. Run bun install before using browser actions.', {
307
+ retryable: false,
308
+ });
201
309
  },
202
310
  fetchJson: async (url: string, timeoutMs: number) => {
203
311
  const controller = new AbortController();
@@ -210,11 +318,21 @@ const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
210
318
  clearTimeout(timer);
211
319
  }
212
320
  },
321
+ activateNativeBrowserApplication: activateNativeBrowserApplicationViaDesktopOperator,
213
322
  };
214
323
 
215
324
  let runtimeHooks: BrowserPluginRuntimeHooks = { ...defaultRuntimeHooks };
216
325
  let runtimeHooksCustomized = false;
217
326
 
327
+ interface ManagedBrowserContextState {
328
+ repoRoot: string;
329
+ profilePath: string;
330
+ context: BrowserContextLike;
331
+ pagesBySession: Map<string, PageLike>;
332
+ }
333
+
334
+ const managedBrowserContexts = new Map<string, Promise<ManagedBrowserContextState>>();
335
+
218
336
  export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPluginRuntimeHooks>): void {
219
337
  runtimeHooks = { ...defaultRuntimeHooks, ...hooks };
220
338
  runtimeHooksCustomized = true;
@@ -223,6 +341,7 @@ export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPlugin
223
341
  export function resetBrowserPluginRuntimeHooksForTest(): void {
224
342
  runtimeHooks = { ...defaultRuntimeHooks };
225
343
  runtimeHooksCustomized = false;
344
+ managedBrowserContexts.clear();
226
345
  }
227
346
 
228
347
  function now(): string {
@@ -237,6 +356,13 @@ function positiveNumber(value: unknown, fallback: number): number {
237
356
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
238
357
  }
239
358
 
359
+ function requiredFiniteNumber(value: unknown, name: string, min = -100_000, max = 100_000): number {
360
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
361
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `${name} must be a finite number between ${min} and ${max}.`, { retryable: false });
362
+ }
363
+ return value;
364
+ }
365
+
240
366
  function browserProfileMode(value: unknown): BrowserProfileMode | undefined {
241
367
  return value === 'repo_local' || value === 'custom' ? value : undefined;
242
368
  }
@@ -284,16 +410,12 @@ function configPath(repoRoot: string): string {
284
410
  return join(repoRoot, CONFIG_ROOT, 'browser.json');
285
411
  }
286
412
 
287
- function stateDir(repoRoot: string, name: 'sessions' | 'screenshots' | 'profiles' | 'downloads' | 'diagnostics'): string {
288
- return join(repoRoot, STATE_ROOT, name);
289
- }
290
-
291
413
  function defaultProfileDir(repoRoot: string): string {
292
- return join(stateDir(repoRoot, 'profiles'), 'default');
414
+ return join(browserStateDir(repoRoot, 'profiles'), 'default');
293
415
  }
294
416
 
295
417
  function isolatedProfileDir(repoRoot: string, sessionId: string): string {
296
- return join(stateDir(repoRoot, 'profiles'), 'isolated', sessionId.replace(/[^a-zA-Z0-9_-]/g, '_'));
418
+ return join(browserStateDir(repoRoot, 'profiles'), 'isolated', sessionId.replace(/[^a-zA-Z0-9_-]/g, '_'));
297
419
  }
298
420
 
299
421
  function resolveConfiguredPath(repoRoot: string, value: string): string {
@@ -325,30 +447,47 @@ function resolveProfileSelection(repoRoot: string, profileDir: string, profileDi
325
447
  };
326
448
  }
327
449
 
328
- function normalizeConfig(raw: Partial<BrowserPluginConfig>): BrowserPluginConfig {
450
+ function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig {
329
451
  const normalizedProfileDir = stringValue(raw.profileDir);
330
452
  return {
331
- schemaVersion: 1,
453
+ schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
332
454
  enabled: raw.enabled === true,
333
455
  provider: 'playwright',
334
- browserMode: browserMode(raw.browserMode) ?? 'managed_persistent',
456
+ browserMode: browserMode(raw.browserMode) ?? 'attach_preferred',
335
457
  profileMode: browserProfileMode(raw.profileMode) ?? (normalizedProfileDir ? 'custom' : 'repo_local'),
336
458
  profileDir: normalizedProfileDir,
337
459
  profileDirectory: stringValue(raw.profileDirectory),
338
- browserChannel: browserChannel(raw.browserChannel) ?? 'chromium',
460
+ browserChannel: browserChannel(raw.browserChannel) ?? DEFAULT_USER_BROWSER_CHANNEL,
339
461
  executablePath: stringValue(raw.executablePath),
340
462
  cdpEndpoint: stringValue(raw.cdpEndpoint),
341
- cdpEndpointCandidates: stringList(raw.cdpEndpointCandidates)?.slice(0, MAX_CDP_ENDPOINT_CANDIDATES),
463
+ cdpEndpointCandidates: stringList(raw.cdpEndpointCandidates)?.slice(0, MAX_BROWSER_CDP_ENDPOINT_CANDIDATES),
342
464
  cdpDiscoveryTimeoutMs: typeof raw.cdpDiscoveryTimeoutMs === 'number'
343
465
  ? Math.min(positiveNumber(raw.cdpDiscoveryTimeoutMs, DEFAULT_CDP_DISCOVERY_TIMEOUT_MS), MAX_CDP_DISCOVERY_TIMEOUT_MS)
344
466
  : undefined,
345
467
  cdpAttachFallback: browserCdpAttachFallback(raw.cdpAttachFallback) ?? 'fail_closed',
346
468
  nativeAttachMode: browserNativeAttachMode(raw.nativeAttachMode) ?? 'auto',
347
- nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? ['vivaldi', 'chrome'],
469
+ nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
348
470
  defaultTimeoutMs: typeof raw.defaultTimeoutMs === 'number' ? positiveNumber(raw.defaultTimeoutMs, DEFAULT_TIMEOUT_MS) : undefined,
349
471
  };
350
472
  }
351
473
 
474
+ function migrateLegacyRepositoryConfig(raw: PersistedBrowserPluginConfig): PersistedBrowserPluginConfig {
475
+ if (raw.schemaVersion !== 1) return raw;
476
+ return {
477
+ schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
478
+ enabled: raw.enabled,
479
+ provider: 'playwright',
480
+ browserMode: 'attach_preferred',
481
+ profileMode: 'repo_local',
482
+ browserChannel: DEFAULT_USER_BROWSER_CHANNEL,
483
+ cdpAttachFallback: 'fail_closed',
484
+ nativeAttachMode: 'auto',
485
+ nativeBrowserCandidates: [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
486
+ defaultTimeoutMs: raw.defaultTimeoutMs,
487
+ cdpDiscoveryTimeoutMs: raw.cdpDiscoveryTimeoutMs,
488
+ };
489
+ }
490
+
352
491
  function readJson<T>(path: string): T | undefined {
353
492
  try {
354
493
  return JSON.parse(readFileSync(path, 'utf-8')) as T;
@@ -363,7 +502,70 @@ function writeJson(path: string, value: unknown): void {
363
502
  }
364
503
 
365
504
  function loadConfig(repoRoot: string): BrowserPluginConfig {
366
- return normalizeConfig(readJson<Partial<BrowserPluginConfig>>(configPath(repoRoot)) ?? {});
505
+ const persisted = readJson<PersistedBrowserPluginConfig>(configPath(repoRoot));
506
+ if (!persisted) return normalizeConfig({});
507
+ return normalizeConfig(migrateLegacyRepositoryConfig(persisted));
508
+ }
509
+
510
+ async function resolveBrowserPluginAuthorizationContextInternal(
511
+ input: AssistantPluginActionExecutionInput,
512
+ ): Promise<AssistantPluginAuthorizationContext | undefined> {
513
+ const action = browserActions().find((entry) => entry.actionId === input.actionId);
514
+ if (!action || action.confirmation !== 'authorization' || !action.scopes.includes('browser.interact')) return undefined;
515
+
516
+ const persistedConfig = loadConfig(input.repoRoot);
517
+ const sessionId = stringValue(input.args.session_id);
518
+ const authorizationBrowserMode = parseBrowserModeInput(input.args.browser_mode) ?? persistedConfig.browserMode;
519
+ if (!sessionId || authorizationBrowserMode === 'isolated') return undefined;
520
+ const session = findBrowserSession(input.repoRoot, sessionId);
521
+ const config = effectiveBrowserActionConfig(persistedConfig, input.args, session);
522
+ const connection = session?.browser;
523
+ if (!session || !connection || connection.activeMode === 'isolated') return undefined;
524
+
525
+ let origin: string;
526
+ try {
527
+ const destinationUrl = input.actionId === 'navigate' ? stringValue(input.args.url) : undefined;
528
+ const parsed = new URL(destinationUrl ?? session.url);
529
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
530
+ origin = parsed.origin.toLowerCase();
531
+ } catch {
532
+ return undefined;
533
+ }
534
+
535
+ const profile = selectedProfile(config, input.repoRoot, connection.activeMode, sessionId);
536
+ const providerId = connection.browserProduct ?? connection.provider;
537
+ const identity = {
538
+ provider: connection.provider,
539
+ browserProduct: connection.browserProduct,
540
+ activeMode: connection.activeMode,
541
+ endpoint: connection.endpoint,
542
+ origin,
543
+ browserChannel: config.browserChannel,
544
+ executablePath: config.executablePath ? resolveConfiguredPath(input.repoRoot, config.executablePath) : undefined,
545
+ profileMode: config.profileMode,
546
+ selectedProfilePath: profile.selectedProfilePath,
547
+ profileDirectory: profile.profileDirectory,
548
+ cdpAttachFallback: config.cdpAttachFallback,
549
+ nativeAttachMode: config.nativeAttachMode,
550
+ nativeBrowserCandidates: config.nativeBrowserCandidates,
551
+ };
552
+ return {
553
+ target: {
554
+ kind: 'browser-origin',
555
+ id: `${providerId}@${origin}`,
556
+ identityFingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex'),
557
+ },
558
+ expiresInMinutes: 30 * 24 * 60,
559
+ };
560
+ }
561
+
562
+ export async function resolveBrowserPluginAuthorizationContext(
563
+ input: AssistantPluginActionExecutionInput,
564
+ ): Promise<AssistantPluginAuthorizationContext | undefined> {
565
+ return await withBrowserSessionAuthorityContext(
566
+ { controllerHome: input.controllerHome, repoId: input.repoId },
567
+ () => resolveBrowserPluginAuthorizationContextInternal(input),
568
+ );
367
569
  }
368
570
 
369
571
  export function readBrowserPluginConfiguration(repoRoot: string): { enabled: boolean } {
@@ -377,39 +579,63 @@ function saveConfig(repoRoot: string, patch: Partial<BrowserPluginConfig>): Brow
377
579
  return next;
378
580
  }
379
581
 
380
- function sessionPath(repoRoot: string, sessionId: string): string {
381
- return join(stateDir(repoRoot, 'sessions'), `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
582
+ function parsedAbsoluteUrl(value: unknown): { raw: string; parsed: URL } {
583
+ const raw = stringValue(value);
584
+ if (!raw) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url is required.', { retryable: false });
585
+ try {
586
+ return { raw, parsed: new URL(raw) };
587
+ } catch {
588
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url must be absolute.', { retryable: false });
589
+ }
590
+ }
591
+
592
+ function unsupportedUrlProtocol(value: unknown, raw: string, protocol: string): never {
593
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Only http and https URLs are supported; observed protocol: ${protocol}`, {
594
+ retryable: false,
595
+ details: {
596
+ observedProtocol: protocol,
597
+ inputType: typeof value,
598
+ inputLength: raw.length,
599
+ },
600
+ });
382
601
  }
383
602
 
384
- function saveSession(repoRoot: string, session: BrowserSessionState): void {
385
- writeJson(sessionPath(repoRoot, session.sessionId), session);
603
+ function normalizedUrl(value: unknown): string {
604
+ const { raw, parsed } = parsedAbsoluteUrl(value);
605
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') unsupportedUrlProtocol(value, raw, parsed.protocol);
606
+ return parsed.toString();
386
607
  }
387
608
 
388
- function findSession(repoRoot: string, sessionId: string): BrowserSessionState | undefined {
389
- return readJson<BrowserSessionState>(sessionPath(repoRoot, sessionId));
609
+ function normalizedExplicitNativeAdoptionUrl(value: unknown): string {
610
+ const { raw, parsed } = parsedAbsoluteUrl(value);
611
+ if (!['https:', 'http:', 'chrome:', 'vivaldi:'].includes(parsed.protocol)) unsupportedUrlProtocol(value, raw, parsed.protocol);
612
+ return parsed.toString();
390
613
  }
391
614
 
392
- function loadSession(repoRoot: string, sessionId: string): BrowserSessionState {
393
- const state = findSession(repoRoot, sessionId);
394
- if (!state) {
395
- throw new AssistantPluginError('PLUGIN_SESSION_NOT_FOUND', `Browser session not found: ${sessionId}`, { retryable: false });
396
- }
397
- return state;
615
+ function browserInternalUrlProduct(value: unknown): MacOsBrowserProduct | undefined {
616
+ const { parsed } = parsedAbsoluteUrl(value);
617
+ return parsed.protocol === 'chrome:' ? 'chrome' : parsed.protocol === 'vivaldi:' ? 'vivaldi' : undefined;
398
618
  }
399
619
 
400
- function normalizedUrl(value: unknown): string {
401
- const raw = stringValue(value);
402
- if (!raw) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url is required.', { retryable: false });
403
- let parsed: URL;
404
- try {
405
- parsed = new URL(raw);
406
- } catch {
407
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url must be absolute.', { retryable: false });
408
- }
409
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
410
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Only http and https URLs are supported.', { retryable: false });
411
- }
412
- return parsed.toString();
620
+ function explicitNativeAdoptionUrlsMatch(requestedValue: unknown, observedValue: unknown, product: MacOsBrowserProduct): boolean {
621
+ const requestedUrl = normalizedExplicitNativeAdoptionUrl(requestedValue);
622
+ const observedUrl = normalizedExplicitNativeAdoptionUrl(observedValue);
623
+ if (comparableUrl(requestedUrl) === comparableUrl(observedUrl)) return true;
624
+ return product === 'vivaldi'
625
+ && requestedUrl === 'vivaldi:bookmarks'
626
+ && comparableUrl(observedUrl) === comparableUrl('chrome://bookmarks/');
627
+ }
628
+
629
+ function isExplicitNativeUserOwnedSession(session: BrowserSessionState): boolean {
630
+ return session.browser?.provider === 'macos-apple-events'
631
+ && session.browser.tab?.ownership === 'user_owned'
632
+ && (session.browser.browserProduct === 'chrome' || session.browser.browserProduct === 'vivaldi');
633
+ }
634
+
635
+ function normalizedSavedSessionUrl(session: BrowserSessionState): string {
636
+ return isExplicitNativeUserOwnedSession(session)
637
+ ? normalizedExplicitNativeAdoptionUrl(session.url)
638
+ : normalizedUrl(session.url);
413
639
  }
414
640
 
415
641
 
@@ -463,11 +689,40 @@ function parseNativeBrowserCandidatesInput(value: unknown): MacOsBrowserProduct[
463
689
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'native_browser_candidates must contain chrome and/or vivaldi.', { retryable: false });
464
690
  }
465
691
 
692
+ function effectiveBrowserActionConfig(
693
+ config: BrowserPluginConfig,
694
+ args: Record<string, unknown>,
695
+ existingSession?: BrowserSessionState,
696
+ ): BrowserPluginConfig {
697
+ const explicitBrowserMode = parseBrowserModeInput(args.browser_mode);
698
+ const explicitNativeBrowserCandidates = parseNativeBrowserCandidatesInput(args.native_browser_candidates);
699
+ const existingNativeProduct = existingSession?.browser?.provider === 'macos-apple-events'
700
+ ? existingSession.browser.browserProduct
701
+ : undefined;
702
+ if (existingNativeProduct && explicitNativeBrowserCandidates && !explicitNativeBrowserCandidates.includes(existingNativeProduct)) {
703
+ throw new AssistantPluginError(
704
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
705
+ 'native_browser_candidates cannot switch browser products for an existing native browser session.',
706
+ { retryable: false, details: { sessionId: existingSession?.sessionId, browserProduct: existingNativeProduct, nativeBrowserCandidates: explicitNativeBrowserCandidates } },
707
+ );
708
+ }
709
+ const savedNativeBrowserCandidates = existingSession?.browser?.provider === 'macos-apple-events'
710
+ ? existingSession.browser.nativeBrowserCandidates ?? (existingNativeProduct ? [existingNativeProduct] : undefined)
711
+ : undefined;
712
+ return {
713
+ ...config,
714
+ browserMode: explicitBrowserMode ?? existingSession?.browser?.mode ?? config.browserMode,
715
+ cdpAttachFallback: parseCdpAttachFallbackInput(args.cdp_attach_fallback) ?? config.cdpAttachFallback,
716
+ nativeAttachMode: parseNativeAttachModeInput(args.native_attach_mode) ?? config.nativeAttachMode,
717
+ nativeBrowserCandidates: explicitNativeBrowserCandidates ?? savedNativeBrowserCandidates ?? config.nativeBrowserCandidates,
718
+ };
719
+ }
720
+
466
721
  function cdpEndpoints(config: BrowserPluginConfig): string[] {
467
722
  return Array.from(new Set([
468
723
  ...(config.cdpEndpoint ? [config.cdpEndpoint] : []),
469
724
  ...(config.cdpEndpointCandidates ?? []),
470
- ].map((entry) => entry.trim()).filter(Boolean))).slice(0, MAX_CDP_ENDPOINT_CANDIDATES);
725
+ ].map((entry) => entry.trim()).filter(Boolean))).slice(0, MAX_BROWSER_CDP_ENDPOINT_CANDIDATES);
471
726
  }
472
727
 
473
728
  function isLoopbackHostname(hostname: string): boolean {
@@ -505,8 +760,8 @@ function validateConfig(config: BrowserPluginConfig): string[] {
505
760
  if (config.browserChannel && config.browserChannel !== 'chromium' && config.executablePath) {
506
761
  errors.push('browserChannel and executablePath cannot both be set.');
507
762
  }
508
- if (endpoints.length > MAX_CDP_ENDPOINT_CANDIDATES) {
509
- errors.push(`At most ${MAX_CDP_ENDPOINT_CANDIDATES} CDP endpoint candidates are supported.`);
763
+ if (endpoints.length > MAX_BROWSER_CDP_ENDPOINT_CANDIDATES) {
764
+ errors.push(`At most ${MAX_BROWSER_CDP_ENDPOINT_CANDIDATES} CDP endpoint candidates are supported.`);
510
765
  }
511
766
  for (const endpoint of endpoints) {
512
767
  const error = cdpEndpointValidationError(endpoint);
@@ -531,7 +786,9 @@ function configWarnings(config: BrowserPluginConfig): string[] {
531
786
  }
532
787
  if (config.browserMode === 'attach_preferred' && cdpEndpoints(config).length === 0) {
533
788
  if (config.nativeAttachMode === 'auto' && macOsActiveBrowserAttachSupported()) {
534
- warnings.push('browserMode=attach_preferred has no configured CDP endpoint; macOS active-browser attach will be attempted before managed fallback.');
789
+ warnings.push(config.cdpAttachFallback === 'fail_closed'
790
+ ? 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted and managed fallback is disabled.'
791
+ : 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted before explicit managed fallback.');
535
792
  } else {
536
793
  warnings.push('browserMode=attach_preferred has no configured CDP endpoint; actions will use the configured fallback policy.');
537
794
  }
@@ -567,11 +824,34 @@ function requiredString(value: unknown, field: string): string {
567
824
  return normalized;
568
825
  }
569
826
 
827
+ function openShadowSelectorParts(selector: string): string[] {
828
+ return selector.split(/\s*>>>\s*/).map((part) => part.trim()).filter(Boolean);
829
+ }
830
+
831
+ function openShadowSelectorExpression(selector: string, all = false): string {
832
+ const parts = openShadowSelectorParts(selector);
833
+ if (parts.length <= 1) {
834
+ return all
835
+ ? `Array.from(document.querySelectorAll(${JSON.stringify(selector)}))`
836
+ : `document.querySelector(${JSON.stringify(selector)})`;
837
+ }
838
+ return `(() => {
839
+ const parts = ${JSON.stringify(parts)};
840
+ let root = document;
841
+ for (let index = 0; index < parts.length - 1; index += 1) {
842
+ const host = root.querySelector(parts[index]);
843
+ if (!host || !host.shadowRoot) return ${all ? '[]' : 'null'};
844
+ root = host.shadowRoot;
845
+ }
846
+ return ${all ? 'Array.from(root.querySelectorAll(parts[parts.length - 1]))' : 'root.querySelector(parts[parts.length - 1])'};
847
+ })()`;
848
+ }
849
+
570
850
  function scriptText(selector?: string): string {
571
851
  if (!selector) {
572
852
  return 'document.body ? document.body.innerText : (document.documentElement ? document.documentElement.textContent : "")';
573
853
  }
574
- return `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? (el.innerText || el.textContent || '') : ''; })()`;
854
+ return `(() => { const el = ${openShadowSelectorExpression(selector)}; return el ? (el.innerText || el.textContent || '') : ''; })()`;
575
855
  }
576
856
 
577
857
  function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): BrowserActionTarget {
@@ -581,10 +861,11 @@ function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): B
581
861
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Provide either url or session_id.', { retryable: false });
582
862
  }
583
863
  if (providedSessionId) {
584
- const existingSession = loadSession(repoRoot, providedSessionId);
585
- const sessionUrl = normalizedUrl(existingSession.url);
864
+ const existingSession = requireBrowserSession(repoRoot, providedSessionId);
865
+ const nativeUserOwned = isExplicitNativeUserOwnedSession(existingSession);
866
+ const sessionUrl = normalizedSavedSessionUrl(existingSession);
586
867
  if (directUrl) {
587
- const explicitUrl = normalizedUrl(directUrl);
868
+ const explicitUrl = nativeUserOwned ? normalizedExplicitNativeAdoptionUrl(directUrl) : normalizedUrl(directUrl);
588
869
  if (explicitUrl !== sessionUrl) {
589
870
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url does not match the saved session.', { retryable: false });
590
871
  }
@@ -609,7 +890,7 @@ function sessionFromPage(target: BrowserActionTarget, pageUrl: string, title: st
609
890
  }
610
891
 
611
892
  function screenshotFilePath(repoRoot: string, actionId: string, sessionId: string, url: string): string {
612
- const screenshotDir = stateDir(repoRoot, 'screenshots');
893
+ const screenshotDir = browserStateDir(repoRoot, 'screenshots');
613
894
  mkdirSync(screenshotDir, { recursive: true });
614
895
  const digest = createHash('sha256').update(url).digest('hex').slice(0, 10);
615
896
  const actionLabel = actionId.replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -617,6 +898,27 @@ function screenshotFilePath(repoRoot: string, actionId: string, sessionId: strin
617
898
  return join(screenshotDir, `${Date.now()}-${actionLabel}-${sessionLabel}-${digest}.png`);
618
899
  }
619
900
 
901
+ function safeDownloadArtifactName(requestedName: string | undefined, browserSuggestedName: string | undefined): string {
902
+ const fallback = `download-${Date.now()}`;
903
+ const raw = (requestedName || browserSuggestedName || fallback).trim();
904
+ const leaf = basename(raw) || fallback;
905
+ const sanitized = leaf.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 180);
906
+ return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : fallback;
907
+ }
908
+
909
+ function isBlockedExecutableArtifactName(name: string | undefined): boolean {
910
+ return Boolean(name && /\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(basename(name)));
911
+ }
912
+
913
+ function uniqueDownloadArtifactPath(downloadDir: string, fileName: string): string {
914
+ const initial = join(downloadDir, fileName);
915
+ if (!existsSync(initial)) return initial;
916
+ const dot = fileName.lastIndexOf('.');
917
+ const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
918
+ const ext = dot > 0 ? fileName.slice(dot) : '';
919
+ return join(downloadDir, `${stem}-${Date.now()}${ext}`);
920
+ }
921
+
620
922
  async function captureActionScreenshot(page: PageLike, repoRoot: string, actionId: string, sessionId: string, url: string): Promise<BrowserActionScreenshot | undefined> {
621
923
  const path = screenshotFilePath(repoRoot, actionId, sessionId, url);
622
924
  const bytes = (await page.screenshot({ path, fullPage: true })).length;
@@ -627,6 +929,10 @@ function responseWithWarnings(base: Record<string, unknown>, warnings: string[])
627
929
  return warnings.length > 0 ? { ...base, warnings } : base;
628
930
  }
629
931
 
932
+ function browserResultProvider(provider: BrowserSessionConnectionState['provider'] | BrowserConnectionSummary['provider'] | undefined): 'playwright' | 'macos-apple-events' {
933
+ return provider === 'macos-apple-events' ? 'macos-apple-events' : 'playwright';
934
+ }
935
+
630
936
  function interactionResult(
631
937
  actionId: string,
632
938
  session: BrowserSessionState,
@@ -636,7 +942,7 @@ function interactionResult(
636
942
  extra: Record<string, unknown> = {},
637
943
  ): Record<string, unknown> {
638
944
  return responseWithWarnings({
639
- provider: 'playwright',
945
+ provider: browserResultProvider(session.browser?.provider),
640
946
  session,
641
947
  url: session.url,
642
948
  title: session.title,
@@ -678,8 +984,6 @@ function selectedProfile(config: BrowserPluginConfig, repoRoot: string, activeMo
678
984
  };
679
985
  }
680
986
 
681
- type BrowserTabMatchReason = 'owned_token' | 'saved_url_title' | 'saved_url' | 'exact_url' | 'blank' | 'new_page';
682
-
683
987
  const BROWSER_OWNER_PREFIX = 'forge-browser-owned:';
684
988
 
685
989
  interface BrowserTabInventoryEntry {
@@ -694,30 +998,6 @@ function ownerTokenForSession(sessionId: string): string {
694
998
  return `${BROWSER_OWNER_PREFIX}${createHash('sha256').update(sessionId).digest('hex').slice(0, 24)}`;
695
999
  }
696
1000
 
697
- interface BrowserSessionResumeDiagnostic {
698
- sessionId: string;
699
- status: 'matched' | 'stale_tab' | 'no_saved_tab';
700
- reason: string;
701
- savedTab?: Pick<BrowserTabResumeState, 'key' | 'url' | 'title' | 'index'>;
702
- }
703
-
704
- interface BrowserConnectionFallback {
705
- policy: BrowserCdpAttachFallback;
706
- from: 'attach_preferred';
707
- to: 'managed_persistent';
708
- reason: string;
709
- attempts: CdpAttachAttempt[];
710
- nativeAttempts?: MacOsBrowserAttachAttempt[];
711
- }
712
-
713
- interface CdpAttachAttempt {
714
- endpoint: string;
715
- discoveredEndpoint?: string;
716
- probeUrl?: string;
717
- browserVersion?: string;
718
- error?: string;
719
- }
720
-
721
1001
  interface BrowserConnectionSummary {
722
1002
  requestedMode: BrowserMode;
723
1003
  mode: BrowserMode;
@@ -726,6 +1006,7 @@ interface BrowserConnectionSummary {
726
1006
  endpoint?: string;
727
1007
  browserVersion?: string;
728
1008
  browserProduct?: MacOsBrowserProduct;
1009
+ nativeBrowserCandidates?: MacOsBrowserProduct[];
729
1010
  fallback?: BrowserConnectionFallback;
730
1011
  profile: {
731
1012
  profileMode?: BrowserProfileMode;
@@ -737,6 +1018,62 @@ interface BrowserConnectionSummary {
737
1018
  sessionResume?: BrowserSessionResumeDiagnostic;
738
1019
  }
739
1020
 
1021
+ async function establishAuthoritativeNativeForeground(
1022
+ input: AssistantPluginActionExecutionInput,
1023
+ page: PageLike,
1024
+ connection: BrowserConnectionSummary,
1025
+ target: BrowserActionTarget,
1026
+ waitMs: number,
1027
+ ): Promise<{ frontmost: boolean; active: boolean }> {
1028
+ if (!page.bringToFront || !page.foregroundState) {
1029
+ throw new AssistantPluginError(
1030
+ 'PLUGIN_BROWSER_ACTIVATION_UNSUPPORTED',
1031
+ 'The native browser provider cannot verify exact-tab foreground authority.',
1032
+ { retryable: false, details: { sessionId: target.sessionId, browserProduct: connection.browserProduct } },
1033
+ );
1034
+ }
1035
+
1036
+ await page.bringToFront();
1037
+ await delay(waitMs);
1038
+ let state = await page.foregroundState();
1039
+ if (state.frontmost && state.active) return state;
1040
+
1041
+ const product = connection.browserProduct;
1042
+ if (!product) {
1043
+ throw new AssistantPluginError(
1044
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1045
+ 'Native browser activation could not identify the browser product for system foreground recovery.',
1046
+ { retryable: true, details: { sessionId: target.sessionId, ...state } },
1047
+ );
1048
+ }
1049
+
1050
+ try {
1051
+ await runtimeHooks.activateNativeBrowserApplication(input, product);
1052
+ } catch (error) {
1053
+ const causeCode = error instanceof AssistantPluginError ? error.code : undefined;
1054
+ const cause = error instanceof Error ? error.message : String(error);
1055
+ throw new AssistantPluginError(
1056
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1057
+ 'Native browser exact-tab activation could not establish macOS system foreground authority through Forge Desktop Operator.',
1058
+ { retryable: true, details: { sessionId: target.sessionId, browserProduct: product, ...state, ...(causeCode ? { causeCode } : {}), cause: cause.slice(0, 500) } },
1059
+ );
1060
+ }
1061
+
1062
+ // App-level activation is intentionally separate from tab selection. Re-assert the exact
1063
+ // saved tab after the app becomes frontmost, then verify both authorities together.
1064
+ await page.bringToFront();
1065
+ await delay(waitMs);
1066
+ state = await page.foregroundState();
1067
+ if (!state.frontmost || !state.active) {
1068
+ throw new AssistantPluginError(
1069
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1070
+ 'Native browser activation did not make the exact saved tab authoritative system foreground state.',
1071
+ { retryable: true, details: { sessionId: target.sessionId, browserProduct: product, ...state } },
1072
+ );
1073
+ }
1074
+ return state;
1075
+ }
1076
+
740
1077
  interface BrowserOpenHandle {
741
1078
  page: PageLike;
742
1079
  diagnostics: PageDiagnostics;
@@ -796,6 +1133,106 @@ function comparableUrl(value: string): string {
796
1133
  }
797
1134
  }
798
1135
 
1136
+ export function nativeReplacementUrlMatchesTarget(requestedUrl: string, actualUrl: string): boolean {
1137
+ if (comparableUrl(actualUrl) === comparableUrl(requestedUrl)) return true;
1138
+ try {
1139
+ const requested = new URL(requestedUrl);
1140
+ const actual = new URL(actualUrl);
1141
+ const requestedIsOriginRoot = (requested.protocol === 'https:' || requested.protocol === 'http:')
1142
+ && requested.pathname === '/'
1143
+ && requested.search === ''
1144
+ && requested.hash === '';
1145
+ return requestedIsOriginRoot && actual.origin === requested.origin;
1146
+ } catch {
1147
+ return false;
1148
+ }
1149
+ }
1150
+
1151
+ export function nativeReplacementAssignmentProvenanceProvesTarget(
1152
+ evidence: MacOsBrowserCreateTabEvidence | undefined,
1153
+ requestedUrl: string,
1154
+ replacementRef: MacOsBrowserTabRef,
1155
+ ): boolean {
1156
+ if (!evidence || evidence.refSource !== 'structured' || !evidence.navigation) return false;
1157
+ if (evidence.ref.windowId !== replacementRef.windowId || evidence.ref.tabId !== replacementRef.tabId) return false;
1158
+ return evidence.navigation.provenanceVersion === 1
1159
+ && evidence.navigation.assignmentAccepted === true
1160
+ && evidence.navigation.requestedUrl === requestedUrl;
1161
+ }
1162
+
1163
+ export function nativeReplacementPostAssignmentUrlMatchesTarget(
1164
+ requestedUrl: string,
1165
+ actualUrl: string,
1166
+ assignmentProvenanceProven: boolean,
1167
+ ): boolean {
1168
+ if (nativeReplacementUrlMatchesTarget(requestedUrl, actualUrl)) return true;
1169
+ return assignmentProvenanceProven && sameOrigin(actualUrl, requestedUrl);
1170
+ }
1171
+
1172
+ export async function settleNativeCreatedPageIdentity(
1173
+ page: {
1174
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
1175
+ identity: () => Promise<{ url: string; title: string }>;
1176
+ },
1177
+ state: string,
1178
+ timeoutMs: number,
1179
+ target?: { requestedUrl: string; previousUrl?: string },
1180
+ ): Promise<{ url: string; title: string }> {
1181
+ const deadline = Date.now() + Math.max(0, timeoutMs);
1182
+ await page.waitForLoadState(state, { timeout: timeoutMs, requireHttpUrl: true });
1183
+ let identity = await page.identity();
1184
+ if (!target || nativeReplacementUrlMatchesTarget(target.requestedUrl, identity.url)) return identity;
1185
+
1186
+ const previousUrl = target.previousUrl?.trim();
1187
+ if (!previousUrl || comparableUrl(identity.url) !== comparableUrl(previousUrl)) return identity;
1188
+
1189
+ // Native Chrome/Vivaldi can briefly expose the obsolete tab URL on the newly
1190
+ // created stable replacement ref before the create-tab navigation commits.
1191
+ // Only that exact previous URL is treated as transitional. A different HTTP(S)
1192
+ // URL remains an immediate postcondition failure, and authority never moves to
1193
+ // another tab while this bounded settlement loop runs.
1194
+ while (Date.now() < deadline) {
1195
+ const remainingMs = deadline - Date.now();
1196
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs)));
1197
+ identity = await page.identity();
1198
+ if (nativeReplacementUrlMatchesTarget(target.requestedUrl, identity.url)) return identity;
1199
+ if (comparableUrl(identity.url) !== comparableUrl(previousUrl)) return identity;
1200
+ }
1201
+ return identity;
1202
+ }
1203
+
1204
+ export function nativeReplacementMismatchDiagnostic(input: {
1205
+ requestedUrl: string;
1206
+ actualUrl: string;
1207
+ previousUrl?: string;
1208
+ obsoleteRef?: MacOsBrowserTabRef;
1209
+ replacementRef?: MacOsBrowserTabRef;
1210
+ }): {
1211
+ sameTargetOrigin: boolean;
1212
+ samePreviousOrigin: boolean;
1213
+ samePreviousUrl: boolean;
1214
+ sameTabRef: boolean;
1215
+ actualScheme: string;
1216
+ } {
1217
+ let actualScheme = 'invalid';
1218
+ try {
1219
+ const protocol = new URL(input.actualUrl).protocol.replace(/:$/, '').toLowerCase();
1220
+ actualScheme = /^[a-z][a-z0-9+.-]*$/.test(protocol) ? protocol : 'invalid';
1221
+ } catch {
1222
+ // Keep the diagnostic bounded and non-sensitive when the observed URL is malformed.
1223
+ }
1224
+ const previousUrl = input.previousUrl?.trim();
1225
+ return {
1226
+ sameTargetOrigin: sameOrigin(input.actualUrl, input.requestedUrl),
1227
+ samePreviousOrigin: previousUrl ? sameOrigin(input.actualUrl, previousUrl) : false,
1228
+ samePreviousUrl: previousUrl ? comparableUrl(input.actualUrl) === comparableUrl(previousUrl) : false,
1229
+ sameTabRef: Boolean(input.obsoleteRef && input.replacementRef
1230
+ && input.obsoleteRef.windowId === input.replacementRef.windowId
1231
+ && input.obsoleteRef.tabId === input.replacementRef.tabId),
1232
+ actualScheme,
1233
+ };
1234
+ }
1235
+
799
1236
  function sameOrigin(left: string, right: string): boolean {
800
1237
  try {
801
1238
  return new URL(left).origin === new URL(right).origin;
@@ -804,6 +1241,105 @@ function sameOrigin(left: string, right: string): boolean {
804
1241
  }
805
1242
  }
806
1243
 
1244
+ function stableBrowserEntityKey(value: string): string | undefined {
1245
+ try {
1246
+ const url = new URL(value);
1247
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
1248
+ const segments = url.pathname.split('/').filter(Boolean);
1249
+ for (let index = 0; index + 1 < segments.length; index += 1) {
1250
+ if ((segments[index] ?? '').toLowerCase() !== 'apps') continue;
1251
+ const id = segments[index + 1]?.trim();
1252
+ if (id) return `${url.origin}/apps/${id}`;
1253
+ }
1254
+ return undefined;
1255
+ } catch {
1256
+ return undefined;
1257
+ }
1258
+ }
1259
+
1260
+ function selectReusableNativeTab(
1261
+ referenceUrls: string[],
1262
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1263
+ preferredWindowId?: string,
1264
+ ): {
1265
+ candidate?: (typeof inventory.tabs)[number];
1266
+ match: 'exact_url' | 'stable_entity' | 'none' | 'ambiguous';
1267
+ candidateCount: number;
1268
+ } {
1269
+ const normalizedReferences = [...new Set(referenceUrls.filter(Boolean).map(comparableUrl))];
1270
+ const entityKeys = new Set(normalizedReferences.map(stableBrowserEntityKey).filter((value): value is string => Boolean(value)));
1271
+ const scored = inventory.tabs.map((candidate) => {
1272
+ const normalizedCandidate = comparableUrl(candidate.url);
1273
+ const entityKey = stableBrowserEntityKey(candidate.url);
1274
+ const matchScore = normalizedReferences.includes(normalizedCandidate)
1275
+ ? 100
1276
+ : entityKey && entityKeys.has(entityKey)
1277
+ ? 50
1278
+ : 0;
1279
+ const score = matchScore === 0
1280
+ ? 0
1281
+ : matchScore + (preferredWindowId && candidate.windowId === preferredWindowId ? 10 : 0) + (candidate.active ? 1 : 0);
1282
+ return { candidate, score, matchScore };
1283
+ });
1284
+ const maxScore = scored.reduce((max, entry) => Math.max(max, entry.score), 0);
1285
+ if (maxScore === 0) return { match: 'none', candidateCount: 0 };
1286
+ const matches = scored.filter((entry) => entry.score === maxScore);
1287
+ if (matches.length !== 1) return { match: 'ambiguous', candidateCount: matches.length };
1288
+ return { candidate: matches[0]!.candidate, match: matches[0]!.matchScore === 100 ? 'exact_url' : 'stable_entity', candidateCount: 1 };
1289
+ }
1290
+
1291
+ function selectLiveForgeOwnedNativeTab(
1292
+ repoRoot: string,
1293
+ targetUrl: string,
1294
+ product: MacOsBrowserProduct,
1295
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1296
+ ): {
1297
+ session?: BrowserSessionState;
1298
+ candidate?: (typeof inventory.tabs)[number];
1299
+ match: 'exact_url' | 'same_origin' | 'none' | 'ambiguous';
1300
+ candidateCount: number;
1301
+ } {
1302
+ const liveByRef = new Map(inventory.tabs.map((tab) => [`${tab.windowId}:${tab.tabId}`, tab]));
1303
+ const candidates = new Map<string, { session: BrowserSessionState; candidate: (typeof inventory.tabs)[number] }>();
1304
+ for (const session of listSavedBrowserSessions(repoRoot)) {
1305
+ const browser = session.browser;
1306
+ const tab = browser?.tab;
1307
+ if (browser?.provider !== 'macos-apple-events' || browser.browserProduct !== product || tab?.ownership !== 'plugin_owned') continue;
1308
+ if (!tab.windowId || !tab.tabId) continue;
1309
+ const key = `${tab.windowId}:${tab.tabId}`;
1310
+ const live = liveByRef.get(key);
1311
+ if (!live) continue;
1312
+ const previous = candidates.get(key);
1313
+ if (!previous || session.updatedAt > previous.session.updatedAt) candidates.set(key, { session, candidate: live });
1314
+ }
1315
+
1316
+ const values = [...candidates.values()];
1317
+ const exact = values.filter(({ candidate }) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1318
+ const choose = (matches: typeof values, match: 'exact_url' | 'same_origin') => {
1319
+ if (matches.length === 1) return { ...matches[0], match, candidateCount: 1 } as const;
1320
+ const active = matches.filter(({ candidate }) => candidate.active);
1321
+ if (active.length === 1) return { ...active[0], match, candidateCount: matches.length } as const;
1322
+ return { match: 'ambiguous' as const, candidateCount: matches.length };
1323
+ };
1324
+ if (exact.length > 0) return choose(exact, 'exact_url');
1325
+
1326
+ const sameOriginCandidates = values.filter(({ candidate }) => sameOrigin(candidate.url, targetUrl));
1327
+ if (sameOriginCandidates.length > 0) return choose(sameOriginCandidates, 'same_origin');
1328
+ return { match: 'none', candidateCount: 0 };
1329
+ }
1330
+
1331
+ function selectExactNativeTab(
1332
+ targetUrl: string,
1333
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1334
+ ): { candidate?: (typeof inventory.tabs)[number]; match: 'exact_url' | 'none' | 'ambiguous'; candidateCount: number } {
1335
+ const matches = inventory.tabs.filter((candidate) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1336
+ if (matches.length === 0) return { match: 'none', candidateCount: 0 };
1337
+ if (matches.length === 1) return { candidate: matches[0], match: 'exact_url', candidateCount: 1 };
1338
+ const active = matches.filter((candidate) => candidate.active);
1339
+ if (active.length === 1) return { candidate: active[0], match: 'exact_url', candidateCount: matches.length };
1340
+ return { match: 'ambiguous', candidateCount: matches.length };
1341
+ }
1342
+
807
1343
  function isBlankPage(url: string): boolean {
808
1344
  return !url || url === 'about:blank' || url === 'chrome://newtab/';
809
1345
  }
@@ -980,6 +1516,7 @@ function sessionConnectionFromSummary(connection: BrowserConnectionSummary): Bro
980
1516
  endpoint: connection.endpoint,
981
1517
  browserVersion: connection.browserVersion,
982
1518
  browserProduct: connection.browserProduct,
1519
+ nativeBrowserCandidates: connection.nativeBrowserCandidates,
983
1520
  fallback: connection.fallback,
984
1521
  tab: connection.tab,
985
1522
  sessionResume: connection.sessionResume,
@@ -1045,27 +1582,303 @@ function launchOptionsForRepo(repoRoot: string, config: BrowserPluginConfig, pro
1045
1582
  };
1046
1583
  }
1047
1584
 
1048
- async function openManagedContext(
1585
+ function managedContextKey(profile: BrowserProfileSelection): string {
1586
+ return resolve(profile.selectedProfilePath);
1587
+ }
1588
+
1589
+ async function evictManagedContext(key: string): Promise<void> {
1590
+ const pending = managedBrowserContexts.get(key);
1591
+ managedBrowserContexts.delete(key);
1592
+ if (!pending) return;
1593
+ try {
1594
+ const state = await pending;
1595
+ await state.context.close().catch(() => undefined);
1596
+ } catch {
1597
+ // Failed launches are already unusable; removing the cache entry is enough.
1598
+ }
1599
+ }
1600
+
1601
+ async function closeManagedContextsForRepo(repoRoot: string, options: { strict?: boolean } = {}): Promise<void> {
1602
+ const canonicalRoot = resolve(repoRoot);
1603
+ for (const [key, pending] of [...managedBrowserContexts.entries()]) {
1604
+ let state: ManagedBrowserContextState;
1605
+ try {
1606
+ state = await pending;
1607
+ } catch {
1608
+ managedBrowserContexts.delete(key);
1609
+ continue;
1610
+ }
1611
+ if (state.repoRoot !== canonicalRoot) continue;
1612
+ try {
1613
+ await state.context.close();
1614
+ managedBrowserContexts.delete(key);
1615
+ } catch (error) {
1616
+ if (options.strict === true) {
1617
+ throw new AssistantPluginError('PLUGIN_BROWSER_MANAGED_CONTEXT_CLOSE_FAILED', 'Managed browser context could not be closed; retaining Runtime binding and session metadata for a safe retry.', {
1618
+ retryable: true, details: { profilePath: state.profilePath, cause: error instanceof Error ? error.message : String(error) },
1619
+ });
1620
+ }
1621
+ managedBrowserContexts.delete(key);
1622
+ }
1623
+ }
1624
+ }
1625
+
1626
+ async function managedContextState(
1049
1627
  runtime: PlaywrightRuntime,
1050
1628
  repoRoot: string,
1051
1629
  config: BrowserPluginConfig,
1052
- target: BrowserActionTarget,
1053
- args: Record<string, unknown>,
1054
- activeMode: BrowserMode,
1055
- requestedMode: BrowserMode,
1056
- fallback?: BrowserConnectionFallback,
1057
- ): Promise<BrowserOpenHandle> {
1058
- const profile = selectedProfile(config, repoRoot, activeMode, target.sessionId);
1059
- assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
1060
- mkdirSync(profile.profileDir, { recursive: true });
1061
- const context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1062
- const selected = await selectPage(context, target);
1063
- const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1064
- let response: { status?: () => number } | null | undefined;
1065
- if (selected.matchedBy === 'new_page' || selected.matchedBy === 'blank' || comparableUrl(selected.page.url()) !== comparableUrl(target.url)) {
1066
- response = await selected.page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout }) as { status?: () => number } | null | undefined;
1630
+ profile: BrowserProfileSelection,
1631
+ ): Promise<ManagedBrowserContextState> {
1632
+ const key = managedContextKey(profile);
1633
+ const existing = managedBrowserContexts.get(key);
1634
+ if (existing) {
1635
+ try {
1636
+ const state = await existing;
1637
+ state.context.pages();
1638
+ return state;
1639
+ } catch {
1640
+ managedBrowserContexts.delete(key);
1641
+ }
1067
1642
  }
1068
- const diagnostics = attachDiagnostics(selected.page);
1643
+ const launch = (async () => {
1644
+ const context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1645
+ return {
1646
+ repoRoot: resolve(repoRoot),
1647
+ profilePath: key,
1648
+ context,
1649
+ pagesBySession: new Map<string, PageLike>(),
1650
+ } satisfies ManagedBrowserContextState;
1651
+ })();
1652
+ managedBrowserContexts.set(key, launch);
1653
+ try {
1654
+ return await launch;
1655
+ } catch (error) {
1656
+ if (managedBrowserContexts.get(key) === launch) managedBrowserContexts.delete(key);
1657
+ throw error;
1658
+ }
1659
+ }
1660
+
1661
+ async function markManagedPageOwner(page: PageLike, ownerToken: string): Promise<void> {
1662
+ await page.evaluate((_token) => {
1663
+ if (typeof window !== 'undefined') window.name = String(_token ?? '');
1664
+ }, ownerToken).catch(() => undefined);
1665
+ }
1666
+
1667
+ async function selectManagedSessionPage(
1668
+ state: ManagedBrowserContextState,
1669
+ target: BrowserActionTarget,
1670
+ options: { allowReplacement?: boolean } = {},
1671
+ ) {
1672
+ const pages = state.context.pages();
1673
+ const inventory = await inventoryTabs(state.context);
1674
+ const ownerToken = ownerTokenForSession(target.sessionId);
1675
+ const mapped = state.pagesBySession.get(target.sessionId);
1676
+ if (mapped && pages.includes(mapped)) {
1677
+ const index = pages.indexOf(mapped);
1678
+ await markManagedPageOwner(mapped, ownerToken);
1679
+ return {
1680
+ page: mapped,
1681
+ matchedBy: 'owned_token' as const,
1682
+ inventory,
1683
+ selectedEntry: inventory[index],
1684
+ sessionResume: {
1685
+ sessionId: target.sessionId,
1686
+ status: 'matched' as const,
1687
+ reason: 'Reused the live managed page bound to this browser session.',
1688
+ savedTab: target.existingSession?.browser?.tab,
1689
+ },
1690
+ ownerToken,
1691
+ };
1692
+ }
1693
+ state.pagesBySession.delete(target.sessionId);
1694
+
1695
+ const ownerIndex = inventory.findIndex((entry) => entry.ownerToken === ownerToken);
1696
+ if (ownerIndex >= 0) {
1697
+ const page = pages[ownerIndex];
1698
+ if (page) {
1699
+ state.pagesBySession.set(target.sessionId, page);
1700
+ return {
1701
+ page,
1702
+ matchedBy: 'owned_token' as const,
1703
+ inventory,
1704
+ selectedEntry: inventory[ownerIndex],
1705
+ sessionResume: {
1706
+ sessionId: target.sessionId,
1707
+ status: 'matched' as const,
1708
+ reason: 'Rediscovered the managed page from its stable session owner marker.',
1709
+ savedTab: target.existingSession?.browser?.tab,
1710
+ },
1711
+ ownerToken,
1712
+ };
1713
+ }
1714
+ }
1715
+
1716
+ if (target.existingSession && options.allowReplacement === false) {
1717
+ throw new AssistantPluginError(
1718
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1719
+ 'Saved managed browser page no longer exists; refusing to create or claim a replacement page for this existing-session action.',
1720
+ {
1721
+ retryable: false,
1722
+ details: { sessionId: target.sessionId, provider: 'playwright-persistent-context' },
1723
+ },
1724
+ );
1725
+ }
1726
+
1727
+ const blankIndex = inventory.findIndex((entry) => isBlankPage(entry.url) && !entry.ownerToken);
1728
+ const page = blankIndex >= 0 && pages[blankIndex] ? pages[blankIndex]! : await state.context.newPage();
1729
+ const matchedBy: BrowserTabMatchReason = blankIndex >= 0 ? 'blank' : 'new_page';
1730
+ await markManagedPageOwner(page, ownerToken);
1731
+ state.pagesBySession.set(target.sessionId, page);
1732
+ return {
1733
+ page,
1734
+ matchedBy,
1735
+ inventory,
1736
+ selectedEntry: blankIndex >= 0 ? inventory[blankIndex] : undefined,
1737
+ sessionResume: target.existingSession?.browser?.tab
1738
+ ? {
1739
+ sessionId: target.sessionId,
1740
+ status: 'stale_tab' as const,
1741
+ reason: 'The previously owned managed page no longer exists; created one replacement page for this session only.',
1742
+ savedTab: target.existingSession.browser.tab,
1743
+ }
1744
+ : {
1745
+ sessionId: target.sessionId,
1746
+ status: 'no_saved_tab' as const,
1747
+ reason: matchedBy === 'blank'
1748
+ ? 'Claimed one unowned blank page for this managed browser session.'
1749
+ : 'Created one dedicated managed page for this browser session.',
1750
+ },
1751
+ ownerToken,
1752
+ };
1753
+ }
1754
+
1755
+ async function runtimeManagedPageForSession(
1756
+ repoRoot: string,
1757
+ config: BrowserPluginConfig,
1758
+ session: BrowserSessionState,
1759
+ ): Promise<{ page: PageLike; profile: BrowserProfileSelection } | undefined> {
1760
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') return undefined;
1761
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
1762
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
1763
+ if (!pending) return undefined;
1764
+ let state: ManagedBrowserContextState;
1765
+ try {
1766
+ state = await pending;
1767
+ } catch {
1768
+ return undefined;
1769
+ }
1770
+ let page = state.pagesBySession.get(session.sessionId);
1771
+ if (page && state.context.pages().includes(page)) return { page, profile };
1772
+ state.pagesBySession.delete(session.sessionId);
1773
+ const expectedOwnerToken = session.browser.tab?.ownerToken ?? ownerTokenForSession(session.sessionId);
1774
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
1775
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
1776
+ page = index >= 0 ? state.context.pages()[index] : undefined;
1777
+ if (!page) return undefined;
1778
+ state.pagesBySession.set(session.sessionId, page);
1779
+ return { page, profile };
1780
+ }
1781
+
1782
+ async function closeManagedSessionPage(repoRoot: string, config: BrowserPluginConfig, session: BrowserSessionState): Promise<boolean> {
1783
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') return false;
1784
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
1785
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
1786
+ if (!pending) return false;
1787
+ let state: ManagedBrowserContextState;
1788
+ try {
1789
+ state = await pending;
1790
+ } catch {
1791
+ return false;
1792
+ }
1793
+ let page = state.pagesBySession.get(session.sessionId);
1794
+ if (!page) {
1795
+ const expectedOwnerToken = session.browser.tab?.ownerToken ?? ownerTokenForSession(session.sessionId);
1796
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
1797
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
1798
+ if (index >= 0) page = state.context.pages()[index];
1799
+ }
1800
+ if (!page || typeof page.close !== 'function') {
1801
+ state.pagesBySession.delete(session.sessionId);
1802
+ return false;
1803
+ }
1804
+ await page.close();
1805
+ state.pagesBySession.delete(session.sessionId);
1806
+ return true;
1807
+ }
1808
+
1809
+ async function openManagedContext(
1810
+ runtime: PlaywrightRuntime,
1811
+ repoRoot: string,
1812
+ config: BrowserPluginConfig,
1813
+ target: BrowserActionTarget,
1814
+ args: Record<string, unknown>,
1815
+ activeMode: BrowserMode,
1816
+ requestedMode: BrowserMode,
1817
+ fallback?: BrowserConnectionFallback,
1818
+ ): Promise<BrowserOpenHandle> {
1819
+ const profile = selectedProfile(config, repoRoot, activeMode, target.sessionId);
1820
+ const persistentKey = managedContextKey(profile);
1821
+ const requireExistingResource = args.__forge_require_existing_resource === true;
1822
+ const allowManagedSessionRehydrate = requireExistingResource && args.__forge_allow_managed_session_rehydrate === true;
1823
+ const managedContextWasMissing = !managedBrowserContexts.has(persistentKey);
1824
+ if (requireExistingResource && target.existingSession && activeMode === 'managed_persistent') {
1825
+ const savedConnection = target.existingSession.browser;
1826
+ if (savedConnection?.provider !== 'playwright-persistent-context'
1827
+ || savedConnection.activeMode !== 'managed_persistent') {
1828
+ throw new AssistantPluginError(
1829
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1830
+ 'Saved browser session belongs to a different browser provider/mode; refusing to migrate it into a replacement managed browser/page for this existing-session action.',
1831
+ {
1832
+ retryable: false,
1833
+ details: {
1834
+ sessionId: target.sessionId,
1835
+ savedProvider: savedConnection?.provider,
1836
+ savedActiveMode: savedConnection?.activeMode,
1837
+ requestedActiveMode: activeMode,
1838
+ },
1839
+ },
1840
+ );
1841
+ }
1842
+ if (managedContextWasMissing && !allowManagedSessionRehydrate) {
1843
+ throw new AssistantPluginError(
1844
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1845
+ 'The managed browser context for this saved session is no longer live; refusing to launch a replacement browser/page for this existing-session action.',
1846
+ { retryable: false, details: { sessionId: target.sessionId, provider: 'playwright-persistent-context' } },
1847
+ );
1848
+ }
1849
+ }
1850
+ assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
1851
+ mkdirSync(profile.profileDir, { recursive: true });
1852
+ let context: BrowserContextLike;
1853
+ let selected: Awaited<ReturnType<typeof selectManagedSessionPage>> | Awaited<ReturnType<typeof selectPage>>;
1854
+ let managedState: ManagedBrowserContextState | undefined;
1855
+ if (activeMode === 'managed_persistent') {
1856
+ managedState = await managedContextState(runtime, repoRoot, config, profile);
1857
+ context = managedState.context;
1858
+ try {
1859
+ selected = await selectManagedSessionPage(managedState, target, { allowReplacement: !requireExistingResource });
1860
+ } catch (error) {
1861
+ if (requireExistingResource) {
1862
+ if (allowManagedSessionRehydrate && managedContextWasMissing) await evictManagedContext(persistentKey);
1863
+ throw error;
1864
+ }
1865
+ await evictManagedContext(persistentKey);
1866
+ assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
1867
+ managedState = await managedContextState(runtime, repoRoot, config, profile);
1868
+ context = managedState.context;
1869
+ selected = await selectManagedSessionPage(managedState, target);
1870
+ }
1871
+ } else {
1872
+ context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1873
+ selected = await selectPage(context, target);
1874
+ }
1875
+ const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1876
+ let response: { status?: () => number } | null | undefined;
1877
+ if (selected.matchedBy === 'new_page' || selected.matchedBy === 'blank' || comparableUrl(selected.page.url()) !== comparableUrl(target.url)) {
1878
+ response = await selected.page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout }) as { status?: () => number } | null | undefined;
1879
+ }
1880
+ if (managedState && 'ownerToken' in selected) await markManagedPageOwner(selected.page, selected.ownerToken);
1881
+ const diagnostics = attachDiagnostics(selected.page);
1069
1882
  diagnostics.navigation = {
1070
1883
  url: target.url,
1071
1884
  status: response && typeof response.status === 'function' ? response.status() : undefined,
@@ -1084,13 +1897,16 @@ async function openManagedContext(
1084
1897
  },
1085
1898
  tabInventory: selected.inventory,
1086
1899
  sessionResume: selected.sessionResume,
1087
- }, normalizedUrl(selected.page.url()), title, selected.matchedBy);
1900
+ }, normalizedUrl(selected.page.url()), title, selected.matchedBy,
1901
+ managedState && 'ownerToken' in selected
1902
+ ? { ownership: 'plugin_owned', ownerToken: selected.ownerToken }
1903
+ : undefined);
1088
1904
  return {
1089
1905
  page: selected.page,
1090
1906
  diagnostics,
1091
1907
  connection,
1092
1908
  close: async () => {
1093
- await context.close().catch(() => undefined);
1909
+ if (activeMode === 'isolated') await context.close().catch(() => undefined);
1094
1910
  },
1095
1911
  };
1096
1912
  }
@@ -1177,6 +1993,11 @@ async function openAttachedContext(
1177
1993
  return { attempts };
1178
1994
  }
1179
1995
 
1996
+ function nativeBackgroundNavigationRequiresReplacement(error: unknown): boolean {
1997
+ const message = error instanceof Error ? error.message : String(error);
1998
+ return message.includes('BROWSER_AUTOMATION_BACKGROUND_NAVIGATION_REQUIRES_REPLACEMENT');
1999
+ }
2000
+
1180
2001
  async function openNativeAttachedContext(
1181
2002
  repoRoot: string,
1182
2003
  config: BrowserPluginConfig,
@@ -1184,17 +2005,46 @@ async function openNativeAttachedContext(
1184
2005
  args: Record<string, unknown>,
1185
2006
  ): Promise<{ handle?: BrowserOpenHandle; attempts: MacOsBrowserAttachAttempt[] }> {
1186
2007
  if (config.nativeAttachMode === 'disabled') return { attempts: [] };
2008
+ if (!target.existingSession && !stringValue(args.session_id)) {
2009
+ await inspectNativeOwnedSessions({ repoRoot, savedSessions: listSavedSessions(repoRoot), timeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, pruneDead: true });
2010
+ }
1187
2011
  const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1188
2012
  const savedTab = target.existingSession?.browser?.tab;
2013
+ const savedOwnership = savedTab?.ownership;
2014
+ let effectiveOwnership = savedOwnership;
2015
+ let recoveredTabTitle = '';
2016
+ let recoveredTabUrl: string | undefined;
1189
2017
  const savedProduct = target.existingSession?.browser?.provider === 'macos-apple-events'
1190
- && savedTab?.ownership === 'plugin_owned'
2018
+ && savedTab
2019
+ && (savedOwnership === 'plugin_owned' || savedOwnership === 'user_owned')
1191
2020
  && typeof savedTab.windowId === 'string'
1192
2021
  && typeof savedTab.tabId === 'string'
1193
2022
  ? target.existingSession.browser.browserProduct
1194
2023
  : undefined;
2024
+ if (args.__forge_require_existing_resource === true
2025
+ && target.existingSession?.browser?.provider === 'macos-apple-events'
2026
+ && !savedProduct) {
2027
+ throw new AssistantPluginError(
2028
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2029
+ 'Saved native browser session lacks a complete stable browser/tab identity; refusing to create or adopt a replacement tab for this existing-session action.',
2030
+ {
2031
+ retryable: false,
2032
+ details: {
2033
+ sessionId: target.sessionId,
2034
+ provider: target.existingSession.browser.provider,
2035
+ browserProduct: target.existingSession.browser.browserProduct,
2036
+ ownership: savedOwnership,
2037
+ windowId: savedTab?.windowId,
2038
+ tabId: savedTab?.tabId,
2039
+ },
2040
+ },
2041
+ );
2042
+ }
1195
2043
  let page: PageLike | undefined;
1196
2044
  let discovered: Awaited<ReturnType<typeof discoverMacOsBrowserAttachment>> | undefined;
1197
- let sessionResume: BrowserSessionResumeDiagnostic;
2045
+ let sessionResume: BrowserSessionResumeDiagnostic = savedTab
2046
+ ? { sessionId: target.sessionId, status: 'stale_tab', reason: 'Saved native tab has not yet been reattached.', savedTab }
2047
+ : { sessionId: target.sessionId, status: 'no_saved_tab', reason: 'No saved native tab exists yet.' };
1198
2048
  let matchedBy: BrowserTabMatchReason = 'new_page';
1199
2049
 
1200
2050
  if (savedProduct && savedTab && typeof savedTab.windowId === 'string' && typeof savedTab.tabId === 'string') {
@@ -1203,10 +2053,73 @@ async function openNativeAttachedContext(
1203
2053
  const reattached = await reattachMacOsBrowserOwnedPage(savedProduct, ref, timeout);
1204
2054
  page = reattached.page as unknown as PageLike;
1205
2055
  discovered = { attachment: reattached.attachment, attempts: reattached.attachment.attempts };
1206
- matchedBy = 'owned_token';
1207
- sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Reattached directly to the saved plugin-owned macOS browser tab.', savedTab };
1208
- } catch {
1209
- sessionResume = { sessionId: target.sessionId, status: 'stale_tab', reason: 'Saved plugin-owned macOS tab no longer exists; one replacement tab was created.', savedTab };
2056
+ matchedBy = savedOwnership === 'user_owned' ? 'saved_url' : 'owned_token';
2057
+ sessionResume = {
2058
+ sessionId: target.sessionId,
2059
+ status: 'matched',
2060
+ reason: savedOwnership === 'user_owned'
2061
+ ? 'Reattached directly to the explicitly adopted user-owned macOS browser tab.'
2062
+ : 'Reattached directly to the saved plugin-owned macOS browser tab.',
2063
+ savedTab,
2064
+ };
2065
+ } catch (error) {
2066
+ if (error instanceof AssistantPluginError
2067
+ && error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN'
2068
+ && savedOwnership === 'user_owned') {
2069
+ throw error;
2070
+ }
2071
+ if (savedOwnership === 'user_owned') {
2072
+ throw new AssistantPluginError(
2073
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2074
+ 'Explicitly adopted user-owned macOS browser tab no longer exists; refusing to silently switch to another user tab.',
2075
+ {
2076
+ retryable: false,
2077
+ details: { sessionId: target.sessionId, browserProduct: savedProduct, ownership: savedOwnership, windowId: savedTab.windowId, tabId: savedTab.tabId, cause: error instanceof Error ? error.message : String(error) },
2078
+ },
2079
+ );
2080
+ }
2081
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2082
+ let selection: ReturnType<typeof selectReusableNativeTab> | undefined;
2083
+ let recoveryFailure: string | undefined;
2084
+ try {
2085
+ inventory = await listMacOsBrowserTabs(savedProduct, timeout);
2086
+ selection = selectReusableNativeTab(
2087
+ [savedTab.url, target.existingSession?.url ?? '', target.url],
2088
+ inventory,
2089
+ savedTab.windowId,
2090
+ );
2091
+ if (selection.candidate) {
2092
+ const recovered = await reattachMacOsBrowserOwnedPage(savedProduct, { windowId: selection.candidate.windowId, tabId: selection.candidate.tabId }, timeout);
2093
+ page = recovered.page as unknown as PageLike;
2094
+ discovered = { attachment: recovered.attachment, attempts: recovered.attachment.attempts };
2095
+ matchedBy = 'recovered_tab';
2096
+ effectiveOwnership = 'user_owned';
2097
+ recoveredTabTitle = selection.candidate.title;
2098
+ recoveredTabUrl = selection.candidate.url;
2099
+ sessionResume = {
2100
+ sessionId: target.sessionId,
2101
+ status: 'matched',
2102
+ reason: selection.match === 'exact_url'
2103
+ ? 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab with the same URL and preserved it as user-owned.'
2104
+ : 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab for the same stable app entity and preserved its current page as user-owned.',
2105
+ savedTab,
2106
+ };
2107
+ }
2108
+ } catch (recoveryError) {
2109
+ recoveryFailure = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
2110
+ }
2111
+ if (!page) {
2112
+ sessionResume = {
2113
+ sessionId: target.sessionId,
2114
+ status: 'stale_tab',
2115
+ reason: recoveryFailure
2116
+ ? `Saved plugin-owned macOS browser tab disappeared and current-tab recovery failed (${recoveryFailure}); creating one replacement plugin-owned tab.`
2117
+ : selection?.match === 'ambiguous'
2118
+ ? 'Saved plugin-owned macOS browser tab disappeared and multiple reusable current tabs matched; refusing to guess between user tabs and creating one replacement plugin-owned tab.'
2119
+ : 'Saved plugin-owned macOS browser tab disappeared and no uniquely reusable current tab matched; creating one replacement plugin-owned tab.',
2120
+ savedTab,
2121
+ };
2122
+ }
1210
2123
  }
1211
2124
  } else {
1212
2125
  sessionResume = savedTab
@@ -1215,52 +2128,246 @@ async function openNativeAttachedContext(
1215
2128
  }
1216
2129
 
1217
2130
  if (!discovered?.attachment) {
1218
- const candidates = savedProduct ? [savedProduct, ...(config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']).filter((product) => product !== savedProduct)] : (config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']);
2131
+ const candidates = savedProduct ? [savedProduct] : (config.nativeBrowserCandidates ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES]);
1219
2132
  discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
1220
2133
  }
1221
2134
  if (!discovered.attachment) return { attempts: discovered.attempts };
2135
+ let activeAttachment = discovered.attachment;
1222
2136
 
1223
2137
  let createdThisCallRef: MacOsBrowserTabRef | undefined;
1224
- let replacedRef: MacOsBrowserTabRef | undefined;
2138
+ let navigationUsedReplacement = false;
2139
+ const navigateNativePageWithReplacement = async (candidate: PageLike): Promise<PageLike> => {
2140
+ const obsoleteRef = (candidate as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2141
+ try {
2142
+ await candidate.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
2143
+ return candidate;
2144
+ } catch (error) {
2145
+ if (!nativeBackgroundNavigationRequiresReplacement(error) || effectiveOwnership === 'user_owned' || !obsoleteRef) throw error;
2146
+ const replacement = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
2147
+ const replacementPage = replacement as unknown as {
2148
+ tabRef?: () => MacOsBrowserTabRef | undefined;
2149
+ creationEvidence?: () => MacOsBrowserCreateTabEvidence | undefined;
2150
+ };
2151
+ const replacementRef = replacementPage.tabRef?.();
2152
+ if (!replacementRef) {
2153
+ throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native replacement tab did not return a stable plugin-owned tab reference.', { retryable: true });
2154
+ }
2155
+ const creationEvidence = replacementPage.creationEvidence?.();
2156
+ const assignmentProvenanceProven = nativeReplacementAssignmentProvenanceProvesTarget(creationEvidence, target.url, replacementRef);
2157
+ if (!assignmentProvenanceProven) {
2158
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
2159
+ throw new AssistantPluginError(
2160
+ 'PLUGIN_BROWSER_NATIVE_REPLACEMENT_PROVENANCE_UNPROVEN',
2161
+ 'Native replacement tab did not prove exact requested-URL assignment through the stable create-tab provenance contract; preserving the original plugin-owned tab.',
2162
+ {
2163
+ retryable: true,
2164
+ details: {
2165
+ browserProduct: activeAttachment.metadata.product,
2166
+ stableRef: creationEvidence?.refSource === 'structured',
2167
+ provenanceVersion: creationEvidence?.navigation?.provenanceVersion ?? null,
2168
+ assignmentAccepted: creationEvidence?.navigation?.assignmentAccepted === true,
2169
+ requestedUrlMatches: creationEvidence?.navigation
2170
+ ? creationEvidence.navigation.requestedUrl === target.url
2171
+ : false,
2172
+ },
2173
+ },
2174
+ );
2175
+ }
2176
+ try {
2177
+ const previousUrl = target.existingSession?.url ?? candidate.url();
2178
+ const identity = await settleNativeCreatedPageIdentity(
2179
+ replacement as unknown as {
2180
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
2181
+ identity: () => Promise<{ url: string; title: string }>;
2182
+ },
2183
+ waitUntil(args.wait_until),
2184
+ timeout,
2185
+ { requestedUrl: target.url, previousUrl },
2186
+ );
2187
+ if (!nativeReplacementPostAssignmentUrlMatchesTarget(target.url, identity.url, assignmentProvenanceProven)) {
2188
+ const diagnostic = nativeReplacementMismatchDiagnostic({
2189
+ requestedUrl: target.url,
2190
+ actualUrl: identity.url,
2191
+ previousUrl,
2192
+ obsoleteRef,
2193
+ replacementRef,
2194
+ });
2195
+ const diagnosticText = Object.entries(diagnostic)
2196
+ .map(([key, value]) => `${key}=${String(value)}`)
2197
+ .join(',');
2198
+ throw new AssistantPluginError(
2199
+ 'PLUGIN_BROWSER_NATIVE_REPLACEMENT_MISMATCH',
2200
+ `Native replacement tab did not reach the requested URL; preserving the original plugin-owned tab. diagnostic=${diagnosticText}`,
2201
+ { retryable: true, details: diagnostic },
2202
+ );
2203
+ }
2204
+ } catch (replacementError) {
2205
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
2206
+ throw replacementError;
2207
+ }
2208
+ try {
2209
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, obsoleteRef, timeout);
2210
+ } catch (closeError) {
2211
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
2212
+ throw closeError;
2213
+ }
2214
+ createdThisCallRef = replacementRef;
2215
+ navigationUsedReplacement = true;
2216
+ matchedBy = 'owned_token';
2217
+ effectiveOwnership = 'plugin_owned';
2218
+ sessionResume = {
2219
+ sessionId: target.sessionId,
2220
+ status: 'matched',
2221
+ reason: 'Browser broker required replacement for background cross-URL navigation; created and verified a replacement plugin-owned tab before closing the obsolete tab.',
2222
+ savedTab,
2223
+ };
2224
+ return replacement;
2225
+ }
2226
+ };
1225
2227
  try {
2228
+ if (!page && !savedTab && !stringValue(args.session_id)) {
2229
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2230
+ try {
2231
+ inventory = await listMacOsBrowserTabs(activeAttachment.metadata.product, timeout);
2232
+ } catch (error) {
2233
+ if (!nativeTabInventoryUnsupported(error)) throw error;
2234
+ }
2235
+ if (inventory) {
2236
+ const ownedSelection = selectLiveForgeOwnedNativeTab(repoRoot, target.url, activeAttachment.metadata.product, inventory);
2237
+ if (ownedSelection.match === 'ambiguous') {
2238
+ throw new AssistantPluginError(
2239
+ 'PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
2240
+ 'Multiple live Forge-owned browser tabs can satisfy this sessionless navigation; refusing to open another duplicate tab.',
2241
+ { retryable: false, details: { targetUrl: target.url, candidateCount: ownedSelection.candidateCount } },
2242
+ );
2243
+ }
2244
+ if (ownedSelection.candidate) {
2245
+ if (ownedSelection.session) {
2246
+ target.sessionId = ownedSelection.session.sessionId;
2247
+ target.existingSession = ownedSelection.session;
2248
+ }
2249
+ const rebound = await reattachMacOsBrowserOwnedPage(
2250
+ activeAttachment.metadata.product,
2251
+ { windowId: ownedSelection.candidate.windowId, tabId: ownedSelection.candidate.tabId },
2252
+ timeout,
2253
+ );
2254
+ page = rebound.page as unknown as PageLike;
2255
+ discovered = { attachment: rebound.attachment, attempts: rebound.attachment.attempts };
2256
+ activeAttachment = rebound.attachment;
2257
+ matchedBy = 'owned_token';
2258
+ effectiveOwnership = 'plugin_owned';
2259
+ sessionResume = {
2260
+ sessionId: target.sessionId,
2261
+ status: 'matched',
2262
+ reason: ownedSelection.match === 'exact_url'
2263
+ ? 'No session id was supplied; reused the unique live Forge-owned native tab already at the target URL.'
2264
+ : 'No session id was supplied; reused the unique live Forge-owned native tab for the same origin and navigated it in place.',
2265
+ savedTab: ownedSelection.session?.browser?.tab,
2266
+ };
2267
+ } else {
2268
+ const exactSelection = selectExactNativeTab(target.url, inventory);
2269
+ if (exactSelection.match === 'ambiguous') {
2270
+ throw new AssistantPluginError(
2271
+ 'PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
2272
+ 'Multiple existing browser tabs already match the target URL; refusing to open another duplicate tab.',
2273
+ { retryable: false, details: { targetUrl: target.url, candidateCount: exactSelection.candidateCount } },
2274
+ );
2275
+ }
2276
+ if (exactSelection.candidate) {
2277
+ const rebound = await reattachMacOsBrowserOwnedPage(
2278
+ activeAttachment.metadata.product,
2279
+ { windowId: exactSelection.candidate.windowId, tabId: exactSelection.candidate.tabId },
2280
+ timeout,
2281
+ );
2282
+ page = rebound.page as unknown as PageLike;
2283
+ discovered = { attachment: rebound.attachment, attempts: rebound.attachment.attempts };
2284
+ activeAttachment = rebound.attachment;
2285
+ matchedBy = 'exact_url';
2286
+ effectiveOwnership = 'user_owned';
2287
+ recoveredTabTitle = exactSelection.candidate.title;
2288
+ recoveredTabUrl = exactSelection.candidate.url;
2289
+ sessionResume = {
2290
+ sessionId: target.sessionId,
2291
+ status: 'matched',
2292
+ reason: 'No session id was supplied; reused the unique existing native browser tab with the exact target URL instead of opening a duplicate.',
2293
+ };
2294
+ }
2295
+ }
2296
+ }
2297
+ }
1226
2298
  if (!page) {
1227
- page = await createMacOsBrowserOwnedPage(discovered.attachment, target.url, timeout) as unknown as PageLike;
2299
+ page = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
1228
2300
  createdThisCallRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2301
+ const liveIdentity = await settleNativeCreatedPageIdentity(
2302
+ page as unknown as {
2303
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
2304
+ identity: () => Promise<{ url: string; title: string }>;
2305
+ },
2306
+ waitUntil(args.wait_until),
2307
+ timeout,
2308
+ ).catch(() => undefined);
2309
+ if (!liveIdentity?.url || isBlankPage(liveIdentity.url) || comparableUrl(liveIdentity.url) !== comparableUrl(target.url)) {
2310
+ page = await navigateNativePageWithReplacement(page);
2311
+ }
1229
2312
  }
1230
- if (comparableUrl(page.url()) !== comparableUrl(target.url)) {
2313
+ const explicitUserOwnedUrlMatch = effectiveOwnership === 'user_owned'
2314
+ && savedProduct
2315
+ && explicitNativeAdoptionUrlsMatch(target.url, page.url(), savedProduct);
2316
+ if (comparableUrl(page.url()) !== comparableUrl(target.url) && !explicitUserOwnedUrlMatch) {
1231
2317
  const currentRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2318
+ if (effectiveOwnership === 'user_owned') {
2319
+ if (matchedBy === 'recovered_tab') {
2320
+ sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Recovered current user tab was already within the same saved app entity; preserved its current URL instead of navigating it.', savedTab };
2321
+ } else {
2322
+ throw new AssistantPluginError(
2323
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
2324
+ 'Explicitly adopted user-owned tab no longer matches the saved session URL; refusing to navigate, replace, or close the user tab.',
2325
+ { retryable: true, details: { sessionId: target.sessionId, expectedUrl: target.url, actualUrl: page.url(), windowId: savedTab?.windowId, tabId: savedTab?.tabId } },
2326
+ );
2327
+ }
2328
+ }
1232
2329
  const sessionTargetUnchanged = Boolean(target.existingSession?.url) && comparableUrl(target.url) === comparableUrl(target.existingSession?.url ?? '');
1233
2330
  const preserveOwnedSameOriginDrift = matchedBy === 'owned_token' && sessionTargetUnchanged && sameOrigin(page.url(), target.url);
1234
- if (preserveOwnedSameOriginDrift) {
2331
+ if (matchedBy === 'recovered_tab' && effectiveOwnership === 'user_owned') {
2332
+ // Safe recovery adopts an already-open tab. Its current page is authoritative and must not be navigated.
2333
+ } else if (preserveOwnedSameOriginDrift) {
1235
2334
  sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Plugin-owned tab navigated within the same origin; preserved the live tab and refreshed its session URL.', savedTab };
1236
- } else if (!currentRef) {
1237
- await page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
1238
2335
  } else {
1239
- const replacement = await createMacOsBrowserOwnedPage(discovered.attachment, target.url, timeout) as unknown as PageLike;
1240
- const createdRef = (replacement as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
1241
- if (!createdRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Replacement native browser tab did not return a stable plugin-owned tab reference.', { retryable: true });
1242
- replacedRef = currentRef;
1243
- createdThisCallRef = createdRef;
1244
- page = replacement;
1245
- matchedBy = 'new_page';
1246
- sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Reattached to the saved session by replacing its previous plugin-owned background tab.', savedTab };
2336
+ page = await navigateNativePageWithReplacement(page);
2337
+ if (currentRef && !navigationUsedReplacement) {
2338
+ matchedBy = 'owned_token';
2339
+ sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Navigated the existing plugin-owned tab in place and preserved its stable tab identity.', savedTab };
2340
+ }
1247
2341
  }
1248
2342
  }
1249
- if (matchedBy !== 'owned_token' && page.waitForLoadState) {
2343
+ const explicitInternalUserSession = Boolean(
2344
+ target.existingSession
2345
+ && isExplicitNativeUserOwnedSession(target.existingSession)
2346
+ && browserInternalUrlProduct(target.url) === savedProduct,
2347
+ );
2348
+ if (matchedBy !== 'owned_token' && page.waitForLoadState && !explicitInternalUserSession) {
1250
2349
  await page.waitForLoadState(waitUntil(args.wait_until), { timeout });
1251
2350
  }
1252
- const pageUrl = normalizedUrl(page.url());
2351
+ const observedPageUrl = matchedBy === 'recovered_tab' && recoveredTabUrl ? recoveredTabUrl : page.url();
2352
+ const pageUrl = explicitInternalUserSession
2353
+ ? normalizedExplicitNativeAdoptionUrl(observedPageUrl)
2354
+ : normalizedUrl(observedPageUrl);
1253
2355
  const nativeRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
1254
2356
  if (!nativeRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native browser did not return a stable plugin-owned tab reference.', { retryable: true });
1255
- const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : '';
2357
+ const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : matchedBy === 'recovered_tab' ? recoveredTabTitle : '';
1256
2358
  const inventory: BrowserTabInventoryEntry[] = [{ index: 0, key: tabKey(pageUrl, title), url: pageUrl, title }];
1257
- const metadata = discovered.attachment.metadata;
2359
+ const metadata = activeAttachment.metadata;
2360
+ // The live exact-tab URL is authoritative after a safe native rebind. Keep the
2361
+ // action-local target synchronized so read/query responses and the persisted
2362
+ // session report the same observed URL in this round.
2363
+ target.url = pageUrl;
1258
2364
  const connection = refreshConnectionTab({
1259
2365
  requestedMode: 'attach_preferred',
1260
2366
  mode: 'attach_preferred',
1261
2367
  provider: 'macos-apple-events',
1262
2368
  attached: true,
1263
2369
  browserProduct: metadata.product,
2370
+ nativeBrowserCandidates: config.nativeBrowserCandidates,
1264
2371
  profile: {
1265
2372
  profileMode: config.profileMode,
1266
2373
  profileDirectory: config.profileDirectory,
@@ -1269,15 +2376,16 @@ async function openNativeAttachedContext(
1269
2376
  tabInventory: inventory,
1270
2377
  sessionResume,
1271
2378
  }, pageUrl, title, matchedBy, {
1272
- ownership: 'plugin_owned', windowId: nativeRef.windowId, tabId: nativeRef.tabId,
2379
+ ownership: effectiveOwnership === 'user_owned' ? 'user_owned' : 'plugin_owned',
2380
+ windowId: nativeRef.windowId,
2381
+ tabId: nativeRef.tabId,
1273
2382
  });
1274
2383
  const diagnostics: PageDiagnostics = { consoleErrors: [], failedRequests: [], navigation: { url: pageUrl } };
1275
- if (replacedRef) await closeMacOsBrowserOwnedTab(metadata.product, replacedRef, timeout);
1276
2384
  createdThisCallRef = undefined;
1277
2385
  return { attempts: discovered.attempts, handle: { page, diagnostics, connection, close: async () => undefined } };
1278
2386
  } catch (error) {
1279
2387
  if (createdThisCallRef) {
1280
- await closeMacOsBrowserOwnedTab(discovered.attachment.metadata.product, createdThisCallRef, timeout).catch(() => undefined);
2388
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, createdThisCallRef, timeout).catch(() => undefined);
1281
2389
  }
1282
2390
  throw error;
1283
2391
  }
@@ -1291,15 +2399,43 @@ async function openBrowser(
1291
2399
  ): Promise<BrowserOpenHandle> {
1292
2400
  const requestedMode = config.browserMode ?? 'managed_persistent';
1293
2401
  if (requestedMode === 'attach_preferred') {
1294
- const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright')
1295
- ? await openAttachedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args)
2402
+ const requireExistingResource = args.__forge_require_existing_resource === true;
2403
+ const existingProvider = target.existingSession?.browser?.provider;
2404
+ const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright', repoRoot)
2405
+ ? await openAttachedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args)
1296
2406
  : { attempts: [] as CdpAttachAttempt[] };
1297
2407
  if (attached.handle) return attached.handle;
2408
+ if (requireExistingResource && existingProvider === 'playwright-cdp') {
2409
+ throw new AssistantPluginError(
2410
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2411
+ 'Saved CDP browser page is no longer attachable; refusing to fall back to another browser or create a replacement page.',
2412
+ { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
2413
+ );
2414
+ }
2415
+ if (requireExistingResource && existingProvider === 'playwright-persistent-context') {
2416
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
2417
+ throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
2418
+ }
2419
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
2420
+ policy: config.cdpAttachFallback ?? 'fail_closed',
2421
+ from: 'attach_preferred',
2422
+ to: 'managed_persistent',
2423
+ reason: 'Reusing the existing managed fallback session without probing or creating a different browser resource.',
2424
+ attempts: attached.attempts,
2425
+ });
2426
+ }
1298
2427
 
1299
2428
  const native = await openNativeAttachedContext(repoRoot, config, target, args);
1300
2429
  if (native.handle) return native.handle;
2430
+ if (requireExistingResource) {
2431
+ throw new AssistantPluginError(
2432
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2433
+ 'Saved browser resource is no longer attachable; refusing to create a replacement tab/page for this existing-session action.',
2434
+ { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
2435
+ );
2436
+ }
1301
2437
 
1302
- const fallbackPolicy = config.cdpAttachFallback ?? 'managed_persistent';
2438
+ const fallbackPolicy = config.cdpAttachFallback ?? 'fail_closed';
1303
2439
  const cdpReason = attached.attempts.map((attempt) => `${attempt.endpoint}: ${attempt.error ?? 'unavailable'}`).join('; ')
1304
2440
  || 'No CDP endpoint candidates were configured.';
1305
2441
  const nativeReason = native.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`).join('; ')
@@ -1316,10 +2452,10 @@ async function openBrowser(
1316
2452
  },
1317
2453
  });
1318
2454
  }
1319
- if (!runtimeHooks.moduleAvailable('playwright')) {
2455
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
1320
2456
  throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', `Managed browser fallback requires Playwright, but native/CDP attach was unavailable. ${reason}`, { retryable: false });
1321
2457
  }
1322
- return openManagedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
2458
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
1323
2459
  policy: fallbackPolicy,
1324
2460
  from: 'attach_preferred',
1325
2461
  to: 'managed_persistent',
@@ -1329,37 +2465,82 @@ async function openBrowser(
1329
2465
  });
1330
2466
  }
1331
2467
 
1332
- if (!runtimeHooks.moduleAvailable('playwright')) {
2468
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
1333
2469
  throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
1334
2470
  }
1335
- return openManagedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args, requestedMode, requestedMode);
2471
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, requestedMode, requestedMode);
1336
2472
  }
1337
2473
 
1338
2474
  async function withPage<T>(
2475
+ actionId: string,
1339
2476
  repoRoot: string,
1340
2477
  config: BrowserPluginConfig,
1341
2478
  target: BrowserActionTarget,
1342
2479
  args: Record<string, unknown>,
1343
2480
  run: (page: PageLike, diagnostics: PageDiagnostics, connection: BrowserConnectionSummary) => Promise<T>,
1344
- options: { persistSession?: boolean } = {},
2481
+ options: { persistSession?: boolean; requireExistingResource?: boolean; pruneStaleSessionMetadata?: boolean } = {},
1345
2482
  ): Promise<T> {
1346
2483
  const retries = Math.min(Math.max(positiveNumber(args.retries, 1), 1), 3);
2484
+ const requireExistingResource = options.requireExistingResource ?? Boolean(target.existingSession);
2485
+ const postDispatchReplaySafe = canReplayBrowserActionAfterDispatch(actionId, requireExistingResource);
1347
2486
  let lastError: unknown;
1348
2487
  for (let attempt = 1; attempt <= retries; attempt += 1) {
1349
2488
  let handle: BrowserOpenHandle | undefined;
2489
+ // Opening a browser resource may itself create or navigate a tab before a
2490
+ // handle is returned. Replay is safe only for an observation action that is
2491
+ // fenced to an already-existing resource. Anything else is potentially
2492
+ // externally mutating from the outset.
2493
+ let actionDispatched = !postDispatchReplaySafe;
1350
2494
  try {
1351
- handle = await openBrowser(repoRoot, config, target, args);
2495
+ if (target.existingSession) assertBrowserSessionAvailable(repoRoot, target.sessionId);
2496
+ const browserArgs = requireExistingResource
2497
+ ? { ...args, __forge_require_existing_resource: true }
2498
+ : args;
2499
+ handle = await openBrowser(repoRoot, config, target, browserArgs);
2500
+ actionDispatched = true;
1352
2501
  const result = await run(handle.page, handle.diagnostics, handle.connection);
1353
2502
  if (options.persistSession) {
1354
- const identity = await readPageIdentity(handle.page, handle.connection);
1355
- saveSession(repoRoot, sessionFromPage(target, identity.url, identity.title, handle.connection));
2503
+ const identity = await readPageIdentity(handle.page, handle.connection, target.url);
2504
+ saveBrowserSession(repoRoot, sessionFromPage(target, identity.url, identity.title, handle.connection));
2505
+ if (result && typeof result === 'object' && !Array.isArray(result) && Object.prototype.hasOwnProperty.call(result, 'url')) {
2506
+ (result as Record<string, unknown>).url = identity.url;
2507
+ }
1356
2508
  }
1357
2509
  return result;
1358
2510
  } catch (error) {
1359
2511
  lastError = error;
2512
+ const nativeTab = handle?.connection.provider === 'macos-apple-events' ? handle.connection.tab : undefined;
2513
+ if (handle?.connection.provider === 'macos-apple-events'
2514
+ && handle.connection.browserProduct
2515
+ && nativeTab?.windowId
2516
+ && nativeTab.tabId
2517
+ && macOsBrowserPageHandleStale(error)) {
2518
+ // Do not replay the current action. Evict only so the next independent action must
2519
+ // reattach/recover through the existing cold-path identity checks.
2520
+ invalidateMacOsBrowserPageHandle(handle.connection.browserProduct, {
2521
+ windowId: nativeTab.windowId,
2522
+ tabId: nativeTab.tabId,
2523
+ });
2524
+ }
2525
+ if (options.pruneStaleSessionMetadata
2526
+ && target.existingSession
2527
+ && error instanceof AssistantPluginError
2528
+ && error.code === 'PLUGIN_BROWSER_SESSION_STATE_LOST') {
2529
+ removeBrowserSession(repoRoot, target.sessionId);
2530
+ }
1360
2531
  const message = error instanceof Error ? error.message : String(error);
1361
- const transient = /timeout|net::|ERR_|Navigation failed|Target closed|Protocol error/i.test(message);
1362
- if (!transient || attempt >= retries) throw error;
2532
+ const transient = error instanceof AssistantPluginError
2533
+ ? error.retryable
2534
+ : /timeout|timed out|net::|ERR_|Navigation failed|Target closed|Protocol error/i.test(message);
2535
+ if (!transient) throw error;
2536
+ if (actionDispatched && !postDispatchReplaySafe) {
2537
+ throw new AssistantPluginError(
2538
+ 'PLUGIN_BROWSER_MUTATION_OUTCOME_UNKNOWN',
2539
+ `Browser action ${actionId} reported a transient failure after dispatch. Forge refused automatic replay because the external mutation outcome is unknown; verify current browser state before retrying.`,
2540
+ { retryable: false, details: { actionId, requestedRetries: retries } },
2541
+ );
2542
+ }
2543
+ if (attempt >= retries) throw error;
1363
2544
  } finally {
1364
2545
  if (handle) await handle.close();
1365
2546
  }
@@ -1368,17 +2549,112 @@ async function withPage<T>(
1368
2549
  }
1369
2550
 
1370
2551
  function listSavedSessions(repoRoot: string): BrowserSessionState[] {
1371
- const dir = stateDir(repoRoot, 'sessions');
1372
- if (!existsSync(dir)) return [];
1373
- try {
1374
- return readdirSync(dir)
1375
- .filter((name) => name.endsWith('.json'))
1376
- .map((name) => readJson<BrowserSessionState>(join(dir, name)))
1377
- .filter((entry): entry is BrowserSessionState => Boolean(entry?.sessionId && entry.url))
1378
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
1379
- } catch {
1380
- return [];
2552
+ return listSavedBrowserSessions(repoRoot)
2553
+ .filter((entry) => Boolean(entry.sessionId && entry.url))
2554
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
2555
+ }
2556
+
2557
+ async function inspectSavedSessions(
2558
+ repoRoot: string,
2559
+ config: BrowserPluginConfig,
2560
+ options: { pruneDead?: boolean } = {},
2561
+ ): Promise<BrowserSessionInventory> {
2562
+ const saved = listSavedSessions(repoRoot);
2563
+ const sessions: BrowserSessionInventoryItem[] = [];
2564
+ let liveManagedSessionCount = 0;
2565
+ let unverifiedSessionCount = 0;
2566
+ let deadManagedSessionCount = 0;
2567
+ let prunedSessionCount = 0;
2568
+ const native = await inspectNativeOwnedSessions({ repoRoot, savedSessions: saved, timeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, pruneDead: options.pruneDead });
2569
+ let liveNativeSessionCount = native.liveCount;
2570
+ let deadNativeSessionCount = native.deadCount;
2571
+ let closedInvalidNativeSessionCount = native.closedInvalidCount;
2572
+ let failedNativeCleanupCount = native.failedCleanupCount;
2573
+ prunedSessionCount += native.prunedCount;
2574
+ unverifiedSessionCount += native.unverifiedCount;
2575
+
2576
+ for (const session of saved) {
2577
+ const nativeItem = native.items.get(session.sessionId);
2578
+ if (nativeItem) {
2579
+ sessions.push(nativeItem);
2580
+ continue;
2581
+ }
2582
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') {
2583
+ sessions.push({ session, liveness: 'unverified', evidence: 'provider_unverified' });
2584
+ unverifiedSessionCount += 1;
2585
+ continue;
2586
+ }
2587
+
2588
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
2589
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
2590
+ if (!pending) {
2591
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_context_unavailable' });
2592
+ unverifiedSessionCount += 1;
2593
+ continue;
2594
+ }
2595
+
2596
+ let state: ManagedBrowserContextState;
2597
+ try {
2598
+ state = await pending;
2599
+ } catch {
2600
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_context_unavailable' });
2601
+ unverifiedSessionCount += 1;
2602
+ continue;
2603
+ }
2604
+
2605
+ const pages = state.context.pages();
2606
+ const boundPage = state.pagesBySession.get(session.sessionId);
2607
+ if (boundPage) {
2608
+ if (pages.includes(boundPage)) {
2609
+ sessions.push({ session, liveness: 'live', evidence: 'runtime_session_binding' });
2610
+ liveManagedSessionCount += 1;
2611
+ continue;
2612
+ }
2613
+
2614
+ const pruned = options.pruneDead === true;
2615
+ if (pruned) {
2616
+ state.pagesBySession.delete(session.sessionId);
2617
+ removeBrowserSession(repoRoot, session.sessionId);
2618
+ prunedSessionCount += 1;
2619
+ }
2620
+ sessions.push({ session, liveness: 'dead', evidence: 'runtime_bound_page_missing', pruned });
2621
+ deadManagedSessionCount += 1;
2622
+ continue;
2623
+ }
2624
+
2625
+ const expectedOwnerToken = session.browser.tab?.ownership === 'plugin_owned' ? session.browser.tab.ownerToken : undefined;
2626
+ if (expectedOwnerToken) {
2627
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
2628
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
2629
+ const page = index >= 0 ? state.context.pages()[index] : undefined;
2630
+ if (page) {
2631
+ state.pagesBySession.set(session.sessionId, page);
2632
+ sessions.push({ session, liveness: 'live', evidence: 'runtime_owner_token' });
2633
+ liveManagedSessionCount += 1;
2634
+ continue;
2635
+ }
2636
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_owner_token_not_observed' });
2637
+ unverifiedSessionCount += 1;
2638
+ continue;
2639
+ }
2640
+
2641
+ sessions.push({ session, liveness: 'unverified', evidence: 'provider_unverified' });
2642
+ unverifiedSessionCount += 1;
1381
2643
  }
2644
+
2645
+ return {
2646
+ sessions,
2647
+ scannedSessionCount: saved.length,
2648
+ savedSessionCount: saved.length - prunedSessionCount,
2649
+ liveManagedSessionCount,
2650
+ liveNativeSessionCount,
2651
+ unverifiedSessionCount,
2652
+ deadManagedSessionCount,
2653
+ deadNativeSessionCount,
2654
+ closedInvalidNativeSessionCount,
2655
+ failedNativeCleanupCount,
2656
+ prunedSessionCount,
2657
+ };
1382
2658
  }
1383
2659
 
1384
2660
  const STABLE_SELECTOR_HELPERS = `
@@ -1459,7 +2735,7 @@ const STABLE_SELECTOR_HELPERS = `
1459
2735
  const EXTRACTION_SCRIPTS = {
1460
2736
  query: (selector: string, limit: number) => `(() => {
1461
2737
  ${STABLE_SELECTOR_HELPERS}
1462
- const nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).slice(0, ${limit});
2738
+ const nodes = ${openShadowSelectorExpression(selector, true)}.slice(0, ${limit});
1463
2739
  return nodes.map((el) => {
1464
2740
  const role = el.getAttribute('role');
1465
2741
  const text = (el.innerText || el.textContent || '').trim().slice(0, 120);
@@ -1467,11 +2743,11 @@ const EXTRACTION_SCRIPTS = {
1467
2743
  });
1468
2744
  })()`,
1469
2745
  attribute: (selector: string, attribute: string) => `(() => {
1470
- const el = document.querySelector(${JSON.stringify(selector)});
2746
+ const el = ${openShadowSelectorExpression(selector)};
1471
2747
  return el ? el.getAttribute(${JSON.stringify(attribute)}) : null;
1472
2748
  })()`,
1473
2749
  html: (selector?: string) => selector
1474
- ? `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? el.outerHTML : ''; })()`
2750
+ ? `(() => { const el = ${openShadowSelectorExpression(selector)}; return el ? el.outerHTML : ''; })()`
1475
2751
  : 'document.documentElement ? document.documentElement.outerHTML : ""',
1476
2752
  links: (limit: number) => `(() => {
1477
2753
  ${STABLE_SELECTOR_HELPERS}
@@ -1501,10 +2777,44 @@ const EXTRACTION_SCRIPTS = {
1501
2777
  })()`,
1502
2778
  interactive: (limit: number) => `(() => {
1503
2779
  ${STABLE_SELECTOR_HELPERS}
1504
- return Array.from(document.querySelectorAll('a,button,input,select,textarea,[role="button"],[role="link"]')).slice(0, ${limit}).map((el) => {
2780
+ const viewport = {
2781
+ width: Number(window.innerWidth || document.documentElement?.clientWidth || 0),
2782
+ height: Number(window.innerHeight || document.documentElement?.clientHeight || 0),
2783
+ scrollX: Number(window.scrollX || window.pageXOffset || 0),
2784
+ scrollY: Number(window.scrollY || window.pageYOffset || 0),
2785
+ devicePixelRatio: Number(window.devicePixelRatio || 1),
2786
+ };
2787
+ const elements = Array.from(document.querySelectorAll('a,button,input,select,textarea,[contenteditable="true"],[role="button"],[role="link"],[role="textbox"]')).slice(0, ${limit}).map((el) => {
1505
2788
  const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('value') || el.textContent || '').trim().slice(0, 100);
1506
- return { tag: el.tagName.toLowerCase(), text, type: el.getAttribute('type') || undefined, href: el.getAttribute('href') || undefined, selectorHint: forgeSelectorHint(el) };
2789
+ const rect = el.getBoundingClientRect();
2790
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : undefined;
2791
+ const width = Number(rect.width || 0);
2792
+ const height = Number(rect.height || 0);
2793
+ const left = Number(rect.left || 0);
2794
+ const top = Number(rect.top || 0);
2795
+ const right = Number(rect.right || (left + width));
2796
+ const bottom = Number(rect.bottom || (top + height));
2797
+ const visible = width > 0 && height > 0 && style?.display !== 'none' && style?.visibility !== 'hidden' && Number(style?.opacity ?? 1) !== 0;
2798
+ const inViewport = visible && right > 0 && bottom > 0 && left < viewport.width && top < viewport.height;
2799
+ const disabled = Boolean(el.disabled || el.getAttribute('aria-disabled') === 'true');
2800
+ const editable = !disabled && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable || el.getAttribute('role') === 'textbox');
2801
+ return {
2802
+ tag: el.tagName.toLowerCase(),
2803
+ text,
2804
+ type: el.getAttribute('type') || undefined,
2805
+ href: el.getAttribute('href') || undefined,
2806
+ role: el.getAttribute('role') || undefined,
2807
+ ariaLabel: el.getAttribute('aria-label') || undefined,
2808
+ selectorHint: forgeSelectorHint(el),
2809
+ bounds: { x: left, y: top, width, height, right, bottom },
2810
+ center: { x: left + width / 2, y: top + height / 2 },
2811
+ visible,
2812
+ inViewport,
2813
+ disabled,
2814
+ editable,
2815
+ };
1507
2816
  });
2817
+ return { geometryVersion: 1, viewport, elements };
1508
2818
  })()`,
1509
2819
  };
1510
2820
 
@@ -1518,17 +2828,229 @@ function selectorRepairHint(selector: string, errorMessage: string): string {
1518
2828
  return `Check selector "${selector}" against the current page structure.`;
1519
2829
  }
1520
2830
 
1521
- async function extractText(page: PageLike, selector: string | undefined, maxChars: number): Promise<Record<string, unknown>> {
1522
- const raw = await page.evaluate<string>(scriptText(selector));
2831
+ type BrowserEvaluationScope = Pick<PageLike, 'evaluate'>;
2832
+
2833
+ type BrowserFrameSelection = {
2834
+ scope: BrowserEvaluationScope;
2835
+ frame?: { url: string; name: string };
2836
+ frameHandle?: FrameLike;
2837
+ };
2838
+
2839
+ function requestedFrameSelection(args: Record<string, unknown>): { frameUrl?: string; frameName?: string } {
2840
+ return { frameUrl: stringValue(args.frame_url), frameName: stringValue(args.frame_name) };
2841
+ }
2842
+
2843
+ function resolveBrowserEvaluationScope(
2844
+ page: PageLike,
2845
+ args: Record<string, unknown>,
2846
+ connection: BrowserConnectionSummary,
2847
+ ): BrowserFrameSelection {
2848
+ const { frameUrl, frameName } = requestedFrameSelection(args);
2849
+ if (!frameUrl && !frameName) return { scope: page };
2850
+ if (connection.provider === 'macos-apple-events' || !page.frames) {
2851
+ throw new AssistantPluginError(
2852
+ 'PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE',
2853
+ 'Explicit frame targeting requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.',
2854
+ { retryable: false, details: { provider: connection.provider, frameUrl, frameName } },
2855
+ );
2856
+ }
2857
+ const matches = page.frames().filter((frame) => (!frameUrl || frame.url() === frameUrl) && (!frameName || frame.name() === frameName));
2858
+ if (matches.length === 0) {
2859
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_NOT_FOUND', 'No browser frame matched the requested exact frame identity.', {
2860
+ retryable: true,
2861
+ details: { frameUrl, frameName },
2862
+ });
2863
+ }
2864
+ if (matches.length > 1) {
2865
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_AMBIGUOUS', 'Multiple browser frames matched the requested frame identity; specify both frame_url and frame_name.', {
2866
+ retryable: false,
2867
+ details: { frameUrl, frameName, matchCount: matches.length },
2868
+ });
2869
+ }
2870
+ const frame = matches[0]!;
2871
+ return { scope: frame, frame: { url: frame.url(), name: frame.name() }, frameHandle: frame };
2872
+ }
2873
+
2874
+ const VIEWPORT_METRICS_SCRIPT = `(() => ({
2875
+ viewportMetricsVersion: 1,
2876
+ viewport: {
2877
+ width: Number(window.innerWidth || document.documentElement?.clientWidth || 0),
2878
+ height: Number(window.innerHeight || document.documentElement?.clientHeight || 0),
2879
+ scrollX: Number(window.scrollX || window.pageXOffset || 0),
2880
+ scrollY: Number(window.scrollY || window.pageYOffset || 0),
2881
+ devicePixelRatio: Number(window.devicePixelRatio || 1),
2882
+ }
2883
+ }))()`;
2884
+
2885
+ async function resolveFrameViewportOffset(page: PageLike, selection: BrowserFrameSelection): Promise<{ x: number; y: number; width: number; height: number } | undefined> {
2886
+ const frame = selection.frameHandle;
2887
+ if (!frame) return undefined;
2888
+ if (page.mainFrame && page.mainFrame() === frame) return { x: 0, y: 0, width: 0, height: 0 };
2889
+ if (!frame.frameElement) {
2890
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'The selected Playwright frame does not expose a frame element for coordinate grounding.', {
2891
+ retryable: false,
2892
+ details: selection.frame,
2893
+ });
2894
+ }
2895
+ let box: { x: number; y: number; width: number; height: number } | null;
2896
+ try {
2897
+ box = await (await frame.frameElement()).boundingBox();
2898
+ } catch (error) {
2899
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Could not resolve the selected frame element bounding box.', {
2900
+ retryable: true,
2901
+ details: { ...selection.frame, cause: error instanceof Error ? error.message : String(error) },
2902
+ });
2903
+ }
2904
+ if (!box || ![box.x, box.y, box.width, box.height].every((value) => Number.isFinite(value))) {
2905
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'The selected frame element has no stable visible bounding box; refusing to guess trusted-input coordinates.', {
2906
+ retryable: true,
2907
+ details: selection.frame,
2908
+ });
2909
+ }
2910
+ return box;
2911
+ }
2912
+
2913
+ function translateFrameGroundingElements(elements: unknown[], offset: { x: number; y: number }, viewport: unknown): unknown[] {
2914
+ const viewportRecord = viewport && typeof viewport === 'object' && !Array.isArray(viewport)
2915
+ ? viewport as Record<string, unknown>
2916
+ : {};
2917
+ const viewportWidth = typeof viewportRecord.width === 'number' && Number.isFinite(viewportRecord.width) ? viewportRecord.width : undefined;
2918
+ const viewportHeight = typeof viewportRecord.height === 'number' && Number.isFinite(viewportRecord.height) ? viewportRecord.height : undefined;
2919
+ if (viewportWidth === undefined || viewportHeight === undefined) {
2920
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Top-level viewport dimensions are not finite for frame grounding.', { retryable: true });
2921
+ }
2922
+ return elements.map((entry) => {
2923
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
2924
+ const record = entry as Record<string, unknown>;
2925
+ const bounds = record.bounds && typeof record.bounds === 'object' && !Array.isArray(record.bounds)
2926
+ ? record.bounds as Record<string, unknown>
2927
+ : {};
2928
+ const center = record.center && typeof record.center === 'object' && !Array.isArray(record.center)
2929
+ ? record.center as Record<string, unknown>
2930
+ : {};
2931
+ const required = (value: unknown, field: string): number => {
2932
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
2933
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', `Frame grounding field ${field} is not finite.`, { retryable: true });
2934
+ }
2935
+ return value;
2936
+ };
2937
+ const x = required(bounds.x, 'bounds.x');
2938
+ const y = required(bounds.y, 'bounds.y');
2939
+ const width = required(bounds.width, 'bounds.width');
2940
+ const height = required(bounds.height, 'bounds.height');
2941
+ const right = required(bounds.right, 'bounds.right');
2942
+ const bottom = required(bounds.bottom, 'bounds.bottom');
2943
+ const centerX = required(center.x, 'center.x');
2944
+ const centerY = required(center.y, 'center.y');
2945
+ const translatedX = x + offset.x;
2946
+ const translatedY = y + offset.y;
2947
+ const translatedRight = right + offset.x;
2948
+ const translatedBottom = bottom + offset.y;
2949
+ const frameLocalInViewport = record.inViewport === true;
2950
+ const topLevelInViewport = translatedRight > 0
2951
+ && translatedBottom > 0
2952
+ && translatedX < viewportWidth
2953
+ && translatedY < viewportHeight;
2954
+ return {
2955
+ ...record,
2956
+ bounds: {
2957
+ x: translatedX,
2958
+ y: translatedY,
2959
+ width,
2960
+ height,
2961
+ right: translatedRight,
2962
+ bottom: translatedBottom,
2963
+ },
2964
+ center: { x: centerX + offset.x, y: centerY + offset.y },
2965
+ frameLocalInViewport,
2966
+ topLevelInViewport,
2967
+ inViewport: frameLocalInViewport && topLevelInViewport,
2968
+ };
2969
+ });
2970
+ }
2971
+
2972
+ async function extractText(scope: BrowserEvaluationScope, selector: string | undefined, maxChars: number): Promise<Record<string, unknown>> {
2973
+ const raw = await scope.evaluate<string>(scriptText(selector));
1523
2974
  return truncateText(raw, maxChars);
1524
2975
  }
1525
2976
 
1526
- async function readPageIdentity(page: PageLike, connection?: BrowserConnectionSummary): Promise<{ url: string; title: string }> {
2977
+ function trustedInputGuardScript(selector: string): string {
2978
+ const encodedSelector = JSON.stringify(selector);
2979
+ return `(() => {
2980
+ let element;
2981
+ try { element = document.querySelector(${encodedSelector}); }
2982
+ catch (error) { return { trustedInputGuardVersion: 1, found: false, selectorError: String(error) }; }
2983
+ if (!element) return { trustedInputGuardVersion: 1, found: false };
2984
+ const rect = element.getBoundingClientRect();
2985
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : undefined;
2986
+ const visible = Number(rect.width || 0) > 0
2987
+ && Number(rect.height || 0) > 0
2988
+ && style?.display !== 'none'
2989
+ && style?.visibility !== 'hidden'
2990
+ && Number(style?.opacity ?? 1) !== 0;
2991
+ return {
2992
+ trustedInputGuardVersion: 1,
2993
+ found: true,
2994
+ visible,
2995
+ bounds: {
2996
+ x: Number(rect.left || 0), y: Number(rect.top || 0),
2997
+ width: Number(rect.width || 0), height: Number(rect.height || 0),
2998
+ right: Number(rect.right || 0), bottom: Number(rect.bottom || 0),
2999
+ },
3000
+ };
3001
+ })()`;
3002
+ }
3003
+
3004
+ function verifyStateObservationScript(selector: string | undefined, textContains: string | undefined, maxChars: number): string {
3005
+ const payload = JSON.stringify({ selector: selector ?? null, textContains: textContains ?? null, maxChars });
3006
+ return `(() => {
3007
+ const args = ${payload};
3008
+ const element = args.selector ? document.querySelector(args.selector) : null;
3009
+ const textTarget = element || document.body || document.documentElement;
3010
+ const rawText = String(textTarget?.innerText || textTarget?.textContent || '');
3011
+ let visible;
3012
+ if (args.selector) {
3013
+ if (!element) visible = false;
3014
+ else {
3015
+ const rect = element.getBoundingClientRect();
3016
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : undefined;
3017
+ visible = Number(rect.width || 0) > 0
3018
+ && Number(rect.height || 0) > 0
3019
+ && style?.display !== 'none'
3020
+ && style?.visibility !== 'hidden'
3021
+ && Number(style?.opacity ?? 1) !== 0;
3022
+ }
3023
+ }
3024
+ return {
3025
+ verificationVersion: 1,
3026
+ selectorExists: args.selector ? Boolean(element) : undefined,
3027
+ visible,
3028
+ textContains: args.textContains === null ? undefined : rawText.includes(args.textContains),
3029
+ textSample: rawText.slice(0, args.maxChars),
3030
+ textLength: rawText.length,
3031
+ truncated: rawText.length > args.maxChars,
3032
+ };
3033
+ })()`;
3034
+ }
3035
+
3036
+ async function readPageIdentity(page: PageLike, connection?: BrowserConnectionSummary, expectedUrl?: string): Promise<{ url: string; title: string }> {
3037
+ const normalizeObservedUrl = (value: unknown): string => {
3038
+ const internalProduct = browserInternalUrlProduct(value);
3039
+ if (connection?.provider === 'macos-apple-events' && connection.tab?.ownership === 'user_owned') {
3040
+ if (connection.browserProduct && expectedUrl && explicitNativeAdoptionUrlsMatch(expectedUrl, value, connection.browserProduct)) {
3041
+ return normalizedExplicitNativeAdoptionUrl(expectedUrl);
3042
+ }
3043
+ if (internalProduct && internalProduct === connection.browserProduct) {
3044
+ return normalizedExplicitNativeAdoptionUrl(value);
3045
+ }
3046
+ }
3047
+ return normalizedUrl(value);
3048
+ };
1527
3049
  if (connection?.provider === 'macos-apple-events' && typeof page.identity === 'function') {
1528
3050
  const identity = await page.identity();
1529
- return { url: normalizedUrl(identity.url), title: identity.title };
3051
+ return { url: normalizeObservedUrl(identity.url), title: identity.title };
1530
3052
  }
1531
- return { url: normalizedUrl(page.url()), title: await page.title() };
3053
+ return { url: normalizeObservedUrl(page.url()), title: await page.title() };
1532
3054
  }
1533
3055
 
1534
3056
  async function finalizeInteractiveAction(
@@ -1542,13 +3064,12 @@ async function finalizeInteractiveAction(
1542
3064
  extra: Record<string, unknown> = {},
1543
3065
  ): Promise<Record<string, unknown>> {
1544
3066
  const warnings: string[] = [];
1545
- const identity = await readPageIdentity(page, connection);
3067
+ const identity = await readPageIdentity(page, connection, target.url);
1546
3068
  const title = identity.title;
1547
3069
  const pageUrl = identity.url;
1548
- const session = sessionFromPage(target, pageUrl, title, connection);
1549
- saveSession(repoRoot, session);
3070
+ const session = saveBrowserSession(repoRoot, sessionFromPage(target, pageUrl, title, connection));
1550
3071
  let screenshot: BrowserActionScreenshot | undefined;
1551
- const silentNativeEvidence = connection.provider === 'macos-apple-events' && connection.tab?.ownership === 'plugin_owned';
3072
+ const silentNativeEvidence = connection.provider === 'macos-apple-events';
1552
3073
  if (!silentNativeEvidence) {
1553
3074
  try {
1554
3075
  screenshot = await captureActionScreenshot(page, repoRoot, actionId, session.sessionId, session.url);
@@ -1568,475 +3089,6 @@ async function finalizeInteractiveAction(
1568
3089
  };
1569
3090
  }
1570
3091
 
1571
- function permissions(ready: boolean): AssistantPluginPermissionScope[] {
1572
- return [
1573
- {
1574
- scope: 'browser.read',
1575
- mode: 'read',
1576
- description: 'Open pages, extract text, and capture screenshots.',
1577
- granted: ready,
1578
- required: true,
1579
- },
1580
- {
1581
- scope: 'browser.interact',
1582
- mode: 'write',
1583
- description: 'Click, type, press keys, and wait on allowed browser pages after explicit authorization.',
1584
- granted: ready,
1585
- required: true,
1586
- },
1587
- {
1588
- scope: 'browser.profile',
1589
- mode: 'write',
1590
- description: 'Persist the dedicated local browser profile, session metadata, and screenshots.',
1591
- granted: ready,
1592
- required: true,
1593
- },
1594
- ];
1595
- }
1596
-
1597
- function capabilities(): AssistantPluginCapability[] {
1598
- return [
1599
- {
1600
- capabilityId: 'browser-session',
1601
- title: 'Browser Sessions',
1602
- description: 'Create, list, reuse, and close persistent browser sessions.',
1603
- scopes: ['browser.read', 'browser.profile'],
1604
- actions: ['create_session', 'list_sessions', 'close_session', 'close_page', 'clear_session'],
1605
- },
1606
- {
1607
- capabilityId: 'browser-human-handoff',
1608
- title: 'Browser Human Handoff',
1609
- description: 'Present the persistent browser in the foreground, keep it open for manual verification, and resume safely.',
1610
- scopes: ['browser.read', 'browser.profile'],
1611
- actions: ['request_human_handoff', 'get_handoff_status', 'resolve_handoff'],
1612
- },
1613
- {
1614
- capabilityId: 'browser-readonly',
1615
- title: 'Read-only Browser',
1616
- description: 'Navigate allowed pages, extract DOM/text, capture screenshots, and collect diagnostics.',
1617
- scopes: ['browser.read', 'browser.profile'],
1618
- actions: [
1619
- 'open_page', 'navigate', 'reload', 'go_back', 'wait_for_load_state',
1620
- 'get_text', 'get_html', 'query_selector', 'query_all', 'get_attribute',
1621
- 'screenshot', 'extract_links', 'extract_tables', 'extract_forms', 'snapshot_interactive',
1622
- 'get_console_errors', 'get_failed_requests',
1623
- ],
1624
- },
1625
- {
1626
- capabilityId: 'browser-interaction',
1627
- title: 'Browser Interaction',
1628
- description: 'Perform explicit form and pointer interactions on HTTP(S) pages through the persistent Playwright profile.',
1629
- scopes: ['browser.interact', 'browser.profile'],
1630
- actions: [
1631
- 'click', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
1632
- 'check', 'uncheck', 'press', 'keyboard_shortcut', 'wait_for_selector', 'attach_local_file', 'await_file_transfer',
1633
- ],
1634
- },
1635
- ];
1636
- }
1637
-
1638
- function sessionTargetSchema(extra: Record<string, unknown> = {}, required: string[] = []): Record<string, unknown> {
1639
- return {
1640
- type: 'object',
1641
- properties: {
1642
- session_id: { type: 'string' },
1643
- url: { type: 'string' },
1644
- wait_until: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] },
1645
- timeout_ms: { type: 'number' },
1646
- retries: { type: 'number' },
1647
- ...extra,
1648
- },
1649
- ...(required.length > 0 ? { required } : {}),
1650
- additionalProperties: false,
1651
- };
1652
- }
1653
-
1654
- function interactSchema(extra: Record<string, unknown>, required: string[]): Record<string, unknown> {
1655
- return sessionTargetSchema({
1656
- post_action_wait_ms: { type: 'number' },
1657
- ...extra,
1658
- }, required);
1659
- }
1660
-
1661
- function actions(): AssistantPluginActionDescriptor[] {
1662
- const readRemote = [
1663
- { resource: 'remote' as const, mode: 'read' as const },
1664
- { resource: 'repo-state' as const, mode: 'write' as const },
1665
- ];
1666
- const writeRemote = [
1667
- { resource: 'remote' as const, mode: 'exclusive' as const },
1668
- { resource: 'repo-state' as const, mode: 'write' as const },
1669
- ];
1670
- return [
1671
- {
1672
- actionId: 'configure',
1673
- title: 'Configure browser plugin',
1674
- description: 'Enable or update the local browser plugin configuration.',
1675
- readOnly: false,
1676
- risk: 'workspace_write',
1677
- confirmation: 'authorization',
1678
- defaultTimeoutMs: 30_000,
1679
- cancellable: true,
1680
- idempotent: true,
1681
- scopes: ['browser.profile'],
1682
- resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
1683
- argumentsSchema: {
1684
- type: 'object',
1685
- properties: {
1686
- enabled: { type: 'boolean' },
1687
- browser_mode: { type: 'string', enum: ['attach_preferred', 'managed_persistent', 'isolated'] },
1688
- profile_mode: { type: 'string', enum: ['repo_local', 'custom'] },
1689
- profile_dir: { type: 'string' },
1690
- profile_directory: { type: 'string' },
1691
- clear_profile_dir: { type: 'boolean' },
1692
- clear_profile_directory: { type: 'boolean' },
1693
- browser_channel: { type: 'string', enum: ['chromium', 'chrome', 'chrome-beta', 'chrome-dev', 'chrome-canary'] },
1694
- clear_browser_channel: { type: 'boolean' },
1695
- browser_executable_path: { type: 'string' },
1696
- clear_browser_executable_path: { type: 'boolean' },
1697
- cdp_endpoint: { type: 'string' },
1698
- clear_cdp_endpoint: { type: 'boolean' },
1699
- cdp_endpoint_candidates: { type: 'array', items: { type: 'string' }, maxItems: MAX_CDP_ENDPOINT_CANDIDATES },
1700
- clear_cdp_endpoint_candidates: { type: 'boolean' },
1701
- cdp_discovery_timeout_ms: { type: 'number' },
1702
- cdp_attach_fallback: { type: 'string', enum: ['managed_persistent', 'fail_closed'] },
1703
- native_attach_mode: { type: 'string', enum: ['auto', 'disabled'] },
1704
- native_browser_candidates: { type: 'array', items: { type: 'string', enum: ['vivaldi', 'chrome'] }, maxItems: 2 },
1705
- default_timeout_ms: { type: 'number' },
1706
- },
1707
- additionalProperties: false,
1708
- },
1709
- },
1710
- {
1711
- actionId: 'create_session',
1712
- title: 'Create browser session',
1713
- description: 'Open an HTTP(S) URL and persist a reusable session id.',
1714
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1715
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }, ...readRemote],
1716
- argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
1717
- },
1718
- {
1719
- actionId: 'list_sessions',
1720
- title: 'List browser sessions',
1721
- description: 'List saved browser session metadata without secrets or cookies.',
1722
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
1723
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'read' }],
1724
- argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
1725
- },
1726
- {
1727
- actionId: 'close_session',
1728
- title: 'Close browser session',
1729
- description: 'Remove one saved session metadata record.',
1730
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
1731
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
1732
- argumentsSchema: { type: 'object', properties: { session_id: { type: 'string' } }, required: ['session_id'], additionalProperties: false },
1733
- },
1734
- {
1735
- actionId: 'clear_session',
1736
- title: 'Clear all browser sessions',
1737
- description: 'Remove all saved session metadata while keeping the profile.',
1738
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
1739
- scopes: ['browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
1740
- argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
1741
- },
1742
- {
1743
- actionId: 'request_human_handoff',
1744
- title: 'Request human browser handoff',
1745
- description: 'Keep a saved browser session open for CAPTCHA, login, two-factor authentication, or another manual step.',
1746
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false,
1747
- scopes: ['browser.read', 'browser.profile'], resourceClaims: readRemote,
1748
- argumentsSchema: sessionTargetSchema({
1749
- reason: { type: 'string', enum: ['captcha', 'login', 'two_factor', 'manual_review', 'sensitive_confirmation'] },
1750
- instructions: { type: 'string' },
1751
- handoff_timeout_ms: { type: 'number' },
1752
- }, ['session_id']),
1753
- },
1754
- {
1755
- actionId: 'get_handoff_status',
1756
- title: 'Get browser handoff status',
1757
- description: 'Read durable browser handoff status and reconcile a stale or crashed host.',
1758
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
1759
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'read' }],
1760
- argumentsSchema: { type: 'object', properties: { interaction_id: { type: 'string' } }, required: ['interaction_id'], additionalProperties: false },
1761
- },
1762
- {
1763
- actionId: 'resolve_handoff',
1764
- title: 'Resolve browser handoff',
1765
- description: 'Resume or cancel a foreground browser handoff.',
1766
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
1767
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
1768
- argumentsSchema: {
1769
- type: 'object',
1770
- properties: {
1771
- interaction_id: { type: 'string' },
1772
- resolution: { type: 'string', enum: ['resume', 'cancel'] },
1773
- },
1774
- required: ['interaction_id', 'resolution'],
1775
- additionalProperties: false,
1776
- },
1777
- },
1778
- {
1779
- actionId: 'open_page',
1780
- title: 'Open page',
1781
- description: 'Open an HTTP(S) URL with the persistent profile and save a lightweight session.',
1782
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1783
- scopes: ['browser.read'], resourceClaims: readRemote,
1784
- argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
1785
- },
1786
- {
1787
- actionId: 'navigate',
1788
- title: 'Navigate',
1789
- description: 'Navigate an existing session or open a new page to an HTTP(S) URL.',
1790
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1791
- scopes: ['browser.read'], resourceClaims: readRemote,
1792
- argumentsSchema: sessionTargetSchema({}, ['url']),
1793
- },
1794
- {
1795
- actionId: 'reload',
1796
- title: 'Reload page',
1797
- description: 'Reload the current page for a session.',
1798
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1799
- scopes: ['browser.read'], resourceClaims: readRemote,
1800
- argumentsSchema: sessionTargetSchema({}, ['session_id']),
1801
- },
1802
- {
1803
- actionId: 'go_back',
1804
- title: 'Go back',
1805
- description: 'Navigate back in history for a session.',
1806
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1807
- scopes: ['browser.read'], resourceClaims: readRemote,
1808
- argumentsSchema: sessionTargetSchema({}, ['session_id']),
1809
- },
1810
- {
1811
- actionId: 'wait_for_load_state',
1812
- title: 'Wait for load state',
1813
- description: 'Wait for load/domcontentloaded/networkidle on a session page.',
1814
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1815
- scopes: ['browser.read'], resourceClaims: readRemote,
1816
- argumentsSchema: sessionTargetSchema({ state: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }),
1817
- },
1818
- {
1819
- actionId: 'get_text',
1820
- title: 'Get text',
1821
- description: 'Extract text from a URL or saved browser session.',
1822
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1823
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1824
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' } }),
1825
- },
1826
- {
1827
- actionId: 'get_html',
1828
- title: 'Get HTML',
1829
- description: 'Extract HTML for the page or a selector (bounded).',
1830
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1831
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1832
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' } }),
1833
- },
1834
- {
1835
- actionId: 'query_selector',
1836
- title: 'Query selector',
1837
- description: 'Return the first matching element summary with a stable selector hint.',
1838
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1839
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1840
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' } }, ['selector']),
1841
- },
1842
- {
1843
- actionId: 'query_all',
1844
- title: 'Query all selectors',
1845
- description: 'Return matching element summaries (bounded).',
1846
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1847
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1848
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, limit: { type: 'number' } }, ['selector']),
1849
- },
1850
- {
1851
- actionId: 'get_attribute',
1852
- title: 'Get attribute',
1853
- description: 'Read one attribute from a selector.',
1854
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1855
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1856
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, attribute: { type: 'string' } }, ['selector', 'attribute']),
1857
- },
1858
- {
1859
- actionId: 'screenshot',
1860
- title: 'Screenshot',
1861
- description: 'Capture a page, full-page, or element screenshot under browser artifact storage.',
1862
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1863
- scopes: ['browser.read'], resourceClaims: readRemote,
1864
- argumentsSchema: sessionTargetSchema({ full_page: { type: 'boolean' }, selector: { type: 'string' } }),
1865
- },
1866
- {
1867
- actionId: 'extract_links',
1868
- title: 'Extract links',
1869
- description: 'Extract anchors with href/text from the page.',
1870
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1871
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1872
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
1873
- },
1874
- {
1875
- actionId: 'extract_tables',
1876
- title: 'Extract tables',
1877
- description: 'Extract simple HTML tables as row arrays.',
1878
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1879
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1880
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
1881
- },
1882
- {
1883
- actionId: 'extract_forms',
1884
- title: 'Extract forms',
1885
- description: 'Extract form field summaries without values that look like secrets.',
1886
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1887
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1888
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
1889
- },
1890
- {
1891
- actionId: 'snapshot_interactive',
1892
- title: 'Snapshot interactive elements',
1893
- description: 'Snapshot buttons/inputs/links with stable selector hints.',
1894
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1895
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1896
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
1897
- },
1898
- {
1899
- actionId: 'get_console_errors',
1900
- title: 'Get console errors',
1901
- description: 'Return captured console error messages for a page open cycle.',
1902
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1903
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1904
- argumentsSchema: sessionTargetSchema({}),
1905
- },
1906
- {
1907
- actionId: 'get_failed_requests',
1908
- title: 'Get failed requests',
1909
- description: 'Return failed network requests captured during a page open cycle.',
1910
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1911
- scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1912
- argumentsSchema: sessionTargetSchema({}),
1913
- },
1914
- {
1915
- actionId: 'click',
1916
- title: 'Click element',
1917
- description: 'Click a selector on an allowed page after authorization.',
1918
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1919
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1920
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
1921
- },
1922
- {
1923
- actionId: 'double_click',
1924
- title: 'Double-click element',
1925
- description: 'Double-click a selector after authorization.',
1926
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1927
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1928
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
1929
- },
1930
- {
1931
- actionId: 'hover',
1932
- title: 'Hover element',
1933
- description: 'Hover a selector after authorization.',
1934
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1935
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1936
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
1937
- },
1938
- {
1939
- actionId: 'focus',
1940
- title: 'Focus element',
1941
- description: 'Focus a selector after authorization.',
1942
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1943
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1944
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
1945
- },
1946
- {
1947
- actionId: 'type',
1948
- title: 'Type into element',
1949
- description: 'Type text into a selector (append-style) after authorization.',
1950
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1951
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1952
- argumentsSchema: interactSchema({ selector: { type: 'string' }, text: { type: 'string' } }, ['selector', 'text']),
1953
- },
1954
- {
1955
- actionId: 'fill',
1956
- title: 'Fill element',
1957
- description: 'Replace the value of a selector after authorization.',
1958
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1959
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1960
- argumentsSchema: interactSchema({ selector: { type: 'string' }, text: { type: 'string' } }, ['selector', 'text']),
1961
- },
1962
- {
1963
- actionId: 'select_option',
1964
- title: 'Select option',
1965
- description: 'Select one or more option values on a <select> after authorization.',
1966
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1967
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1968
- argumentsSchema: interactSchema({ selector: { type: 'string' }, values: { type: 'array', items: { type: 'string' } } }, ['selector', 'values']),
1969
- },
1970
- {
1971
- actionId: 'check',
1972
- title: 'Check checkbox',
1973
- description: 'Check a checkbox/radio after authorization.',
1974
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1975
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1976
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
1977
- },
1978
- {
1979
- actionId: 'uncheck',
1980
- title: 'Uncheck checkbox',
1981
- description: 'Uncheck a checkbox after authorization.',
1982
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1983
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1984
- argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
1985
- },
1986
- {
1987
- actionId: 'press',
1988
- title: 'Press key',
1989
- description: 'Press a key on a selector on an allowed page after authorization.',
1990
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1991
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1992
- argumentsSchema: interactSchema({ selector: { type: 'string' }, key: { type: 'string' } }, ['selector', 'key']),
1993
- },
1994
- {
1995
- actionId: 'keyboard_shortcut',
1996
- title: 'Keyboard shortcut',
1997
- description: 'Press a keyboard shortcut (e.g. Meta+A) after authorization.',
1998
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1999
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
2000
- argumentsSchema: interactSchema({ key: { type: 'string' } }, ['key']),
2001
- },
2002
- {
2003
- actionId: 'wait_for_selector',
2004
- title: 'Wait for selector',
2005
- description: 'Wait for a selector state on an allowed page after authorization.',
2006
- readOnly: true, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
2007
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: readRemote,
2008
- argumentsSchema: sessionTargetSchema({
2009
- selector: { type: 'string' },
2010
- state: { type: 'string', enum: ['attached', 'detached', 'visible', 'hidden'] },
2011
- }, ['selector']),
2012
- },
2013
- {
2014
- actionId: 'attach_local_file',
2015
- title: 'Attach local file',
2016
- description: 'Set an input[type=file] path for an allowed local file after authorization. Never auto-opens executables.',
2017
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
2018
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
2019
- argumentsSchema: interactSchema({ selector: { type: 'string' }, file_path: { type: 'string' } }, ['selector', 'file_path']),
2020
- },
2021
- {
2022
- actionId: 'await_file_transfer',
2023
- title: 'Await file transfer',
2024
- description: 'Capture a browser download into bounded artifact storage after authorization. Never auto-opens downloaded files.',
2025
- readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 120_000, cancellable: true, idempotent: false,
2026
- scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
2027
- argumentsSchema: interactSchema({ selector: { type: 'string' }, suggested_name: { type: 'string' } }, ['selector']),
2028
- },
2029
- {
2030
- actionId: 'close_page',
2031
- title: 'Close session',
2032
- description: 'Remove saved session metadata while keeping the persistent profile.',
2033
- readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
2034
- scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
2035
- argumentsSchema: { type: 'object', properties: { session_id: { type: 'string' } }, required: ['session_id'], additionalProperties: false },
2036
- },
2037
- ];
2038
- }
2039
-
2040
3092
  function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, sessionCount = 0): string {
2041
3093
  if (!config.enabled) return 'disabled';
2042
3094
  if (!ready) return 'not ready';
@@ -2045,10 +3097,11 @@ function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, se
2045
3097
  }
2046
3098
 
2047
3099
  function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPluginHealth {
2048
- const dependencyReady = runtimeHooks.moduleAvailable('playwright');
3100
+ const dependencyReady = runtimeHooks.moduleAvailable('playwright', repoRoot);
2049
3101
  const configErrors = validateConfig(config);
2050
3102
  const warnings = configWarnings(config);
2051
- const sessionCount = repoRoot ? listSavedSessions(repoRoot).length : 0;
3103
+ const authority = currentBrowserSessionAuthorityContext();
3104
+ const savedSessionCount = repoRoot && authority ? listSavedBrowserSessions(repoRoot).length : 0;
2052
3105
  const activeHandoffCount = repoRoot
2053
3106
  ? listBrowserHandoffs(repoRoot).filter((entry) => ['starting', 'waiting_for_user', 'closing'].includes(entry.status)).length
2054
3107
  : 0;
@@ -2067,7 +3120,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2067
3120
  nativeBrowserCandidates: config.nativeBrowserCandidates,
2068
3121
  nativeAttachSupported: macOsActiveBrowserAttachSupported(),
2069
3122
  windowMode: 'visible' as const,
2070
- sessionCount,
3123
+ sessionCount: savedSessionCount,
3124
+ sessionCountSemantics: authority ? 'controller_authority' as const : 'controller_authority_unavailable' as const,
3125
+ savedSessionCount,
2071
3126
  activeHandoffCount,
2072
3127
  humanHandoffSupported: true,
2073
3128
  artifactsAvailable: true,
@@ -2098,14 +3153,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2098
3153
  const managedRouteReady = dependencyReady
2099
3154
  && (config.browserMode !== 'attach_preferred' || config.cdpAttachFallback === 'managed_persistent');
2100
3155
  const nativeRouteReady = nativeAttachConfigured && nativeObservation?.ready === true;
2101
- const nativeRouteUnverified = nativeAttachConfigured
2102
- && cdpEndpoints(config).length === 0
2103
- && config.cdpAttachFallback === 'fail_closed'
2104
- && nativeObservation === undefined;
2105
- const nativeRouteFailed = nativeAttachConfigured
2106
- && cdpEndpoints(config).length === 0
2107
- && config.cdpAttachFallback === 'fail_closed'
2108
- && nativeObservation?.ready === false;
3156
+ const nativeOnlyRoute = nativeAttachConfigured && !cdpRouteReady && !managedRouteReady;
3157
+ const nativeRouteUnverified = nativeOnlyRoute && nativeObservation === undefined;
3158
+ const nativeRouteFailed = nativeOnlyRoute && nativeObservation?.ready === false;
2109
3159
  if (nativeRouteUnverified || nativeRouteFailed) {
2110
3160
  const nativeWarnings = nativeRouteFailed
2111
3161
  ? nativeObservation?.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`) ?? []
@@ -2120,7 +3170,7 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2120
3170
  details: {
2121
3171
  ...baseDetails,
2122
3172
  nativeAttachObservation: nativeObservation,
2123
- provider: 'macos-active-browser-fail-closed',
3173
+ provider: 'macos-active-browser',
2124
3174
  userFacingStatus: 'not ready',
2125
3175
  },
2126
3176
  };
@@ -2147,7 +3197,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2147
3197
  details: {
2148
3198
  ...baseDetails,
2149
3199
  provider: config.browserMode === 'attach_preferred'
2150
- ? 'cdp-or-macos-active-browser-or-persistent-context'
3200
+ ? config.cdpAttachFallback === 'managed_persistent'
3201
+ ? 'cdp-or-macos-active-browser-or-persistent-context'
3202
+ : 'cdp-or-macos-active-browser'
2151
3203
  : 'playwright-persistent-context',
2152
3204
  userFacingStatus: 'not ready',
2153
3205
  },
@@ -2166,7 +3218,7 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2166
3218
  provider: config.browserMode === 'attach_preferred'
2167
3219
  ? 'cdp-or-macos-active-browser-or-persistent-context'
2168
3220
  : 'playwright-persistent-context',
2169
- userFacingStatus: browserUserFacingStatus(config, true, sessionCount),
3221
+ userFacingStatus: browserUserFacingStatus(config, true, savedSessionCount),
2170
3222
  },
2171
3223
  };
2172
3224
  }
@@ -2186,7 +3238,7 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
2186
3238
  authority: {
2187
3239
  strategy: 'derived',
2188
3240
  duplicateStateAllowed: false,
2189
- sourceOfTruth: ['repo-local:.forge/plugins/browser.json', 'repo-local:.forge/browser/'],
3241
+ sourceOfTruth: ['repo-local:.forge/plugins/browser.json', 'controller-home:sqlite/browser_session', 'repo-local:.forge/browser/{screenshots,downloads,diagnostics}'],
2190
3242
  },
2191
3243
  enabled: config.enabled,
2192
3244
  lifecycle: {
@@ -2194,21 +3246,42 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
2194
3246
  reason: !config.enabled
2195
3247
  ? 'Browser plugin is disabled.'
2196
3248
  : state.ready
2197
- ? `Browser plugin is ready via ${config.browserMode === 'attach_preferred' ? 'CDP, macOS active-browser attach, and explicit managed fallback.' : 'Playwright persistent context.'}`
3249
+ ? `Browser plugin is ready via ${config.browserMode === 'attach_preferred'
3250
+ ? config.cdpAttachFallback === 'managed_persistent'
3251
+ ? 'CDP, macOS native attach, and explicit managed fallback.'
3252
+ : 'CDP or macOS native attach; managed fallback is disabled.'
3253
+ : 'Playwright persistent context.'}`
2198
3254
  : state.state === 'degraded'
2199
3255
  ? state.warnings[0] ?? 'Browser plugin requires a successful live native attach probe.'
2200
3256
  : state.errors[0],
2201
3257
  },
2202
3258
  health: state,
2203
- permissions: permissions(state.ready),
2204
- capabilities: capabilities(),
2205
- actions: actions(),
3259
+ permissions: browserPermissions(state.ready),
3260
+ capabilities: browserCapabilities(),
3261
+ actions: browserActions(),
2206
3262
  updatedAt: previousUpdatedAt ?? now(),
2207
3263
  };
2208
3264
  }
2209
3265
 
2210
- export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
2211
- const current = loadConfig(input.repoRoot);
3266
+ const NODE_BRIDGE_PRE_ACTION_FAILURE_CODES = new Set([
3267
+ 'PLUGIN_BROWSER_NODE_UNAVAILABLE',
3268
+ 'PLUGIN_BROWSER_NODE_HOST_UNAVAILABLE',
3269
+ 'PLUGIN_BROWSER_NODE_REQUEST_TOO_LARGE',
3270
+ 'PLUGIN_BROWSER_NODE_START_FAILED',
3271
+ ]);
3272
+
3273
+ function browserRuntimeKey(input: AssistantPluginActionExecutionInput): string {
3274
+ return `${input.controllerHome}:${input.repoId}`;
3275
+ }
3276
+
3277
+ async function executeBrowserPluginActionInternal(
3278
+ input: AssistantPluginActionExecutionInput,
3279
+ runtimeAlreadyRouted = false,
3280
+ ): Promise<Record<string, unknown>> {
3281
+ const persisted = loadConfig(input.repoRoot);
3282
+ const actionSessionId = input.actionId === 'configure' ? undefined : stringValue(input.args.session_id);
3283
+ const actionSession = actionSessionId ? findBrowserSession(input.repoRoot, actionSessionId) : undefined;
3284
+ const current = input.actionId === 'configure' ? persisted : effectiveBrowserActionConfig(persisted, input.args, actionSession);
2212
3285
  if (!current.enabled && input.actionId !== 'configure') {
2213
3286
  throw new AssistantPluginError('PLUGIN_DISABLED', 'Browser plugin is disabled.', { retryable: false });
2214
3287
  }
@@ -2218,8 +3291,54 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2218
3291
  throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
2219
3292
  }
2220
3293
  }
2221
- if (shouldUseBrowserNodeBridge(input.actionId, current.browserMode, runtimeHooksCustomized, cdpEndpoints(current).length > 0)) {
2222
- return await executeBrowserActionThroughNode(input);
3294
+ if (!runtimeAlreadyRouted) {
3295
+ const runtimeKey = browserRuntimeKey(input);
3296
+ const nodeBridgeEligible = shouldUseBrowserNodeBridge(
3297
+ input.actionId,
3298
+ current.browserMode,
3299
+ runtimeHooksCustomized,
3300
+ cdpEndpoints(current).length > 0,
3301
+ );
3302
+ const result = await executeBrowserRuntimeAction({
3303
+ runtimeKey,
3304
+ input,
3305
+ providers: [
3306
+ ...(nodeBridgeEligible ? [{
3307
+ providerId: 'browser.node_bridge',
3308
+ capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES.filter((capability) => capability !== 'browser.persistent_handle'),
3309
+ foreground: 'none' as const,
3310
+ verifiesPostconditions: true,
3311
+ persistentHandle: false,
3312
+ priority: 10,
3313
+ supportsInput: () => true,
3314
+ execute: async (routedInput: AssistantPluginActionExecutionInput) => {
3315
+ try {
3316
+ return await executeBrowserActionThroughNode(routedInput);
3317
+ } catch (error) {
3318
+ if (error instanceof AssistantPluginError && NODE_BRIDGE_PRE_ACTION_FAILURE_CODES.has(error.code)) {
3319
+ throw new BrowserProviderUnavailableBeforeActionError('browser.node_bridge', error.code);
3320
+ }
3321
+ throw error;
3322
+ }
3323
+ },
3324
+ }] : []),
3325
+ {
3326
+ providerId: 'browser.local',
3327
+ capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES,
3328
+ foreground: 'none' as const,
3329
+ verifiesPostconditions: true,
3330
+ persistentHandle: true,
3331
+ priority: 20,
3332
+ supportsInput: () => true,
3333
+ execute: async (routedInput: AssistantPluginActionExecutionInput) => executeBrowserPluginActionInternal(routedInput, true),
3334
+ },
3335
+ ],
3336
+ });
3337
+ if (input.actionId === 'configure') {
3338
+ invalidateBrowserRuntime(runtimeKey, 'configuration_changed');
3339
+ invalidateMacOsBrowserPageHandles();
3340
+ }
3341
+ return result;
2223
3342
  }
2224
3343
  try {
2225
3344
  switch (input.actionId) {
@@ -2265,56 +3384,155 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2265
3384
  if (configErrors.length > 0) {
2266
3385
  throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
2267
3386
  }
3387
+ await closeManagedContextsForRepo(input.repoRoot);
2268
3388
  return { config, health: health(config, input.repoRoot) };
2269
3389
  }
2270
- case 'list_sessions':
3390
+ case 'list_sessions': {
3391
+ const inventory = await inspectSavedSessions(input.repoRoot, current);
3392
+ const requestedLimit = Math.max(1, Math.min(Math.trunc(positiveNumber(input.args.limit, DEFAULT_BROWSER_SESSION_LIST_LIMIT)), MAX_BROWSER_SESSION_LIST_LIMIT));
3393
+ let offset = 0;
3394
+ const cursor = stringValue(input.args.cursor);
3395
+ if (cursor) {
3396
+ try {
3397
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { offset?: unknown };
3398
+ if (!Number.isInteger(parsed.offset) || Number(parsed.offset) < 0) throw new Error('invalid offset');
3399
+ offset = Number(parsed.offset);
3400
+ } catch {
3401
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_CURSOR_INVALID', 'Browser session cursor is invalid or expired.', { retryable: false });
3402
+ }
3403
+ }
3404
+ const page = inventory.sessions.slice(offset, offset + requestedLimit);
3405
+ const nextOffset = offset + page.length;
2271
3406
  return {
2272
3407
  provider: 'playwright',
2273
- sessions: listSavedSessions(input.repoRoot).map((session) => ({
3408
+ sessionCountSemantics: currentBrowserSessionAuthorityContext() ? 'controller_authority' : 'saved_metadata',
3409
+ scannedSessionCount: inventory.scannedSessionCount,
3410
+ savedSessionCount: inventory.savedSessionCount,
3411
+ liveManagedSessionCount: inventory.liveManagedSessionCount,
3412
+ liveNativeSessionCount: inventory.liveNativeSessionCount,
3413
+ unverifiedSessionCount: inventory.unverifiedSessionCount,
3414
+ deadManagedSessionCount: inventory.deadManagedSessionCount,
3415
+ deadNativeSessionCount: inventory.deadNativeSessionCount,
3416
+ limit: requestedLimit,
3417
+ totalCount: inventory.sessions.length,
3418
+ ...(nextOffset < inventory.sessions.length ? { nextCursor: Buffer.from(JSON.stringify({ offset: nextOffset }), 'utf8').toString('base64url') } : {}),
3419
+ sessions: page.map(({ session, liveness, evidence, cleanupError }) => ({
2274
3420
  sessionId: session.sessionId,
2275
3421
  url: session.url,
2276
3422
  title: session.title,
2277
3423
  createdAt: session.createdAt,
2278
3424
  updatedAt: session.updatedAt,
2279
3425
  browser: session.browser,
3426
+ liveness,
3427
+ livenessEvidence: evidence,
3428
+ ...(cleanupError ? { cleanupError } : {}),
2280
3429
  })),
2281
3430
  };
3431
+ }
3432
+ case 'reconcile_sessions': {
3433
+ assertBrowserSessionAvailable(input.repoRoot);
3434
+ const inventory = await inspectSavedSessions(input.repoRoot, current, { pruneDead: true });
3435
+ return {
3436
+ reconciled: true,
3437
+ sessionCountSemantics: 'saved_metadata',
3438
+ scannedSessionCount: inventory.scannedSessionCount,
3439
+ savedSessionCount: inventory.savedSessionCount,
3440
+ liveManagedSessionCount: inventory.liveManagedSessionCount,
3441
+ liveNativeSessionCount: inventory.liveNativeSessionCount,
3442
+ unverifiedSessionCount: inventory.unverifiedSessionCount,
3443
+ deadManagedSessionCount: inventory.deadManagedSessionCount,
3444
+ deadNativeSessionCount: inventory.deadNativeSessionCount,
3445
+ closedInvalidNativeSessionCount: inventory.closedInvalidNativeSessionCount,
3446
+ failedNativeCleanupCount: inventory.failedNativeCleanupCount,
3447
+ prunedSessionCount: inventory.prunedSessionCount,
3448
+ prunedSessionIds: inventory.sessions.filter((item) => item.pruned).map((item) => item.session.sessionId),
3449
+ };
3450
+ }
2282
3451
  case 'close_session':
2283
3452
  case 'close_page': {
2284
3453
  const sessionId = requiredString(input.args.session_id, 'session_id');
2285
3454
  assertBrowserSessionAvailable(input.repoRoot, sessionId);
2286
- const session = findSession(input.repoRoot, sessionId);
3455
+ const session = findBrowserSession(input.repoRoot, sessionId);
2287
3456
  const tab = session?.browser?.tab;
2288
- if (session?.browser?.provider === 'macos-apple-events'
2289
- && session.browser.browserProduct
2290
- && tab?.ownership === 'plugin_owned'
2291
- && typeof tab.windowId === 'string'
2292
- && typeof tab.tabId === 'string') {
2293
- await closeMacOsBrowserOwnedTab(session.browser.browserProduct, { windowId: tab.windowId, tabId: tab.tabId }, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS)
2294
- .catch(() => undefined);
2295
- }
2296
- rmSync(sessionPath(input.repoRoot, sessionId), { force: true });
2297
- return { closed: true, sessionId, resourceClosed: tab?.ownership === 'plugin_owned' };
3457
+ const managedClosed = session ? await closeManagedSessionPage(input.repoRoot, current, session) : false;
3458
+ const nativeClose = session ? await closeTrackedNativeOwnedSession(session, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) : { resourceClosed: false, resourceAlreadyMissing: false };
3459
+ const closedSessionIds = session ? nativeOwnedAliasSessionIds(input.repoRoot, session) : [sessionId];
3460
+ for (const closedSessionId of closedSessionIds) removeBrowserSession(input.repoRoot, closedSessionId);
3461
+ return {
3462
+ closed: true, sessionId,
3463
+ resourceClosed: managedClosed || nativeClose.resourceClosed,
3464
+ ...(closedSessionIds.length > 1 ? { closedSessionIds } : {}),
3465
+ ...(nativeClose.resourceAlreadyMissing ? { resourceAlreadyMissing: true } : {}),
3466
+ ...(tab?.ownership === 'user_owned' ? { preservedUserOwnedTab: true } : {}),
3467
+ };
2298
3468
  }
2299
3469
  case 'clear_session': {
2300
3470
  assertBrowserSessionAvailable(input.repoRoot);
2301
3471
  const sessions = listSavedSessions(input.repoRoot);
3472
+ await closeManagedContextsForRepo(input.repoRoot, { strict: true });
3473
+ const failedSessionIds: string[] = [];
3474
+ const cleanupErrors: Array<{ sessionId: string; error: string }> = [];
3475
+ let removedCount = 0;
3476
+ let resourceClosedCount = 0;
3477
+ let resourceAlreadyMissingCount = 0;
2302
3478
  for (const session of sessions) {
2303
- const tab = session.browser?.tab;
2304
- if (session.browser?.provider === 'macos-apple-events'
2305
- && session.browser.browserProduct
2306
- && tab?.ownership === 'plugin_owned'
2307
- && typeof tab.windowId === 'string'
2308
- && typeof tab.tabId === 'string') {
2309
- await closeMacOsBrowserOwnedTab(session.browser.browserProduct, { windowId: tab.windowId, tabId: tab.tabId }, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS)
2310
- .catch(() => undefined);
3479
+ try {
3480
+ const nativeClose = await closeTrackedNativeOwnedSession(session, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
3481
+ if (nativeClose.resourceClosed) resourceClosedCount += 1;
3482
+ if (nativeClose.resourceAlreadyMissing) resourceAlreadyMissingCount += 1;
3483
+ removeBrowserSession(input.repoRoot, session.sessionId);
3484
+ removedCount += 1;
3485
+ } catch (error) {
3486
+ failedSessionIds.push(session.sessionId);
3487
+ cleanupErrors.push({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) });
2311
3488
  }
2312
- rmSync(sessionPath(input.repoRoot, session.sessionId), { force: true });
2313
3489
  }
2314
- return { cleared: true, count: sessions.length };
3490
+ return {
3491
+ cleared: failedSessionIds.length === 0,
3492
+ count: removedCount,
3493
+ requestedCount: sessions.length,
3494
+ resourceClosedCount,
3495
+ resourceAlreadyMissingCount,
3496
+ failedSessionIds,
3497
+ cleanupErrors,
3498
+ };
2315
3499
  }
2316
3500
  case 'request_human_handoff': {
2317
3501
  const target = resolveActionTarget(input.repoRoot, input.args);
3502
+ const managedSession = target.existingSession;
3503
+ const managedTarget = managedSession ? await runtimeManagedPageForSession(input.repoRoot, current, managedSession) : undefined;
3504
+ if (managedSession && managedTarget) {
3505
+ const result = { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => managedSession.title) };
3506
+ const handoff = startRuntimeManagedBrowserHandoff({
3507
+ repoRoot: input.repoRoot,
3508
+ repoId: input.repoId,
3509
+ requestId: input.requestId,
3510
+ jobId: input.jobId,
3511
+ sessionId: target.sessionId,
3512
+ url: target.url,
3513
+ profileDir: managedTarget.profile.profileDir,
3514
+ selectedProfilePath: managedTarget.profile.selectedProfilePath,
3515
+ profileDirectory: managedTarget.profile.profileDirectory,
3516
+ browserChannel: current.browserChannel,
3517
+ executablePath: current.executablePath ? resolveConfiguredPath(input.repoRoot, current.executablePath) : undefined,
3518
+ defaultTimeoutMs: current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
3519
+ reason: stringValue(input.args.reason) ?? 'manual_review',
3520
+ instructions: stringValue(input.args.instructions),
3521
+ timeoutMs: typeof input.args.handoff_timeout_ms === 'number' ? input.args.handoff_timeout_ms : undefined,
3522
+ }, result);
3523
+ try {
3524
+ if (managedTarget.page.bringToFront) await managedTarget.page.bringToFront();
3525
+ } catch (error) {
3526
+ resolveRuntimeManagedBrowserHandoff(input.repoRoot, handoff.interactionId, 'cancel', result);
3527
+ throw error;
3528
+ }
3529
+ return {
3530
+ provider: 'playwright-runtime-managed-handoff',
3531
+ handoff,
3532
+ session: managedSession,
3533
+ nextAction: 'Complete the manual step, then call resolve_handoff with resolution=resume. Its response contains the updated handoff state; call get_handoff_status only when a later decision needs an observation, not as periodic polling.',
3534
+ };
3535
+ }
2318
3536
  const nativeSession = target.existingSession;
2319
3537
  const nativeTab = nativeSession?.browser?.tab;
2320
3538
  const nativeBrowser = nativeSession?.browser?.provider === 'macos-apple-events'
@@ -2339,7 +3557,6 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2339
3557
  requestId: input.requestId,
2340
3558
  jobId: input.jobId,
2341
3559
  sessionId: target.sessionId,
2342
- sessionPath: sessionPath(input.repoRoot, target.sessionId),
2343
3560
  url: target.url,
2344
3561
  profileDir: profile.profileDir,
2345
3562
  selectedProfilePath,
@@ -2356,14 +3573,29 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2356
3573
  provider: nativeBrowser ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
2357
3574
  handoff,
2358
3575
  session: target.existingSession,
2359
- nextAction: 'Complete the manual step, call resolve_handoff with resolution=resume, then poll get_handoff_status.',
3576
+ nextAction: 'Complete the manual step, then call resolve_handoff with resolution=resume. Its response contains the updated handoff state; call get_handoff_status only when a later decision needs an observation, not as periodic polling.',
2360
3577
  };
2361
3578
  }
2362
3579
  case 'get_handoff_status': {
2363
3580
  const interactionId = requiredString(input.args.interaction_id, 'interaction_id');
2364
3581
  const handoff = getBrowserHandoff(input.repoRoot, interactionId);
3582
+ if (handoff.status === 'completed' && handoff.result?.url) {
3583
+ const session = findBrowserSession(input.repoRoot, handoff.sessionId);
3584
+ if (session) {
3585
+ saveBrowserSession(input.repoRoot, {
3586
+ ...session,
3587
+ url: handoff.result.url,
3588
+ title: handoff.result.title,
3589
+ updatedAt: new Date().toISOString(),
3590
+ });
3591
+ }
3592
+ }
2365
3593
  return {
2366
- provider: handoff.targetId.startsWith('macos-apple-events:') ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
3594
+ provider: isRuntimeManagedBrowserHandoff(handoff)
3595
+ ? 'playwright-runtime-managed-handoff'
3596
+ : handoff.targetId.startsWith('macos-apple-events:')
3597
+ ? 'macos-apple-events-handoff-host'
3598
+ : 'playwright-handoff-host',
2367
3599
  handoff,
2368
3600
  };
2369
3601
  }
@@ -2373,9 +3605,42 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2373
3605
  if (resolution !== 'resume' && resolution !== 'cancel') {
2374
3606
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'resolution must be resume or cancel', { retryable: false });
2375
3607
  }
3608
+ const currentHandoff = getBrowserHandoff(input.repoRoot, interactionId);
3609
+ if (isRuntimeManagedBrowserHandoff(currentHandoff)) {
3610
+ const session = findBrowserSession(input.repoRoot, currentHandoff.sessionId);
3611
+ const managedTarget = session ? await runtimeManagedPageForSession(input.repoRoot, current, session) : undefined;
3612
+ const result = managedTarget
3613
+ ? { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => session?.title) }
3614
+ : currentHandoff.result;
3615
+ if (resolution === 'resume' && session && result?.url) {
3616
+ saveBrowserSession(input.repoRoot, {
3617
+ ...session,
3618
+ url: result.url,
3619
+ title: result.title,
3620
+ updatedAt: new Date().toISOString(),
3621
+ });
3622
+ }
3623
+ const handoff = resolveRuntimeManagedBrowserHandoff(input.repoRoot, interactionId, resolution, result);
3624
+ return {
3625
+ provider: 'playwright-runtime-managed-handoff',
3626
+ resolutionRequested: resolution,
3627
+ handoff,
3628
+ };
3629
+ }
2376
3630
  const handoff = resolution === 'resume'
2377
- ? resumeBrowserHandoff(input.repoRoot, interactionId, input.requestId)
3631
+ ? await resumeBrowserHandoffAndWait(input.repoRoot, interactionId, input.requestId)
2378
3632
  : cancelBrowserHandoff(input.repoRoot, interactionId, input.requestId);
3633
+ if (resolution === 'resume' && handoff.status === 'completed' && handoff.result?.url) {
3634
+ const session = findBrowserSession(input.repoRoot, handoff.sessionId);
3635
+ if (session) {
3636
+ saveBrowserSession(input.repoRoot, {
3637
+ ...session,
3638
+ url: handoff.result.url,
3639
+ title: handoff.result.title,
3640
+ updatedAt: new Date().toISOString(),
3641
+ });
3642
+ }
3643
+ }
2379
3644
  return {
2380
3645
  provider: handoff.targetId.startsWith('macos-apple-events:') ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
2381
3646
  resolutionRequested: resolution,
@@ -2385,17 +3650,152 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2385
3650
  case 'create_session':
2386
3651
  case 'open_page':
2387
3652
  case 'navigate': {
2388
- const url = normalizedUrl(input.args.url);
3653
+ const nativeProduct = stringValue(input.args.native_browser_product);
3654
+ const normalizedNativeProduct: MacOsBrowserProduct | undefined = nativeProduct === 'chrome' || nativeProduct === 'vivaldi' ? nativeProduct : undefined;
3655
+ const nativeWindowId = stringValue(input.args.native_window_id);
3656
+ const nativeTabId = stringValue(input.args.native_tab_id);
3657
+ const nativeActiveTab = input.args.native_active_tab === true;
3658
+ const hasNativeAdoption = Boolean(nativeProduct || nativeWindowId || nativeTabId || nativeActiveTab);
3659
+ const url = hasNativeAdoption && input.actionId === 'create_session'
3660
+ ? normalizedExplicitNativeAdoptionUrl(input.args.url)
3661
+ : normalizedUrl(input.args.url);
2389
3662
  const existingSessionId = stringValue(input.args.session_id);
2390
3663
  const target: BrowserActionTarget = existingSessionId
2391
- ? { sessionId: existingSessionId, url, existingSession: findSession(input.repoRoot, existingSessionId) }
3664
+ ? { sessionId: existingSessionId, url, existingSession: findBrowserSession(input.repoRoot, existingSessionId) }
2392
3665
  : { sessionId: sessionIdFor(url), url };
2393
- return await withPage(input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
2394
- const identity = await readPageIdentity(page, connection);
2395
- const session = sessionFromPage(target, identity.url, identity.title, connection);
2396
- saveSession(input.repoRoot, session);
3666
+ if (hasNativeAdoption) {
3667
+ if (input.actionId !== 'create_session' || (nativeProduct && !normalizedNativeProduct)) {
3668
+ throw new AssistantPluginError(
3669
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
3670
+ 'Explicit native tab adoption is only supported by create_session for Chrome or Vivaldi.',
3671
+ { retryable: false },
3672
+ );
3673
+ }
3674
+ const timeout = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
3675
+ let adoptedProduct: MacOsBrowserProduct;
3676
+ let adoptedRef: MacOsBrowserTabRef;
3677
+ let adoptedSystemFrontmost = false;
3678
+ if (nativeActiveTab) {
3679
+ if (nativeWindowId || nativeTabId) {
3680
+ throw new AssistantPluginError(
3681
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
3682
+ 'native_active_tab cannot be combined with native_window_id or native_tab_id.',
3683
+ { retryable: false },
3684
+ );
3685
+ }
3686
+ const configuredCandidates = (current.nativeBrowserCandidates ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES])
3687
+ .filter((candidate): candidate is MacOsBrowserProduct => candidate === 'chrome' || candidate === 'vivaldi');
3688
+ const candidates: MacOsBrowserProduct[] = normalizedNativeProduct ? [normalizedNativeProduct] : configuredCandidates;
3689
+ const discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
3690
+ const activeMetadata = discovered.attachment?.metadata;
3691
+ const requiresSystemFrontmost = !normalizedNativeProduct;
3692
+ if (!activeMetadata || (requiresSystemFrontmost && (!activeMetadata.frontmost || activeMetadata.active !== true))) {
3693
+ throw new AssistantPluginError(
3694
+ 'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
3695
+ requiresSystemFrontmost
3696
+ ? 'No system-frontmost active native browser tab with a stable native identity is available for adoption.'
3697
+ : 'The explicitly selected native browser does not expose an active tab with a stable native identity.',
3698
+ { retryable: true, details: { browserProduct: nativeProduct, requiresSystemFrontmost, attempts: discovered.attempts } },
3699
+ );
3700
+ }
3701
+ adoptedSystemFrontmost = activeMetadata.frontmost === true && activeMetadata.active === true;
3702
+ adoptedProduct = activeMetadata.product;
3703
+ if (activeMetadata.windowId && activeMetadata.tabId) {
3704
+ adoptedRef = { windowId: activeMetadata.windowId, tabId: activeMetadata.tabId };
3705
+ } else {
3706
+ const inventory = await listMacOsBrowserTabs(adoptedProduct, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
3707
+ const matchingActiveTabs = inventory.tabs.filter((candidate) => candidate.active
3708
+ && comparableUrl(candidate.url) === comparableUrl(activeMetadata.url)
3709
+ && (!activeMetadata.windowId || candidate.windowId === activeMetadata.windowId)
3710
+ && (!activeMetadata.tabId || candidate.tabId === activeMetadata.tabId));
3711
+ if (matchingActiveTabs.length !== 1) {
3712
+ throw new AssistantPluginError(
3713
+ 'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
3714
+ 'The explicitly selected native browser active tab metadata lacks a stable identity and tab inventory did not resolve exactly one matching active tab.',
3715
+ {
3716
+ retryable: true,
3717
+ details: {
3718
+ browserProduct: adoptedProduct,
3719
+ requiresSystemFrontmost,
3720
+ observedUrl: activeMetadata.url,
3721
+ observedTitle: activeMetadata.title,
3722
+ observedWindowId: activeMetadata.windowId,
3723
+ observedTabId: activeMetadata.tabId,
3724
+ matchingActiveTabCount: matchingActiveTabs.length,
3725
+ inventoryTruncated: inventory.truncated,
3726
+ attempts: discovered.attempts,
3727
+ },
3728
+ },
3729
+ );
3730
+ }
3731
+ adoptedRef = { windowId: matchingActiveTabs[0]!.windowId, tabId: matchingActiveTabs[0]!.tabId };
3732
+ }
3733
+ } else {
3734
+ if (!normalizedNativeProduct || !nativeWindowId || !nativeTabId) {
3735
+ throw new AssistantPluginError(
3736
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
3737
+ 'Explicit native tab adoption requires native_active_tab=true or native_browser_product with native_window_id and native_tab_id.',
3738
+ { retryable: false },
3739
+ );
3740
+ }
3741
+ adoptedProduct = normalizedNativeProduct;
3742
+ adoptedRef = { windowId: nativeWindowId, tabId: nativeTabId };
3743
+ }
3744
+ const adopted = await reattachMacOsBrowserOwnedPage(adoptedProduct, adoptedRef, timeout);
3745
+ const metadata = adopted.attachment.metadata;
3746
+ const pageUrl = normalizedExplicitNativeAdoptionUrl(metadata.url);
3747
+ const requestedUrl = normalizedExplicitNativeAdoptionUrl(url);
3748
+ const urlsMatch = explicitNativeAdoptionUrlsMatch(requestedUrl, pageUrl, adoptedProduct);
3749
+ const pageProtocol = new URL(pageUrl).protocol;
3750
+ if (((pageProtocol === 'chrome:' && adoptedProduct !== 'chrome') || (pageProtocol === 'vivaldi:' && adoptedProduct !== 'vivaldi')) && !urlsMatch) {
3751
+ throw new AssistantPluginError(
3752
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
3753
+ 'Explicitly adopted native tab uses an internal URL scheme that does not match the selected browser.',
3754
+ { retryable: false, details: { actualUrl: pageUrl, browserProduct: adoptedProduct, windowId: adoptedRef.windowId, tabId: adoptedRef.tabId } },
3755
+ );
3756
+ }
3757
+ if (!urlsMatch) {
3758
+ throw new AssistantPluginError(
3759
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
3760
+ 'Explicitly adopted native tab URL does not match the requested session URL.',
3761
+ { retryable: false, details: { requestedUrl, actualUrl: pageUrl, browserProduct: adoptedProduct, windowId: adoptedRef.windowId, tabId: adoptedRef.tabId } },
3762
+ );
3763
+ }
3764
+ const title = metadata.title || '';
3765
+ const connection = refreshConnectionTab({
3766
+ requestedMode: 'attach_preferred',
3767
+ mode: 'attach_preferred',
3768
+ provider: 'macos-apple-events',
3769
+ attached: true,
3770
+ browserProduct: adoptedProduct,
3771
+ profile: { selectedProfilePath: `macos:${adoptedProduct}:user-tab` },
3772
+ tabInventory: [{ index: 0, key: tabKey(requestedUrl, title), url: requestedUrl, title }],
3773
+ sessionResume: {
3774
+ sessionId: target.sessionId,
3775
+ status: 'matched',
3776
+ reason: nativeActiveTab && adoptedSystemFrontmost
3777
+ ? 'Explicitly adopted the matching system-frontmost active user-owned native browser tab.'
3778
+ : nativeActiveTab
3779
+ ? 'Explicitly adopted the selected browser active tab; system-frontmost authority was not established.'
3780
+ : 'Explicitly adopted an existing user-owned native browser tab.',
3781
+ },
3782
+ }, requestedUrl, title, 'saved_url', { ownership: 'user_owned', windowId: adoptedRef.windowId, tabId: adoptedRef.tabId });
3783
+ const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, requestedUrl, title, connection));
3784
+ return {
3785
+ provider: 'macos-apple-events',
3786
+ session,
3787
+ navigation: { url: requestedUrl },
3788
+ browserConnection: { ...connection, tab: session.browser?.tab, sessionResume: session.browser?.sessionResume },
3789
+ ...(input.args.extract_text === true
3790
+ ? { text: await extractText(adopted.page as unknown as PageLike, undefined, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)) }
3791
+ : {}),
3792
+ };
3793
+ }
3794
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
3795
+ const identity = await readPageIdentity(page, connection, target.url);
3796
+ const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
2397
3797
  return {
2398
- provider: 'playwright',
3798
+ provider: browserResultProvider(connection.provider),
2399
3799
  session,
2400
3800
  navigation: diagnostics.navigation,
2401
3801
  browserConnection: {
@@ -2407,13 +3807,13 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2407
3807
  ? { text: await extractText(page, undefined, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)) }
2408
3808
  : {}),
2409
3809
  };
2410
- });
3810
+ }, { requireExistingResource: Boolean(existingSessionId) });
2411
3811
  }
2412
3812
  case 'reload':
2413
3813
  case 'go_back':
2414
3814
  case 'wait_for_load_state': {
2415
3815
  const target = resolveActionTarget(input.repoRoot, input.args);
2416
- return await withPage(input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
3816
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
2417
3817
  if (input.actionId === 'reload') {
2418
3818
  if (page.reload) await page.reload({ waitUntil: waitUntil(input.args.wait_until), timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
2419
3819
  else await page.goto(target.url, { waitUntil: waitUntil(input.args.wait_until), timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
@@ -2425,10 +3825,9 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2425
3825
  });
2426
3826
  }
2427
3827
  const identity = await readPageIdentity(page, connection);
2428
- const session = sessionFromPage(target, identity.url, identity.title, connection);
2429
- saveSession(input.repoRoot, session);
3828
+ const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
2430
3829
  return {
2431
- provider: 'playwright',
3830
+ provider: browserResultProvider(connection.provider),
2432
3831
  session,
2433
3832
  navigation: diagnostics.navigation,
2434
3833
  actionId: input.actionId,
@@ -2438,28 +3837,34 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2438
3837
  sessionResume: session.browser?.sessionResume,
2439
3838
  },
2440
3839
  };
2441
- });
3840
+ }, input.actionId === 'reload'
3841
+ ? { requireExistingResource: true, pruneStaleSessionMetadata: true }
3842
+ : {});
2442
3843
  }
2443
3844
  case 'get_text': {
2444
3845
  const target = resolveActionTarget(input.repoRoot, input.args);
2445
- return {
2446
- provider: 'playwright',
2447
- sessionId: target.sessionId,
2448
- url: target.url,
2449
- ...(await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => ({
2450
- ...(await extractText(page, stringValue(input.args.selector), positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS))),
3846
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3847
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
3848
+ return {
3849
+ provider: browserResultProvider(connection.provider),
3850
+ sessionId: target.sessionId,
3851
+ url: target.url,
3852
+ ...(await extractText(selection.scope, stringValue(input.args.selector), positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS))),
3853
+ ...(selection.frame ? { frame: selection.frame } : {}),
2451
3854
  browserConnection: connection,
2452
- }), { persistSession: true })),
2453
- };
3855
+ };
3856
+ }, { persistSession: true });
2454
3857
  }
2455
3858
  case 'get_html': {
2456
3859
  const target = resolveActionTarget(input.repoRoot, input.args);
2457
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2458
- const raw = await page.evaluate<string>(EXTRACTION_SCRIPTS.html(stringValue(input.args.selector)));
3860
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3861
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
3862
+ const raw = await selection.scope.evaluate<string>(EXTRACTION_SCRIPTS.html(stringValue(input.args.selector)));
2459
3863
  return {
2460
- provider: 'playwright',
3864
+ provider: browserResultProvider(connection.provider),
2461
3865
  sessionId: target.sessionId,
2462
3866
  url: target.url,
3867
+ ...(selection.frame ? { frame: selection.frame } : {}),
2463
3868
  browserConnection: connection,
2464
3869
  ...truncateText(raw, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)),
2465
3870
  };
@@ -2470,13 +3875,15 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2470
3875
  const target = resolveActionTarget(input.repoRoot, input.args);
2471
3876
  const selector = requiredString(input.args.selector, 'selector');
2472
3877
  const limit = Math.min(positiveNumber(input.args.limit, 25), 100);
2473
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2474
- const matches = await page.evaluate<Array<Record<string, unknown>>>(EXTRACTION_SCRIPTS.query(selector, input.actionId === 'query_selector' ? 1 : limit));
3878
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3879
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
3880
+ const matches = await selection.scope.evaluate<Array<Record<string, unknown>>>(EXTRACTION_SCRIPTS.query(selector, input.actionId === 'query_selector' ? 1 : limit));
2475
3881
  return {
2476
- provider: 'playwright',
3882
+ provider: browserResultProvider(connection.provider),
2477
3883
  sessionId: target.sessionId,
2478
3884
  url: target.url,
2479
3885
  selector,
3886
+ ...(selection.frame ? { frame: selection.frame } : {}),
2480
3887
  browserConnection: connection,
2481
3888
  ...(input.actionId === 'query_selector'
2482
3889
  ? { match: matches[0] ?? null, found: matches.length > 0 }
@@ -2488,21 +3895,101 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2488
3895
  const target = resolveActionTarget(input.repoRoot, input.args);
2489
3896
  const selector = requiredString(input.args.selector, 'selector');
2490
3897
  const attribute = requiredString(input.args.attribute, 'attribute');
2491
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => ({
2492
- provider: 'playwright',
2493
- sessionId: target.sessionId,
2494
- url: target.url,
2495
- selector,
2496
- attribute,
2497
- browserConnection: connection,
2498
- value: await page.evaluate<string | null>(EXTRACTION_SCRIPTS.attribute(selector, attribute)),
2499
- }), { persistSession: true });
3898
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3899
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
3900
+ return {
3901
+ provider: browserResultProvider(connection.provider),
3902
+ sessionId: target.sessionId,
3903
+ url: target.url,
3904
+ selector,
3905
+ attribute,
3906
+ ...(selection.frame ? { frame: selection.frame } : {}),
3907
+ browserConnection: connection,
3908
+ value: await selection.scope.evaluate<string | null>(EXTRACTION_SCRIPTS.attribute(selector, attribute)),
3909
+ };
3910
+ }, { persistSession: true });
3911
+ }
3912
+ case 'list_frames': {
3913
+ const target = resolveActionTarget(input.repoRoot, input.args);
3914
+ const limit = Math.min(positiveNumber(input.args.limit, 50), 100);
3915
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3916
+ if (connection.provider === 'macos-apple-events' || !page.frames) {
3917
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE', 'Frame discovery requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.', {
3918
+ retryable: false,
3919
+ details: { provider: connection.provider },
3920
+ });
3921
+ }
3922
+ const allFrames = page.frames();
3923
+ const frames = allFrames.slice(0, limit).map((frame) => ({ url: frame.url(), name: frame.name() }));
3924
+ return {
3925
+ provider: browserResultProvider(connection.provider),
3926
+ sessionId: target.sessionId,
3927
+ url: target.url,
3928
+ frames,
3929
+ count: allFrames.length,
3930
+ truncated: allFrames.length > frames.length,
3931
+ browserConnection: connection,
3932
+ };
3933
+ }, { persistSession: true });
3934
+ }
3935
+ case 'verify_state': {
3936
+ requiredString(input.args.session_id, 'session_id');
3937
+ const target = resolveActionTarget(input.repoRoot, input.args);
3938
+ const expectedUrlRaw = stringValue(input.args.expected_url);
3939
+ const expectedUrl = expectedUrlRaw ? normalizedUrl(expectedUrlRaw) : undefined;
3940
+ const urlContains = stringValue(input.args.url_contains);
3941
+ const selector = stringValue(input.args.selector);
3942
+ const requireVisible = input.args.require_visible === true;
3943
+ const textContains = stringValue(input.args.text_contains);
3944
+ if (!expectedUrl && !urlContains && !selector && !textContains) {
3945
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'verify_state requires at least one of expected_url, url_contains, selector, or text_contains.', { retryable: false });
3946
+ }
3947
+ if (requireVisible && !selector) {
3948
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'require_visible requires selector.', { retryable: false });
3949
+ }
3950
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3951
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
3952
+ const observedUrl = selection.frame?.url ?? page.url();
3953
+ const checks: Array<Record<string, unknown>> = [];
3954
+ if (expectedUrl) checks.push({ criterion: 'url_exact', expected: expectedUrl, observed: observedUrl, matched: observedUrl === expectedUrl });
3955
+ if (urlContains) checks.push({ criterion: 'url_contains', expected: urlContains, observed: observedUrl, matched: observedUrl.includes(urlContains) });
3956
+ let observation: { verificationVersion?: unknown; selectorExists?: unknown; visible?: unknown; textContains?: unknown; textSample?: unknown; textLength?: unknown; truncated?: unknown } | undefined;
3957
+ if (selector || textContains) {
3958
+ observation = await selection.scope.evaluate(verifyStateObservationScript(selector, textContains, Math.min(positiveNumber(input.args.max_chars, 2_000), 100_000)));
3959
+ if (observation?.verificationVersion !== 1) {
3960
+ throw new AssistantPluginError('PLUGIN_BROWSER_VERIFY_STATE_UNAVAILABLE', 'Browser state observation returned an unsupported result.', { retryable: true });
3961
+ }
3962
+ }
3963
+ if (selector) {
3964
+ checks.push({ criterion: 'selector_exists', expected: true, observed: observation?.selectorExists === true, matched: observation?.selectorExists === true, selector });
3965
+ if (requireVisible) {
3966
+ checks.push({ criterion: 'selector_visible', expected: true, observed: observation?.visible === true, matched: observation?.visible === true, selector });
3967
+ }
3968
+ }
3969
+ if (textContains) {
3970
+ checks.push({
3971
+ criterion: 'text_contains', expected: textContains, observed: observation?.textSample,
3972
+ matched: observation?.textContains === true, textLength: observation?.textLength, truncated: observation?.truncated,
3973
+ ...(selector ? { selector } : {}),
3974
+ });
3975
+ }
3976
+ return {
3977
+ provider: browserResultProvider(connection.provider),
3978
+ sessionId: target.sessionId,
3979
+ url: target.url,
3980
+ observedUrl,
3981
+ ...(selection.frame ? { frame: selection.frame } : {}),
3982
+ matched: checks.every((check) => check.matched === true),
3983
+ checks,
3984
+ browserConnection: connection,
3985
+ };
3986
+ }, { requireExistingResource: true });
2500
3987
  }
2501
3988
  case 'screenshot': {
2502
3989
  const target = resolveActionTarget(input.repoRoot, input.args);
2503
3990
  const file = screenshotFilePath(input.repoRoot, 'screenshot', target.sessionId, target.url);
2504
3991
  const selector = stringValue(input.args.selector);
2505
- const screenshot = await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
3992
+ const screenshot = await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2506
3993
  let bytes: number;
2507
3994
  if (selector && page.locator) {
2508
3995
  bytes = (await page.locator(selector).screenshot({ path: file })).length;
@@ -2520,7 +4007,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2520
4007
  browserConnection: connection,
2521
4008
  };
2522
4009
  });
2523
- return { provider: 'playwright', screenshot };
4010
+ return { provider: browserResultProvider(screenshot.browserConnection.provider), screenshot };
2524
4011
  }
2525
4012
  case 'extract_links':
2526
4013
  case 'extract_tables':
@@ -2528,7 +4015,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2528
4015
  case 'snapshot_interactive': {
2529
4016
  const target = resolveActionTarget(input.repoRoot, input.args);
2530
4017
  const limit = Math.min(positiveNumber(input.args.limit, 50), 200);
2531
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4018
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2532
4019
  const script = input.actionId === 'extract_links'
2533
4020
  ? EXTRACTION_SCRIPTS.links(limit)
2534
4021
  : input.actionId === 'extract_tables'
@@ -2536,13 +4023,39 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2536
4023
  : input.actionId === 'extract_forms'
2537
4024
  ? EXTRACTION_SCRIPTS.forms(limit)
2538
4025
  : EXTRACTION_SCRIPTS.interactive(limit);
2539
- const data = await page.evaluate<unknown>(script);
4026
+ const selection = input.actionId === 'snapshot_interactive'
4027
+ ? resolveBrowserEvaluationScope(page, input.args, connection)
4028
+ : { scope: page } as BrowserFrameSelection;
4029
+ const evaluated = await selection.scope.evaluate<unknown>(script);
4030
+ const grounded = input.actionId === 'snapshot_interactive' && evaluated && typeof evaluated === 'object' && !Array.isArray(evaluated)
4031
+ ? evaluated as { geometryVersion?: unknown; viewport?: unknown; elements?: unknown }
4032
+ : undefined;
4033
+ let data = grounded && Array.isArray(grounded.elements) ? grounded.elements : evaluated;
4034
+ let viewport = grounded?.viewport;
4035
+ let frameViewport: unknown;
4036
+ let frameOffset: { x: number; y: number; width: number; height: number } | undefined;
4037
+ if (grounded?.geometryVersion === 1 && selection.frame && Array.isArray(grounded.elements)) {
4038
+ frameOffset = await resolveFrameViewportOffset(page, selection);
4039
+ if (!frameOffset) {
4040
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Frame geometry offset was not available.', { retryable: true });
4041
+ }
4042
+ const topMetrics = await page.evaluate<{ viewportMetricsVersion?: unknown; viewport?: unknown }>(VIEWPORT_METRICS_SCRIPT);
4043
+ if (topMetrics?.viewportMetricsVersion !== 1 || !topMetrics.viewport) {
4044
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Top-level viewport metrics were not available for frame grounding.', { retryable: true });
4045
+ }
4046
+ frameViewport = grounded.viewport;
4047
+ viewport = topMetrics.viewport;
4048
+ data = translateFrameGroundingElements(grounded.elements, frameOffset, topMetrics.viewport);
4049
+ }
2540
4050
  return {
2541
- provider: 'playwright',
4051
+ provider: browserResultProvider(connection.provider),
2542
4052
  sessionId: target.sessionId,
2543
4053
  url: target.url,
2544
4054
  actionId: input.actionId,
2545
4055
  data,
4056
+ ...(selection.frame ? { frame: selection.frame } : {}),
4057
+ ...(grounded?.geometryVersion === 1 ? { geometryVersion: 1, viewport } : {}),
4058
+ ...(selection.frame && frameOffset ? { coordinateSpace: 'main_viewport_css', frameViewport, frameOffset } : {}),
2546
4059
  browserConnection: connection,
2547
4060
  };
2548
4061
  }, { persistSession: true });
@@ -2550,16 +4063,83 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2550
4063
  case 'get_console_errors':
2551
4064
  case 'get_failed_requests': {
2552
4065
  const target = resolveActionTarget(input.repoRoot, input.args);
2553
- return await withPage(input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => ({
2554
- provider: 'playwright',
2555
- sessionId: target.sessionId,
2556
- url: target.url,
2557
- navigation: diagnostics.navigation,
2558
- browserConnection: connection,
2559
- ...(input.actionId === 'get_console_errors'
2560
- ? { consoleErrors: diagnostics.consoleErrors.slice(0, 50) }
2561
- : { failedRequests: diagnostics.failedRequests.slice(0, 50) }),
2562
- }), { persistSession: true });
4066
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => {
4067
+ if (connection.provider === 'macos-apple-events') {
4068
+ throw new AssistantPluginError(
4069
+ 'PLUGIN_BROWSER_DIAGNOSTICS_UNAVAILABLE',
4070
+ 'Console and failed-request diagnostics require a Playwright/CDP-controlled page; native Apple Events sessions do not expose browser diagnostic events.',
4071
+ { retryable: false, details: { provider: connection.provider, actionId: input.actionId, sessionId: target.sessionId } },
4072
+ );
4073
+ }
4074
+ return {
4075
+ provider: browserResultProvider(connection.provider),
4076
+ sessionId: target.sessionId,
4077
+ url: target.url,
4078
+ navigation: diagnostics.navigation,
4079
+ browserConnection: connection,
4080
+ ...(input.actionId === 'get_console_errors'
4081
+ ? { consoleErrors: diagnostics.consoleErrors.slice(0, 50) }
4082
+ : { failedRequests: diagnostics.failedRequests.slice(0, 50) }),
4083
+ };
4084
+ }, { persistSession: true });
4085
+ }
4086
+ case 'activate_page': {
4087
+ const target = resolveActionTarget(input.repoRoot, input.args);
4088
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4089
+ const waitMs = positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS);
4090
+ if (connection.provider === 'macos-apple-events') {
4091
+ await establishAuthoritativeNativeForeground(input, page, connection, target, waitMs);
4092
+ } else {
4093
+ if (!page.bringToFront) {
4094
+ throw new AssistantPluginError('PLUGIN_BROWSER_ACTIVATION_UNSUPPORTED', 'The selected browser provider cannot bring the saved page to the foreground.', { retryable: false });
4095
+ }
4096
+ await page.bringToFront();
4097
+ await delay(waitMs);
4098
+ }
4099
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'activate_page', 'Activated the exact saved browser page in authoritative foreground state.', {});
4100
+ });
4101
+ }
4102
+ case 'click_text': {
4103
+ const target = resolveActionTarget(input.repoRoot, input.args);
4104
+ const text = requiredString(input.args.text, 'text');
4105
+ if (text.length > 200) {
4106
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text must be 200 characters or fewer.', { retryable: false });
4107
+ }
4108
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4109
+ const clicked = await page.evaluate((payload) => {
4110
+ const args = payload as { text: string };
4111
+ const normalize = (value: string) => value.replace(/\s+/g, ' ').trim();
4112
+ const roots: Array<Document | ShadowRoot> = [document];
4113
+ const candidates: HTMLElement[] = [];
4114
+ for (let index = 0; index < roots.length; index += 1) {
4115
+ const root = roots[index];
4116
+ candidates.push(...Array.from(root.querySelectorAll<HTMLElement>('button,[role=button],a,div,span')));
4117
+ for (const host of Array.from(root.querySelectorAll<HTMLElement>('*'))) {
4118
+ if (host.shadowRoot) roots.push(host.shadowRoot);
4119
+ }
4120
+ }
4121
+ const matches = candidates
4122
+ .filter((element) => normalize(element.innerText || element.textContent || '') === args.text)
4123
+ .filter((element) => {
4124
+ const style = getComputedStyle(element);
4125
+ const rect = element.getBoundingClientRect();
4126
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
4127
+ })
4128
+ .sort((left, right) => {
4129
+ const childDelta = left.children.length - right.children.length;
4130
+ if (childDelta !== 0) return childDelta;
4131
+ const leftRect = left.getBoundingClientRect();
4132
+ const rightRect = right.getBoundingClientRect();
4133
+ return (leftRect.width * leftRect.height) - (rightRect.width * rightRect.height);
4134
+ });
4135
+ const element = matches[0];
4136
+ if (!element) throw new Error(`visible exact text not found: ${args.text}`);
4137
+ element.click();
4138
+ return { tag: element.tagName.toLowerCase(), className: element.className || '', text: normalize(element.innerText || element.textContent || '') };
4139
+ }, { text });
4140
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
4141
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'click_text', `Clicked exact visible text ${text}.`, { text, clicked });
4142
+ });
2563
4143
  }
2564
4144
  case 'click':
2565
4145
  case 'double_click':
@@ -2571,8 +4151,23 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2571
4151
  const selector = requiredString(input.args.selector, 'selector');
2572
4152
  const timeoutMs = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
2573
4153
  try {
2574
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2575
- if (input.actionId === 'click') await page.click(selector, { timeout: timeoutMs });
4154
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4155
+ if (input.actionId === 'click' && openShadowSelectorParts(selector).length > 1) {
4156
+ const parts = openShadowSelectorParts(selector);
4157
+ await page.evaluate((payload) => {
4158
+ const args = payload as { parts: string[] };
4159
+ let root: Document | ShadowRoot = document;
4160
+ for (let index = 0; index < args.parts.length - 1; index += 1) {
4161
+ const host: Element | null = root.querySelector(args.parts[index]);
4162
+ if (!host || !host.shadowRoot) throw new Error(`Open shadow root not found for selector segment: ${args.parts[index]}`);
4163
+ root = host.shadowRoot;
4164
+ }
4165
+ const element = root.querySelector(args.parts[args.parts.length - 1]) as HTMLElement | null;
4166
+ if (!element || typeof element.click !== 'function') throw new Error('Shadow selector did not resolve to a clickable element.');
4167
+ element.click();
4168
+ return true;
4169
+ }, { parts });
4170
+ } else if (input.actionId === 'click') await page.click(selector, { timeout: timeoutMs });
2576
4171
  else if (input.actionId === 'double_click') {
2577
4172
  if (page.dblclick) await page.dblclick(selector, { timeout: timeoutMs });
2578
4173
  else await page.click(selector, { timeout: timeoutMs, clickCount: 2 } as Record<string, unknown>);
@@ -2615,7 +4210,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2615
4210
  const selector = requiredString(input.args.selector, 'selector');
2616
4211
  const text = requiredString(input.args.text, 'text');
2617
4212
  const timeoutMs = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
2618
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4213
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2619
4214
  if (input.actionId === 'fill' || !page.type) await page.fill(selector, text, { timeout: timeoutMs });
2620
4215
  else await page.type(selector, text, { timeout: timeoutMs });
2621
4216
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
@@ -2630,17 +4225,23 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2630
4225
  const selector = requiredString(input.args.selector, 'selector');
2631
4226
  const values = Array.isArray(input.args.values) ? input.args.values.map(String) : [];
2632
4227
  if (values.length === 0) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'values is required.', { retryable: false });
2633
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2634
- if (page.selectOption) await page.selectOption(selector, values, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
4228
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4229
+ if (!page.selectOption) {
4230
+ throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_UNAVAILABLE', 'The active browser provider cannot select options safely.', { retryable: false });
4231
+ }
4232
+ const selectedValues = await page.selectOption(selector, values, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
4233
+ if (!Array.isArray(selectedValues) || selectedValues.length === 0) {
4234
+ throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_FAILED', 'The browser provider did not retain any requested option selection.', { retryable: true, details: { selector, requestedCount: values.length } });
4235
+ }
2635
4236
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2636
- return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'select_option', `Selected ${values.length} option(s) on ${selector}.`, { selector, values });
4237
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'select_option', `Selected ${values.length} option(s) on ${selector}.`, { selector, values, selectedValues });
2637
4238
  });
2638
4239
  }
2639
4240
  case 'press': {
2640
4241
  const target = resolveActionTarget(input.repoRoot, input.args);
2641
4242
  const selector = requiredString(input.args.selector, 'selector');
2642
4243
  const key = requiredString(input.args.key, 'key');
2643
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4244
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2644
4245
  await page.press(selector, key, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
2645
4246
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2646
4247
  return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'press', `Pressed ${key} on ${selector}.`, {
@@ -2652,18 +4253,184 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2652
4253
  case 'keyboard_shortcut': {
2653
4254
  const target = resolveActionTarget(input.repoRoot, input.args);
2654
4255
  const key = requiredString(input.args.key, 'key');
2655
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4256
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2656
4257
  if (page.keyboard?.press) await page.keyboard.press(key);
2657
4258
  else await page.press('body', key, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
2658
4259
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2659
4260
  return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'keyboard_shortcut', `Pressed shortcut ${key}.`, { key });
2660
4261
  });
2661
4262
  }
4263
+ case 'trusted_input': {
4264
+ const target = resolveActionTarget(input.repoRoot, input.args);
4265
+ const kind = requiredString(input.args.kind, 'kind');
4266
+ if (!['click', 'move', 'wheel', 'drag', 'key', 'text'].includes(kind)) {
4267
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'kind must be click, move, wheel, drag, key, or text.', { retryable: false });
4268
+ }
4269
+ const guardSelector = stringValue(input.args.guard_selector);
4270
+ const hasFrameScope = Boolean(stringValue(input.args.frame_url) || stringValue(input.args.frame_name));
4271
+ if (hasFrameScope && !guardSelector) {
4272
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'trusted_input frame scope requires guard_selector; trusted input coordinates are always main-viewport CSS pixels.', { retryable: false });
4273
+ }
4274
+ if (guardSelector && kind !== 'click' && kind !== 'drag') {
4275
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'guard_selector is supported only for trusted_input click or drag.', { retryable: false });
4276
+ }
4277
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4278
+ const nativeTrustedInput = connection.provider === 'macos-apple-events';
4279
+ if (nativeTrustedInput && !page.trustedInput) {
4280
+ throw new AssistantPluginError(
4281
+ 'PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE',
4282
+ 'Native browser provider does not expose the stable macOS trusted-input broker.',
4283
+ { retryable: false, details: { provider: connection.provider, sessionId: target.sessionId } },
4284
+ );
4285
+ }
4286
+ if (!nativeTrustedInput && (!page.mouse || !page.keyboard)) {
4287
+ throw new AssistantPluginError(
4288
+ 'PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE',
4289
+ 'Trusted browser input requires Playwright mouse/keyboard primitives or the stable native macOS trusted-input broker.',
4290
+ { retryable: false, details: { provider: connection.provider, sessionId: target.sessionId } },
4291
+ );
4292
+ }
4293
+ const button = input.args.button === 'middle' || input.args.button === 'right' ? input.args.button : 'left';
4294
+ let guard: Record<string, unknown> | undefined;
4295
+ if (guardSelector) {
4296
+ const point = kind === 'click'
4297
+ ? { x: requiredFiniteNumber(input.args.x, 'x', 0), y: requiredFiniteNumber(input.args.y, 'y', 0) }
4298
+ : { x: requiredFiniteNumber(input.args.from_x, 'from_x', 0), y: requiredFiniteNumber(input.args.from_y, 'from_y', 0) };
4299
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4300
+ const observed = await selection.scope.evaluate<{
4301
+ trustedInputGuardVersion?: unknown; found?: unknown; visible?: unknown; selectorError?: unknown;
4302
+ bounds?: { x?: unknown; y?: unknown; width?: unknown; height?: unknown; right?: unknown; bottom?: unknown };
4303
+ }>(trustedInputGuardScript(guardSelector));
4304
+ if (observed?.trustedInputGuardVersion !== 1 || observed.found !== true || observed.visible !== true || !observed.bounds) {
4305
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input guard selector is missing or not visibly actionable.', {
4306
+ retryable: true,
4307
+ details: { selector: guardSelector, frame: selection.frame, found: observed?.found, visible: observed?.visible, selectorError: observed?.selectorError },
4308
+ });
4309
+ }
4310
+ const finite = (value: unknown, field: string): number => {
4311
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
4312
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', `Trusted input guard ${field} is not finite.`, { retryable: true });
4313
+ }
4314
+ return value;
4315
+ };
4316
+ let bounds = {
4317
+ x: finite(observed.bounds.x, 'bounds.x'), y: finite(observed.bounds.y, 'bounds.y'),
4318
+ width: finite(observed.bounds.width, 'bounds.width'), height: finite(observed.bounds.height, 'bounds.height'),
4319
+ right: finite(observed.bounds.right, 'bounds.right'), bottom: finite(observed.bounds.bottom, 'bounds.bottom'),
4320
+ };
4321
+ if (selection.frame) {
4322
+ const offset = await resolveFrameViewportOffset(page, selection);
4323
+ if (!offset) throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input frame offset is unavailable.', { retryable: true });
4324
+ bounds = {
4325
+ ...bounds,
4326
+ x: bounds.x + offset.x,
4327
+ y: bounds.y + offset.y,
4328
+ right: bounds.right + offset.x,
4329
+ bottom: bounds.bottom + offset.y,
4330
+ };
4331
+ }
4332
+ const metrics = await page.evaluate<{ viewportMetricsVersion?: unknown; viewport?: { width?: unknown; height?: unknown } }>(VIEWPORT_METRICS_SCRIPT);
4333
+ const viewportWidth = metrics?.viewportMetricsVersion === 1 && typeof metrics.viewport?.width === 'number' && Number.isFinite(metrics.viewport.width) ? metrics.viewport.width : undefined;
4334
+ const viewportHeight = metrics?.viewportMetricsVersion === 1 && typeof metrics.viewport?.height === 'number' && Number.isFinite(metrics.viewport.height) ? metrics.viewport.height : undefined;
4335
+ if (viewportWidth === undefined || viewportHeight === undefined) {
4336
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input guard could not verify the top-level viewport.', { retryable: true });
4337
+ }
4338
+ const targetInViewport = bounds.right > 0 && bounds.bottom > 0 && bounds.x < viewportWidth && bounds.y < viewportHeight;
4339
+ const pointInViewport = point.x >= 0 && point.y >= 0 && point.x < viewportWidth && point.y < viewportHeight;
4340
+ const pointInsideTarget = point.x >= bounds.x && point.x <= bounds.right && point.y >= bounds.y && point.y <= bounds.bottom;
4341
+ if (!targetInViewport || !pointInViewport || !pointInsideTarget) {
4342
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input coordinates no longer match the guarded selector geometry.', {
4343
+ retryable: true,
4344
+ details: { selector: guardSelector, frame: selection.frame, point, bounds, targetInViewport, pointInViewport, pointInsideTarget },
4345
+ });
4346
+ }
4347
+ guard = { selector: guardSelector, ...(selection.frame ? { frame: selection.frame } : {}), point, bounds, targetInViewport: true, pointInViewport: true, pointInsideTarget: true };
4348
+ }
4349
+ let trustedRequest: Record<string, unknown>;
4350
+ if (kind === 'click') {
4351
+ const x = requiredFiniteNumber(input.args.x, 'x', 0);
4352
+ const y = requiredFiniteNumber(input.args.y, 'y', 0);
4353
+ const clickCount = Math.min(Math.max(positiveNumber(input.args.click_count, 1), 1), 3);
4354
+ trustedRequest = { kind, x, y, button, clickCount };
4355
+ if (!nativeTrustedInput) await page.mouse!.click(x, y, { button, clickCount });
4356
+ } else if (kind === 'move') {
4357
+ const x = requiredFiniteNumber(input.args.x, 'x', 0);
4358
+ const y = requiredFiniteNumber(input.args.y, 'y', 0);
4359
+ const steps = Math.min(Math.max(positiveNumber(input.args.steps, 1), 1), 100);
4360
+ trustedRequest = { kind, x, y, steps };
4361
+ if (!nativeTrustedInput) await page.mouse!.move(x, y, { steps });
4362
+ } else if (kind === 'wheel') {
4363
+ const deltaX = requiredFiniteNumber(input.args.delta_x, 'delta_x');
4364
+ const deltaY = requiredFiniteNumber(input.args.delta_y, 'delta_y');
4365
+ trustedRequest = { kind, deltaX, deltaY };
4366
+ if (!nativeTrustedInput) await page.mouse!.wheel(deltaX, deltaY);
4367
+ } else if (kind === 'drag') {
4368
+ const fromX = requiredFiniteNumber(input.args.from_x, 'from_x', 0);
4369
+ const fromY = requiredFiniteNumber(input.args.from_y, 'from_y', 0);
4370
+ const toX = requiredFiniteNumber(input.args.to_x, 'to_x', 0);
4371
+ const toY = requiredFiniteNumber(input.args.to_y, 'to_y', 0);
4372
+ const steps = Math.min(Math.max(positiveNumber(input.args.steps, 10), 1), 100);
4373
+ trustedRequest = { kind, fromX, fromY, toX, toY, button, steps };
4374
+ if (!nativeTrustedInput) {
4375
+ await page.mouse!.move(fromX, fromY);
4376
+ await page.mouse!.down({ button });
4377
+ try {
4378
+ await page.mouse!.move(toX, toY, { steps });
4379
+ } finally {
4380
+ await page.mouse!.up({ button });
4381
+ }
4382
+ }
4383
+ } else if (kind === 'key') {
4384
+ const key = requiredString(input.args.key, 'key');
4385
+ trustedRequest = { kind, key };
4386
+ if (!nativeTrustedInput) await page.keyboard!.press(key);
4387
+ } else {
4388
+ const text = typeof input.args.text === 'string' ? input.args.text : undefined;
4389
+ if (text === undefined || text.length > 10_000) {
4390
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text is required and must be at most 10000 characters.', { retryable: false });
4391
+ }
4392
+ trustedRequest = { kind, text };
4393
+ if (!nativeTrustedInput) {
4394
+ if (!page.keyboard!.insertText) {
4395
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE', 'This Playwright page does not expose trusted text insertion.', { retryable: false });
4396
+ }
4397
+ await page.keyboard!.insertText(text);
4398
+ }
4399
+ }
4400
+ if (nativeTrustedInput) await page.trustedInput!(trustedRequest);
4401
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
4402
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'trusted_input', `Sent trusted browser input (${kind}).`, { kind, ...(guard ? { guard } : {}) });
4403
+ });
4404
+ }
4405
+ case 'dispatch_event': {
4406
+ const target = resolveActionTarget(input.repoRoot, input.args);
4407
+ const selector = requiredString(input.args.selector, 'selector');
4408
+ const event = requiredString(input.args.event, 'event');
4409
+ if (!/^[A-Za-z][A-Za-z0-9:_-]{0,63}$/.test(event)) {
4410
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'event must be a simple DOM event name (letters, digits, colon, underscore, or hyphen; max 64 chars).', { retryable: false });
4411
+ }
4412
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4413
+ const dispatched = await page.evaluate((payload) => {
4414
+ const args = payload as { selector: string; event: string };
4415
+ const element = document.querySelector(args.selector);
4416
+ if (!element) throw new Error(`element not found: ${args.selector}`);
4417
+ const domEvent = new CustomEvent(args.event, { bubbles: true, composed: true, cancelable: true });
4418
+ const accepted = element.dispatchEvent(domEvent);
4419
+ return { accepted, defaultPrevented: domEvent.defaultPrevented };
4420
+ }, { selector, event });
4421
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
4422
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'dispatch_event', `Dispatched ${event} on ${selector}.`, {
4423
+ selector,
4424
+ event,
4425
+ dispatched,
4426
+ });
4427
+ });
4428
+ }
2662
4429
  case 'wait_for_selector': {
2663
4430
  const target = resolveActionTarget(input.repoRoot, input.args);
2664
4431
  const selector = requiredString(input.args.selector, 'selector');
2665
4432
  const state = waitForSelectorState(input.args.state);
2666
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4433
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2667
4434
  await page.waitForSelector(selector, {
2668
4435
  state,
2669
4436
  timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS),
@@ -2677,64 +4444,122 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2677
4444
  case 'attach_local_file': {
2678
4445
  const target = resolveActionTarget(input.repoRoot, input.args);
2679
4446
  const selector = requiredString(input.args.selector, 'selector');
2680
- const filePath = requiredString(input.args.file_path, 'file_path');
2681
- const resolved = resolve(input.repoRoot, filePath);
2682
- if (!existsSync(resolved)) {
2683
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'file_path does not exist.', { retryable: false });
4447
+ const single = stringValue(input.args.file_path);
4448
+ const many = stringList(input.args.file_paths);
4449
+ if (single && many) {
4450
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Use either file_path or file_paths, not both.', { retryable: false });
2684
4451
  }
2685
- if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(resolved)) {
2686
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
4452
+ const requested = many ?? (single ? [single] : []);
4453
+ if (requested.length === 0 || requested.length > 32) {
4454
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'attach_local_file requires file_path or 1-32 file_paths.', { retryable: false });
2687
4455
  }
2688
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2689
- if (connection.provider === 'macos-apple-events') {
2690
- throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_ACTION_UNSUPPORTED', 'Native Apple Events attachment does not support local file input. Use CDP or a managed browser context.', { retryable: false });
4456
+ const repositoryLexicalRoot = resolve(input.repoRoot);
4457
+ const repositoryRoot = realpathSync(input.repoRoot);
4458
+ const resolved = requested.map((filePath) => {
4459
+ const lexicalPath = resolve(input.repoRoot, filePath);
4460
+ const lexicalRelative = relative(repositoryLexicalRoot, lexicalPath);
4461
+ if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
4462
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must stay inside the repository root.', { retryable: false, details: { fileName: basename(filePath) } });
4463
+ }
4464
+ if (!existsSync(lexicalPath)) {
4465
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment does not exist: ${basename(lexicalPath)}`, { retryable: false });
4466
+ }
4467
+ const realPath = realpathSync(lexicalPath);
4468
+ const repositoryRelative = relative(repositoryRoot, realPath);
4469
+ if (repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
4470
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must resolve inside the repository root.', { retryable: false, details: { fileName: basename(lexicalPath) } });
2691
4471
  }
4472
+ if (!statSync(realPath).isFile()) {
4473
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment is not a regular file: ${basename(lexicalPath)}`, { retryable: false });
4474
+ }
4475
+ if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(realPath)) {
4476
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
4477
+ }
4478
+ return lexicalPath;
4479
+ });
4480
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2692
4481
  await page.evaluate((payload) => {
2693
- const args = payload as { selector: string; path: string };
4482
+ const args = payload as { selector: string; count: number };
2694
4483
  const el = document.querySelector(args.selector) as HTMLInputElement | null;
2695
- if (!el) throw new Error(`input not found: ${args.selector}`);
2696
- }, { selector, path: resolved });
2697
- // Prefer Playwright setInputFiles when available via evaluate fallback marker
2698
- const anyPage = page as PageLike & { setInputFiles?: (selector: string, files: string | string[]) => Promise<void> };
2699
- if (typeof anyPage.setInputFiles === 'function') {
2700
- await anyPage.setInputFiles(selector, resolved);
4484
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file') throw new Error(`file input not found: ${args.selector}`);
4485
+ if (args.count > 1 && !el.multiple) throw new Error(`file input does not allow multiple files: ${args.selector}`);
4486
+ }, { selector, count: resolved.length });
4487
+ if (typeof page.setInputFiles !== 'function') {
4488
+ throw new AssistantPluginError('PLUGIN_BROWSER_FILE_ATTACH_UNSUPPORTED', 'The selected browser provider does not support local file attachment.', { retryable: false });
2701
4489
  }
4490
+ await page.setInputFiles(selector, resolved.length === 1 ? resolved[0] : resolved);
2702
4491
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2703
- return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'attach_local_file', `Attached local file to ${selector}.`, {
4492
+ const fileNames = resolved.map((path) => basename(path));
4493
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'attach_local_file', `Attached ${resolved.length} local file(s) to ${selector}.`, {
2704
4494
  selector,
2705
- fileName: basename(resolved),
4495
+ fileCount: resolved.length,
4496
+ fileNames,
4497
+ ...(resolved.length === 1 ? { fileName: fileNames[0] } : {}),
2706
4498
  });
2707
4499
  });
2708
4500
  }
2709
4501
  case 'await_file_transfer': {
2710
4502
  const target = resolveActionTarget(input.repoRoot, input.args);
2711
4503
  const selector = requiredString(input.args.selector, 'selector');
2712
- const downloadDir = stateDir(input.repoRoot, 'downloads');
4504
+ const requestedName = stringValue(input.args.suggested_name);
4505
+ const downloadDir = browserStateDir(input.repoRoot, 'downloads');
2713
4506
  mkdirSync(downloadDir, { recursive: true });
2714
- const suggested = stringValue(input.args.suggested_name) ?? `download-${Date.now()}`;
2715
- const safeName = suggested.replace(/[^a-zA-Z0-9._-]+/g, '_');
2716
- const dest = join(downloadDir, safeName);
2717
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4507
+ return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2718
4508
  if (connection.provider === 'macos-apple-events') {
2719
4509
  throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_ACTION_UNSUPPORTED', 'Native Apple Events attachment does not support download capture. Use CDP or a managed browser context.', { retryable: false });
2720
4510
  }
2721
- await page.click(selector, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
2722
- // Best-effort artifact placeholder when Playwright download events are unavailable in mocks.
2723
- if (!existsSync(dest)) writeFileSync(dest, '');
2724
- if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(dest)) {
4511
+ if (!page.waitForEvent) {
4512
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_UNAVAILABLE', 'The selected Playwright page does not expose download events; refusing to fabricate a download artifact.', { retryable: false, details: { provider: connection.provider } });
4513
+ }
4514
+ const timeout = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
4515
+ let download: BrowserDownloadLike;
4516
+ try {
4517
+ [download] = await Promise.all([
4518
+ page.waitForEvent('download', { timeout }),
4519
+ page.click(selector, { timeout }),
4520
+ ]);
4521
+ } catch (error) {
4522
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download did not start successfully after the authorized click.', {
4523
+ retryable: true, details: { selector, cause: error instanceof Error ? error.message : String(error) },
4524
+ });
4525
+ }
4526
+ const browserSuggestedName = download.suggestedFilename();
4527
+ if (isBlockedExecutableArtifactName(browserSuggestedName) || isBlockedExecutableArtifactName(requestedName)) {
4528
+ await download.delete?.().catch(() => undefined);
4529
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Downloaded executable files are not retained or auto-opened and were discarded.', {
4530
+ retryable: false, details: { suggestedFilename: browserSuggestedName },
4531
+ });
4532
+ }
4533
+ if (download.failure) {
4534
+ const failure = await download.failure();
4535
+ if (failure) {
4536
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser reported that the download failed.', { retryable: true, details: { selector, cause: failure } });
4537
+ }
4538
+ }
4539
+ const safeName = safeDownloadArtifactName(requestedName, browserSuggestedName);
4540
+ const dest = uniqueDownloadArtifactPath(downloadDir, safeName);
4541
+ try {
4542
+ await download.saveAs(dest);
4543
+ } catch (error) {
2725
4544
  rmSync(dest, { force: true });
2726
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Downloaded executable files are not auto-opened and were discarded.', { retryable: false });
4545
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download could not be saved into Forge artifact storage.', {
4546
+ retryable: true, details: { selector, cause: error instanceof Error ? error.message : String(error) },
4547
+ });
4548
+ }
4549
+ if (!existsSync(dest)) {
4550
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download save completed without producing an artifact; refusing false success.', { retryable: true, details: { selector } });
2727
4551
  }
2728
4552
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2729
- const result = await finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'await_file_transfer', `Captured download artifact for ${selector}.`, {
4553
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'await_file_transfer', `Captured download artifact for ${selector}.`, {
2730
4554
  selector,
2731
4555
  download: {
2732
4556
  path: dest,
2733
4557
  relativePath: relative(input.repoRoot, dest),
4558
+ fileName: basename(dest),
4559
+ browserSuggestedName,
2734
4560
  autoOpened: false,
2735
4561
  },
2736
4562
  });
2737
- return result;
2738
4563
  });
2739
4564
  }
2740
4565
  default:
@@ -2757,8 +4582,16 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2757
4582
  }
2758
4583
  }
2759
4584
 
4585
+ export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
4586
+ return await withBrowserSessionAuthorityContext(
4587
+ { controllerHome: input.controllerHome, repoId: input.repoId },
4588
+ () => executeBrowserPluginActionInternal(input),
4589
+ );
4590
+ }
4591
+
2760
4592
  export const browserPluginAdapter = {
2761
4593
  pluginId: BROWSER_PLUGIN_ID,
2762
4594
  buildManifest: buildBrowserPluginManifest,
2763
4595
  executeAction: executeBrowserPluginAction,
4596
+ resolveAuthorizationContext: resolveBrowserPluginAuthorizationContext,
2764
4597
  };